Add AI agent detection to CLI analytics - #8523
Conversation
e5604a9 to
7e369c5
Compare
7e369c5 to
bbb47ef
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds fallback AI-agent detection to CLI analytics using @vercel/detect-agent, while preserving declared agent metadata.
Changes:
- Adds and locks the detection dependency.
- Detects, sanitizes, and reports agent names through sensitive analytics fields.
- Adds detection and analytics integration tests.
Review findings include two critical issues in analytics gating and test fixtures, four moderate issues involving send eligibility and name handling, and one documentation nit.
File summaries
| File | Description |
|---|---|
pnpm-lock.yaml |
Locks the new detection dependency. |
packages/cli-kit/src/public/node/analytics.ts |
Provides analytics skip-check behavior. |
packages/cli-kit/src/public/node/analytics.test.ts |
Tests analytics integration and opt-out behavior. |
packages/cli-kit/src/private/node/context/agent.ts |
Implements agent detection, mapping, and sanitization. |
packages/cli-kit/src/private/node/context/agent.test.ts |
Tests detection behavior and normalization. |
packages/cli-kit/src/private/node/analytics.ts |
Integrates detected variables into analytics payloads. |
packages/cli-kit/package.json |
Adds the detection dependency. |
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
packages/cli-kit/src/private/node/analytics.ts:136
env_shopify_variablesis only delivered in the Monorail payload, but this gate treatsalwaysLogMetricsas sufficient to run detection. With analytics disabled and only the metrics override enabled,monorailAnalyticsSkipped()remains true, sosendAnalyticsEventdrops the Monorail payload and the detected values are discarded after the detection work. Gate this enrichment on Monorail delivery instead; the related test should not require detection for a metrics-only send.
packages/cli-kit/src/private/node/context/agent.ts:33- The lookup happens after replacing
|, so an arbitraryAI_AGENTvalue can collide with a canonical detector name. With the pinned detector names,AI_AGENT=claude|codebecomesclaude_codeand is then remapped toclaude-code, falsely attributing it to the Shopify toolkit instead of preserving the sanitized custom name. Apply the known-agent mapping torawDetectedNamebefore sanitizing the fallback.
const detectedName = rawDetectedName.replaceAll('|', '_').trim()
return {
SHOPIFY_CLI_AGENT_INFO: `n:${toolkitAgentNamesByDetectedName[detectedName] ?? detectedName}`,
- Files reviewed: 6/7 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
2665e2c to
140c363
Compare
|
/snapit |
|
🫰✨ Thanks @gonzaloriestra! Your snapshot has been published to npm. Test the snapshot by installing your package globally: pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20260915103618Caution After installing, validate the version by running |
gonzaloriestra
left a comment
There was a problem hiding this comment.
It seems to work well 👍
I just added a suggestion
|
|
||
| try { | ||
| const detection = await determineAgent() | ||
| if (!detection.isAgent) return {} |
There was a problem hiding this comment.
Shouldn't we better return SHOPIFY_CLI_AGENT_DETECTED: 'false' in this case to distinguish from skipped or failed detection?
There was a problem hiding this comment.
Good point, and it pushed the design somewhere better than the original suggestion.
Returning 'false' here would separate "no agent" from the other two states, but it puts the marker on ~every run — no-agent is the overwhelming majority — while leaving the rare, interesting case, determineAgent() actually throwing, still silent and inferable only from total absence. And "absence" is ambiguous: it also matches an older CLI with no detection code at all.
So SHOPIFY_CLI_AGENT_DETECTED is retired in favour of one always-present SHOPIFY_CLI_AGENT_DETECTION:
| state | value |
|---|---|
| a declaration was already present, detection skipped | skipped |
| detection produced a usable name | detected |
| detection ran and found no agent | none |
| an agent was found under an unusable name | unusable_name |
determineAgent() threw |
failed |
The unusable_name case is an agent reported under a name that is nothing but tag separators or whitespace — reachable by setting AI_AGENT to such a value. It started out folded into none, but that misreports a real agent run as a non-agent one and buries an anomaly in the bucket holding ~99% of traffic, which defeats the point of adding states at all.
One caveat worth carrying into the warehouse column: this field describes only the fallback detector's own outcome, and shouldn't be used by itself to filter agent from non-agent traffic. The guard deliberately ignores the legacy singular SHOPIFY_CLI_AGENT (which the warehouse coalesces first, and which is itself on the allowlist), so a legacy-only producer can yield a single row carrying both SHOPIFY_CLI_AGENT: <their value> and SHOPIFY_CLI_AGENT_DETECTION: none. That row does have an agent — the fallback detector just didn't find it. Same applies in reverse: detected means detection found an agent, not that the resolved agent column came from detection.
Like the key it replaces, SHOPIFY_CLI_AGENT_DETECTION is deliberately kept off allowedShopifyEnvironmentVariableNames. That filter runs before the merge, so the CLI stays the only thing that can set it.
The cost is one short key on every analytics event — the price of a denominator that doesn't depend on reasoning about CLI versions.
— 🤖 AI-generated reply (Claude Code), posted on @amcaplan's behalf.
Detect the agent running the CLI with @vercel/detect-agent and report it as n:<name> inside SHOPIFY_CLI_AGENT_INFO, the packed format the Shopify AI toolkit already uses, so a detected name resolves through the same field as a declared one. Detection only fills the gap. It is skipped when a producer declared SHOPIFY_CLI_AGENT_INFO or SHOPIFY_CLI_AGENT_IDS, because writing INFO ourselves would clobber their whole packed value, not just the name. SHOPIFY_CLI_AGENT_DETECTION reports the outcome on every run, as one of detected, none, unusable_name, skipped or failed, so a detection error is measurable rather than indistinguishable from finding no agent. It is deliberately absent from the allowlist, which filters before the merge, so the CLI is the only thing that can set it. It describes the detector's own outcome, not whether the run had an agent: legacy SHOPIFY_CLI_AGENT does not suppress detection, so a legacy-only producer can yield that variable alongside none. Assisted-By: devx/105d6a35-ec6d-462e-9e36-a9fde4ec06fc Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140c363 to
a3a8c36
Compare
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationspackages/cli-kit/dist/private/node/context/agent.d.tsexport declare function detectedAgentEnvironmentVariables(env?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>;
Existing type declarationsWe found no diffs with existing type declarations |
WHY are these changes introduced?
CLI analytics can't distinguish AI-agent runs from human or scripted ones. The AI toolkit has agents declare themselves through
SHOPIFY_CLI_AGENT_INFO/SHOPIFY_CLI_AGENT_IDS; agents outside it stay invisible.https://github.com/shop/issues-develop/issues/23864
WHAT is this pull request doing?
Detect the agent with
@vercel/detect-agentand report it through the existing sensitiveenv_shopify_variablesfield, so there's no Monorail schema change. Detected names reuse the packed format producers already send —SHOPIFY_CLI_AGENT_INFO="n:<name>"— so theagentcolumn resolves identically whether declared or detected. Onlyn:is set; detection can't resolve version, provider or model.Decisions worth review:
SHOPIFY_CLI_AGENT_INFOourselves would clobber the producer's packed value, not just the name.SHOPIFY_CLI_AGENT_DETECTIONreports the outcome on every run —detected,none,unusable_name,skipped,failed— so a detection error is measurable rather than indistinguishable from finding nothing. Deliberately off the allowlist, which filters before the merge, so only the CLI can set it. It describes the detector's own outcome, so it is not a filter for agent vs non-agent traffic: legacySHOPIFY_CLI_AGENTis allowlisted and coalesced first but doesn't suppress detection, so a legacy-only producer can yield that variable alongsidenone.|becomes_in detected names — it separates tags with no escape sequence, so one could otherwise fabricate tags likev:.determineAgent()measures 15µs worst case, against an event that does a network POST.How to manually test your changes?
bin/dev.jsruns the bundle, sopnpm nx bundle --skip-nx-cachefirst. Detected, nothing declared — reports"n:codex"with"SHOPIFY_CLI_AGENT_DETECTION":"detected":env -u SHOPIFY_CLI_AGENT_INFO -u SHOPIFY_CLI_AGENT_IDS AI_AGENT=codex SHOPIFY_CLI_ALWAYS_LOG_ANALYTICS=1 node packages/cli/bin/dev.js version --verbose | grep env_shopify_variablesDeclared, which detection must leave untouched — reports the declared value with
"SHOPIFY_CLI_AGENT_DETECTION":"skipped". The declared (cursor) and detected (codex) names differ so you can see which won:Checklist
patchfor bug fixes ·minorfor new features ·majorfor breaking changes) and added a changeset withpnpm changeset add🤖 Generated with Claude Code