From 4a012eae077a2ed31f580a69dddd9c9a8de70b8a Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 22 Apr 2026 14:53:03 +0200 Subject: [PATCH 1/4] feat: add --user-agent flag for caller-identified telemetry (#1100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in caller identifier so skills, plugins, and integrations that wrap the CLI can be distinguished from direct human usage. Exposed as `--user-agent` on every command under the `apify` entrypoint and via the `APIFY_CLI_USER_AGENT` env var (flag wins over env). The resolved value is sanitized (control chars stripped, capped at 256 chars) and attached to telemetry as `callerAgent` — named to avoid collision with Segment's existing `context.userAgent`. Scoped to the public `apify` entrypoint only; the `actor` entrypoint rejects it. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 ++++++ src/lib/command-framework/apify-command.ts | 57 +++++++++++++++++- src/lib/hooks/telemetry/trackEvent.ts | 1 + test/e2e/commands/user-agent.test.ts | 29 +++++++++ .../lib/command-framework/user-agent.test.ts | Bin 0 -> 8051 bytes 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/e2e/commands/user-agent.test.ts create mode 100644 test/local/lib/command-framework/user-agent.test.ts diff --git a/README.md b/README.md index 740eb0158..be5777bee 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,25 @@ apify telemetry disable or set the `APIFY_CLI_DISABLE_TELEMETRY=1` environment variable. +### Caller identification (`--user-agent`) + +If you embed the CLI in a tool, skill, or plugin, you can tag telemetry with a caller identifier so we can distinguish direct CLI usage from integrations. This is opt-in and purely informational — it does not change CLI behavior. + +Pass it as a flag on any command: + +```bash +apify actor call my-actor --user-agent apify-agent-skills/ultimate-scraper-1.3.0 +``` + +Or set it via environment variable, useful when wrapping the CLI: + +```bash +export APIFY_CLI_USER_AGENT=apify-agent-skills/ultimate-scraper-1.3.0 +apify actor call my-actor +``` + +The flag value takes precedence over the environment variable. When neither is set, no caller identifier is recorded. + ## Contributing Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup, code style, test categories, and PR guidelines. diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 3a0094455..708c62d17 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ +import process from 'node:process'; import type { parseArgs, ParseArgsConfig, ParseArgsOptionDescriptor } from 'node:util'; import type { Awaitable } from '@crawlee/types'; @@ -144,6 +145,39 @@ const jsonFlagDefinition = { multiple: false, } as const satisfies ParseArgsOptionDescriptor; +const userAgentFlagDefinition = { + type: 'string', + multiple: false, +} as const satisfies ParseArgsOptionDescriptor; + +export const USER_AGENT_FLAG_NAME = 'user-agent'; +export const USER_AGENT_ENV_VAR = 'APIFY_CLI_USER_AGENT'; +export const USER_AGENT_MAX_LENGTH = 256; +// Scope the caller-id flag to the public `apify` entrypoint. The `actor` entrypoint +// runs inside Actor Docker images where caller-identification is not meaningful. +const USER_AGENT_SUPPORTED_ENTRYPOINTS = new Set(['apify', 'test-cli']); + +function sanitizeUserAgentValue(value: string | undefined): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + // Strip ASCII control chars (0x00-0x1F and 0x7F) to keep telemetry payloads clean. + // eslint-disable-next-line no-control-regex + const stripped = value.replace(/[\u0000-\u001f\u007f]/g, ''); + const trimmed = stripped.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > USER_AGENT_MAX_LENGTH ? trimmed.slice(0, USER_AGENT_MAX_LENGTH) : trimmed; +} + +export function resolveUserAgentForTelemetry( + flagValue: string | undefined, + envValue: string | undefined, +): string | undefined { + return sanitizeUserAgentValue(flagValue) ?? sanitizeUserAgentValue(envValue); +} + export const commandRegistry = new Map(); type ParseResult = ReturnType>>; @@ -281,6 +315,17 @@ export abstract class ApifyCommand = { + help: helpFlagDefinition, + }; + + if (USER_AGENT_SUPPORTED_ENTRYPOINTS.has(this.entrypoint)) { + baseOptions[USER_AGENT_FLAG_NAME] = userAgentFlagDefinition; + } + const object = { allowNegative: true, allowPositionals: true, strict: true, tokens: true, - options: { - help: helpFlagDefinition, - } as { + options: baseOptions as { help: typeof helpFlagDefinition; json: typeof jsonFlagDefinition; [k: string]: ParseArgsOptionDescriptor; diff --git a/src/lib/hooks/telemetry/trackEvent.ts b/src/lib/hooks/telemetry/trackEvent.ts index 441f5b30c..09925b3ac 100644 --- a/src/lib/hooks/telemetry/trackEvent.ts +++ b/src/lib/hooks/telemetry/trackEvent.ts @@ -42,6 +42,7 @@ export interface TrackEventMap { exitCode?: number; durationMs?: number; aiAgent?: string; + callerAgent?: string; isCi?: boolean; ciProvider?: string; isInteractive?: boolean; diff --git a/test/e2e/commands/user-agent.test.ts b/test/e2e/commands/user-agent.test.ts new file mode 100644 index 000000000..45c9b4355 --- /dev/null +++ b/test/e2e/commands/user-agent.test.ts @@ -0,0 +1,29 @@ +import { runCli } from '../__helpers__/run-cli.js'; + +describe('[e2e] --user-agent flag', () => { + it('accepts --user-agent on any command without erroring', async () => { + const result = await runCli('apify', ['help', '--user-agent', 'test-caller/1.0.0']); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + }); + + it('accepts APIFY_CLI_USER_AGENT env var on any command without erroring', async () => { + const result = await runCli('apify', ['help'], { + env: { APIFY_CLI_USER_AGENT: 'test-caller/env-1.0.0' }, + }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + }); + + it('rejects --user-agent without a value (string flag)', async () => { + // Passing --user-agent with no value should surface a parseArgs error, + // since the flag is declared as a string type. + const result = await runCli('apify', ['help', '--user-agent']); + expect(result.exitCode).not.toBe(0); + }); + + it('rejects --user-agent under the actor entrypoint', async () => { + // The flag is scoped to the public apify entrypoint only — the actor + // entrypoint runs inside Actor Docker images where caller-id is meaningless. + const result = await runCli('actor', ['help', '--user-agent', 'foo']); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/test/local/lib/command-framework/user-agent.test.ts b/test/local/lib/command-framework/user-agent.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33e79818a91d110b919f16febb04331041f2e71e GIT binary patch literal 8051 zcmds6?QYvf7Hxkmq?g#)KTEkAQb`y4$E^#fiSq%ZPLMj;T{wn1B1aNyYKA*Aw5$z; z_96O&eUhF#LsAqa+mf;k&;^3nriOR!J@@O}tNs^Em@JhxOj5%~nP4-16UG@gMwl=c zIvl5X_(i{)&T^$K`@nLpjTq>OVM{jFYQ_So5^-GEGz-4!*5|sNL7t8mkJW6(W%8ig zv5Q==Z;Ldu%?}tiY`F0h$F(pjyB2Sa(1VGP_Nmh6A`>%V^#UKi{r>dLaPaK(?0k57 z_HOuY@Mitt)0cy1!?VG6r|XZu8~khd^7QQ4`SSk8vL-B_M?V_aT0`>)FCSfT%A#bWYAh$TJzS=yP2ovixx2ckJ{)rNX zNoA+0oJ2sS!_7j*Oc%0u#Bfii-^a(H%DbIqcUh@fs`$^;PCa@D={m>jp+j;Xji19f z4?n5f#=(68!$|e9Qpe2c7a6NzVDAHKn19RyoN%%Nwmd?pE#Zgz0Pq4vq8Hp1nIy=K2y=7?DtPjCq!gczm_@t%Xd)IF%x4U+lDK-CG}K^Qn+b3OHP7W`zBi zduyqs(Qhl2dK-4na=Ylyrzsqe^H?}vJKGJjQ>}{0lvzbS*jt}~F}4AIg~4wAyY=-o z*R@R`Uut0sjR>ld@c6m_FjHu_LMBL1 z*irim^~u|bh;>=Bzdejmf|H+W)mDk3l*^L?EE{pXvaT24&y9?udXpPA`2O*W7gUO^ zRvC+@T=Uop4V%%IrOgiTQJxi(RDRZf6g~XYR>S-O`h0i$0+0ytUXHZjSMRlJ2Jfxq zqpcUvS$EQ%qf?Z3JK-4o^tqB5HDZUzpSQ|yp4b5Pme5aSURai*s6zY}z;RJsjd#=Z zv(L9_x1JTvM2$K84W~}*lyS=%3}?k!*0k`nAybHY^x>Nz(jph!_O`ys{>C0XeCTlY z@0G0~!Mk^M36kFo|4mONzI8nDW!=@#7DCBiB5zpPWVUM9uDwY~EKp#JcI7|a-sFM3z>!zH z(;$Zt@x9F_4iqr9^{F6~pl2baElZRQ!SWg*n77nqghDdoQM;1ckE`M-xi#1McWVQM7geXj-Sk z@$oUc2%3HZKprXYw$NOqS!Vi0X44tBA_VQ_G!i|E{*KakNpgJtR?+URNg~&4%ta%} zYke=v(AndjD!?i#wiYRzveOWKF9Rkt;C<;B#D2#(X%q2Pu(UrZBWCO4Pa8HL}} za`aSWnfs+30kcK8YJe?2BQYwR>wNTsfrp#eF`H4qg#I#As-^W0F) zQfo!>e^Pl45226A``eqp1#z;5HG`4yF_<3vAq;=OzyqIii_VA9i?dvrl==wI9?PIdjK_-Y&P~@CrwJ@)j5qD8b?Ds;eLi}L1T91M=(yppmseMiB33&2+KZ;?;0jSBzhO(au)9C`R(kv%`BxSGivTP(uQ%txC5$Fs zLhyYWn!h$9wCFk8;O5{)v`XKXPx3iWF};@~5XKIK0HzF2l$_SZ+KkCrZ@Uq902 zKogtoa!Z@5yvQ&wU_)4CBt=XVV`Ex=nbFv`<@8kxu__#fH(Um^P(@*Ds#_d-(X}i{WZKDp%Olh}t0B zUlM3}9eQZ{UxKbvVBZJGfyTCO+Ax&1^VQcW)H7%=|LKg{q&?85LdFfOhq;)TWm8Ps z>`Y)t$G1g|=q@A{^ghO`jXg<0o7EL8)EllbxBkA7e~_bZC+UVu(C+`jT5_|&Z+;zv zszS@dkpR;aSe;`~^8L3R&im%{D4o1>U-wX_x_g_2{oX&C>%A(6QSa|52h}BOVD+l^ zZ?6GVH+}azI$*n(P7n=1vP={Fw5b+Yy}B=YP`e~;^F8l%+?Gz1ocGfe3fc%cj(kY& KYzER2VtxadR%ToP literal 0 HcmV?d00001 From 6d43fca732d77b603f35fed47f4f638a7f8767c2 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Wed, 22 Apr 2026 15:14:22 +0200 Subject: [PATCH 2/4] refactor(telemetry): rename callerAgent field to userAgent The flag is --user-agent and env var is APIFY_CLI_USER_AGENT, so the telemetry field should match. Collision with Segment's context.userAgent is not a concern because the two live in distinct objects (properties vs context). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/command-framework/apify-command.ts | 2 +- src/lib/hooks/telemetry/trackEvent.ts | 2 +- .../lib/command-framework/user-agent.test.ts | Bin 8051 -> 8035 bytes 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 708c62d17..b16b0d4c0 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -322,7 +322,7 @@ export abstract class ApifyCommandLrMhll}yvL}biBl%LF25EURVzgbO|n-R#~Y$_)X IqJd({0E@L3M*si- delta 85 zcmaEC_t|bkjwDNRVouKFJjuNfhOX302tz{p6NJGdV+LWk$ZUl$9OUIUOUZIGf_a Date: Wed, 22 Apr 2026 15:17:39 +0200 Subject: [PATCH 3/4] docs: drop --user-agent section from README Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/README.md b/README.md index be5777bee..740eb0158 100644 --- a/README.md +++ b/README.md @@ -113,25 +113,6 @@ apify telemetry disable or set the `APIFY_CLI_DISABLE_TELEMETRY=1` environment variable. -### Caller identification (`--user-agent`) - -If you embed the CLI in a tool, skill, or plugin, you can tag telemetry with a caller identifier so we can distinguish direct CLI usage from integrations. This is opt-in and purely informational — it does not change CLI behavior. - -Pass it as a flag on any command: - -```bash -apify actor call my-actor --user-agent apify-agent-skills/ultimate-scraper-1.3.0 -``` - -Or set it via environment variable, useful when wrapping the CLI: - -```bash -export APIFY_CLI_USER_AGENT=apify-agent-skills/ultimate-scraper-1.3.0 -apify actor call my-actor -``` - -The flag value takes precedence over the environment variable. When neither is set, no caller identifier is recorded. - ## Contributing Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup, code style, test categories, and PR guidelines. From b12d9a6d2043aa57a27e8baf3cd9d5186541bf91 Mon Sep 17 00:00:00 2001 From: patrikbraborec Date: Thu, 23 Apr 2026 15:17:45 +0200 Subject: [PATCH 4/4] refactor(telemetry): drop unused test-cli entrypoint from user-agent scope Why: test-cli is set by testRunCommand() for test harnessing, but no test exercises --user-agent through that path. Keeping it in the Set contradicts the comment above scoping the flag to the public apify entrypoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/command-framework/apify-command.ts | 2 +- .../lib/command-framework/user-agent.test.ts | Bin 8035 -> 8075 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index b16b0d4c0..8c64af0d0 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -155,7 +155,7 @@ export const USER_AGENT_ENV_VAR = 'APIFY_CLI_USER_AGENT'; export const USER_AGENT_MAX_LENGTH = 256; // Scope the caller-id flag to the public `apify` entrypoint. The `actor` entrypoint // runs inside Actor Docker images where caller-identification is not meaningful. -const USER_AGENT_SUPPORTED_ENTRYPOINTS = new Set(['apify', 'test-cli']); +const USER_AGENT_SUPPORTED_ENTRYPOINTS = new Set(['apify']); function sanitizeUserAgentValue(value: string | undefined): string | undefined { if (typeof value !== 'string') { diff --git a/test/local/lib/command-framework/user-agent.test.ts b/test/local/lib/command-framework/user-agent.test.ts index dbc27c6922fef9cf4c09ab72e7602e782e2db7ab..d27a89b8dd5f0f94d7715a34c4e04cc58d857acb 100644 GIT binary patch delta 99 zcmaEC*KNPy7rQ`AsR0n^8|oQ=S>}@yIYcLma?F(jam~}ff`<8dIhDG}`FSNp`8gmF f!?ei