From 86895e85e5442b4b7941ac18314890c481716b0b Mon Sep 17 00:00:00 2001 From: Brandon Reid Date: Tue, 15 Sep 2026 10:44:21 -0300 Subject: [PATCH 1/2] Record fetched URL path in doc fetch analytics Adds cmd_doc_fetch_url_path to the app_cli3_command public payload (schema 1.29) and populates it from doc fetch with the pathname only, so fetches of specific documents (e.g. the App Store self-review requirements page) can be attributed alongside the app context the global metadata hook already provides. Assisted-By: devx/56e14e24-e249-4bda-810f-42156f6e7afb --- .changeset/doc-fetch-url-path-telemetry.md | 6 ++++++ packages/cli-kit/src/public/node/metadata.ts | 3 ++- packages/cli-kit/src/public/node/monorail.ts | 5 ++++- .../src/cli/services/commands/doc/fetch.test.ts | 14 ++++++++++++++ .../cli/src/cli/services/commands/doc/fetch.ts | 5 +++++ 5 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .changeset/doc-fetch-url-path-telemetry.md diff --git a/.changeset/doc-fetch-url-path-telemetry.md b/.changeset/doc-fetch-url-path-telemetry.md new file mode 100644 index 00000000000..374a5495b95 --- /dev/null +++ b/.changeset/doc-fetch-url-path-telemetry.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': patch +'@shopify/cli-kit': patch +--- + +Record the fetched shopify.dev URL path in `doc fetch` command analytics so document-specific usage (such as the App Store self-review requirements page) can be attributed alongside the app context. diff --git a/packages/cli-kit/src/public/node/metadata.ts b/packages/cli-kit/src/public/node/metadata.ts index 2530ab6d96b..6562dc593ae 100644 --- a/packages/cli-kit/src/public/node/metadata.ts +++ b/packages/cli-kit/src/public/node/metadata.ts @@ -176,11 +176,12 @@ export function createRuntimeMetadataContainer< } // We want to track anything that ends up getting sent to monorail as `cmd_all_*`, -// `cmd_app_*`, `cmd_theme_*`, `store_*`, and `env_auto_upgrade_*` +// `cmd_app_*`, `cmd_theme_*`, `cmd_doc_*`, `store_*`, and `env_auto_upgrade_*` type CmdFieldsFromMonorail = PickByPrefix & PickByPrefix & PickByPrefix & PickByPrefix & + PickByPrefix & PickByPrefix & PickByPrefix diff --git a/packages/cli-kit/src/public/node/monorail.ts b/packages/cli-kit/src/public/node/monorail.ts index c797cfe2e88..1618a09b052 100644 --- a/packages/cli-kit/src/public/node/monorail.ts +++ b/packages/cli-kit/src/public/node/monorail.ts @@ -10,7 +10,7 @@ const url = 'https://monorail-edge.shopifysvc.com/v1/produce' type Optional = T | null // This is the topic name of the main event we log to Monorail, the command tracker -export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.28' +export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.29' export interface Schemas { [MONORAIL_COMMAND_TOPIC]: { @@ -66,6 +66,9 @@ export interface Schemas { cmd_all_timing_prompts_ms?: Optional cmd_all_timing_active_ms?: Optional + // Doc related commands + cmd_doc_fetch_url_path?: Optional + // Auto-upgrade env_auto_upgrade_enabled?: Optional env_auto_upgrade_accepted?: Optional diff --git a/packages/cli/src/cli/services/commands/doc/fetch.test.ts b/packages/cli/src/cli/services/commands/doc/fetch.test.ts index 804897640ae..f494a43fd19 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.test.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.test.ts @@ -5,9 +5,11 @@ import {outputResult} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {inTemporaryDirectory, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/metadata') const okResponse = (body: string) => ({ok: true, status: 200, statusText: 'OK', text: () => Promise.resolve(body)}) as any @@ -26,6 +28,17 @@ describe('docFetchService', () => { expect(outputResult).toHaveBeenCalledWith('# Doc') }) + test('records the fetched URL path, without query or fragment, in command analytics', async () => { + await docFetchService( + 'https://shopify.dev/docs/apps/launch/app-store-review/app-store-ai-self-review-requirements?utm=x#top', + ) + + expect(addPublicMetadata).toHaveBeenCalledTimes(1) + expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({ + cmd_doc_fetch_url_path: '/docs/apps/launch/app-store-review/app-store-ai-self-review-requirements', + }) + }) + test('accepts shopify.dev subdomains', async () => { await docFetchService('https://www.shopify.dev/docs') @@ -35,6 +48,7 @@ describe('docFetchService', () => { test('rejects URLs from disallowed hosts without fetching', async () => { await expect(docFetchService('https://example.com/docs')).rejects.toThrowError(AbortError) expect(fetch).not.toHaveBeenCalled() + expect(addPublicMetadata).not.toHaveBeenCalled() }) test('rejects malformed URLs without fetching', async () => { diff --git a/packages/cli/src/cli/services/commands/doc/fetch.ts b/packages/cli/src/cli/services/commands/doc/fetch.ts index 34b2aaefb40..8142549ef52 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.ts @@ -2,6 +2,7 @@ import {fetch} from '@shopify/cli-kit/node/http' import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {dirname, resolvePath} from '@shopify/cli-kit/node/path' // Every page on shopify.dev has a Markdown representation, which is the clean, @@ -31,6 +32,10 @@ export async function docFetchService(url: string, outputPath?: string, language throw new AbortError(`Only documents from the following hosts can be fetched: ${ALLOWED_HOSTS.join(', ')}.`) } + // Path only: it identifies which document was fetched (e.g. the App Store + // self-review requirements page) without carrying query strings or fragments. + await addPublicMetadata(() => ({cmd_doc_fetch_url_path: parsedURL.pathname})) + // shopify.dev filters Markdown code examples when Accept-Language is a // recognized programming-language key. Unrecognized values are ignored and // the unfiltered document is returned. From af9b78dfe7e16c6698191f78b8b0d8025b3d2c42 Mon Sep 17 00:00:00 2001 From: Brandon Reid Date: Tue, 15 Sep 2026 14:24:39 -0300 Subject: [PATCH 2/2] Record doc fetch URL path only after a successful response Addresses review on Shopify/monorail#24489: the field can now only hold the path of a shopify.dev document that actually resolved, never a typo'd or made-up path from a failed request. Assisted-By: devx/56e14e24-e249-4bda-810f-42156f6e7afb --- packages/cli/src/cli/services/commands/doc/fetch.test.ts | 3 ++- packages/cli/src/cli/services/commands/doc/fetch.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli/services/commands/doc/fetch.test.ts b/packages/cli/src/cli/services/commands/doc/fetch.test.ts index f494a43fd19..fd3277eb199 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.test.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.test.ts @@ -28,7 +28,7 @@ describe('docFetchService', () => { expect(outputResult).toHaveBeenCalledWith('# Doc') }) - test('records the fetched URL path, without query or fragment, in command analytics', async () => { + test('records the fetched URL path, without query or fragment, after a successful response', async () => { await docFetchService( 'https://shopify.dev/docs/apps/launch/app-store-review/app-store-ai-self-review-requirements?utm=x#top', ) @@ -84,5 +84,6 @@ describe('docFetchService', () => { await expect(docFetchService('https://shopify.dev/missing')).rejects.toThrowError(AbortError) expect(outputResult).not.toHaveBeenCalled() + expect(addPublicMetadata).not.toHaveBeenCalled() }) }) diff --git a/packages/cli/src/cli/services/commands/doc/fetch.ts b/packages/cli/src/cli/services/commands/doc/fetch.ts index 8142549ef52..4c75cbbe15a 100644 --- a/packages/cli/src/cli/services/commands/doc/fetch.ts +++ b/packages/cli/src/cli/services/commands/doc/fetch.ts @@ -32,10 +32,6 @@ export async function docFetchService(url: string, outputPath?: string, language throw new AbortError(`Only documents from the following hosts can be fetched: ${ALLOWED_HOSTS.join(', ')}.`) } - // Path only: it identifies which document was fetched (e.g. the App Store - // self-review requirements page) without carrying query strings or fragments. - await addPublicMetadata(() => ({cmd_doc_fetch_url_path: parsedURL.pathname})) - // shopify.dev filters Markdown code examples when Accept-Language is a // recognized programming-language key. Unrecognized values are ignored and // the unfiltered document is returned. @@ -51,6 +47,10 @@ export async function docFetchService(url: string, outputPath?: string, language throw new AbortError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) } + // Recorded only after a successful response, so the field can only hold the + // path of a real shopify.dev document. Path only: no query string or fragment. + await addPublicMetadata(() => ({cmd_doc_fetch_url_path: parsedURL.pathname})) + const body = await response.text() // When an output path is provided, write the document to disk (creating any