diff --git a/packages/cli-kit/src/public/node/metadata.ts b/packages/cli-kit/src/public/node/metadata.ts index 1b6223f7b4b..51d19b1e2aa 100644 --- a/packages/cli-kit/src/public/node/metadata.ts +++ b/packages/cli-kit/src/public/node/metadata.ts @@ -177,7 +177,8 @@ 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_*` -type CmdFieldsFromMonorail = PickByPrefix & +type CmdFieldsFromMonorail = Pick & + 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 4d4beb846d3..3a35faa74cd 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.22' +export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.23' export interface Schemas { [MONORAIL_COMMAND_TOPIC]: { @@ -45,6 +45,7 @@ export interface Schemas { node_version: string is_employee: boolean store_fqdn_hash?: Optional + shop_id?: Optional user_id: string // Any and all commands diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index cf4a19c5289..4bbcb131097 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,10 +1,12 @@ import {authenticateStoreWithApp} from './index.js' import {setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {describe, expect, test, vi} from 'vitest' vi.mock('./session-store.js') +vi.mock('../metrics.js') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) @@ -55,6 +57,7 @@ describe('store auth service', () => { ) expect(presenter.success).toHaveBeenCalledWith(result) expect(setLastSeenUserId).toHaveBeenCalledWith('42') + expect(recordStoreCommandShopIdFromAdminApi).toHaveBeenCalledWith({store: 'shop.myshopify.com', accessToken: 'token'}) const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 61fffc2e4af..0c786eb0bce 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -6,6 +6,7 @@ import {createPkceBootstrap} from './pkce.js' import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' +import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {openURL} from '@shopify/cli-kit/node/system' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' @@ -75,6 +76,7 @@ export async function authenticateStoreWithApp( throw new AbortError('Shopify did not return associated user information for the online access token.') } setLastSeenUserId(userId) + await recordStoreCommandShopIdFromAdminApi({store, accessToken: tokenResponse.access_token}) const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined diff --git a/packages/store/src/cli/services/store/execute/admin-context.test.ts b/packages/store/src/cli/services/store/execute/admin-context.test.ts index 492bf15d1c7..5a75d3fbfd3 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.test.ts @@ -2,11 +2,13 @@ import {prepareAdminStoreGraphQLContext} from './admin-context.js' import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' +import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js' import {AbortError} from '@shopify/cli-kit/node/error' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('../metrics.js') vi.mock('@shopify/cli-kit/node/session') vi.mock('./admin-transport.js', () => ({ fetchPublicApiVersions: vi.fn(), @@ -86,6 +88,7 @@ describe('prepareAdminStoreGraphQLContext', () => { session: storedSession, }) expect(fetchPublicApiVersions).not.toHaveBeenCalled() + expect(recordStoreCommandShopIdFromAdminApi).toHaveBeenCalledWith({store, accessToken: 'token'}) }) test('throws when the requested API version is invalid', async () => { diff --git a/packages/store/src/cli/services/store/execute/admin-context.ts b/packages/store/src/cli/services/store/execute/admin-context.ts index d1e3bbcd6f6..1c6c77ee206 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.ts @@ -1,5 +1,6 @@ import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js' import {AbortError} from '@shopify/cli-kit/node/error' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import type {AdminSession} from '@shopify/cli-kit/node/session' @@ -44,6 +45,9 @@ export async function prepareAdminStoreGraphQLContext(input: { storeFqdn: session.store, } const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) + if (version === 'unstable') { + await recordStoreCommandShopIdFromAdminApi({store: session.store, accessToken: session.accessToken}) + } return {adminSession, version, session} } diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index 50f968e8113..051b5553f2b 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -6,6 +6,7 @@ import { } from './admin-transport.js' import {clearStoredStoreAppSession} from '../auth/session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' +import {recordStoreCommandShopIdFromAdminGid} from '../metrics.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -13,6 +14,7 @@ import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {renderSingleTask} from '@shopify/cli-kit/node/ui' vi.mock('../auth/session-store.js') +vi.mock('../metrics.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -188,6 +190,7 @@ describe('fetchPublicApiVersions', () => { {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, ], + shop: {id: 'gid://shopify/Shop/123'}, }) const result = await fetchPublicApiVersions({adminSession, session}) @@ -196,6 +199,7 @@ describe('fetchPublicApiVersions', () => { {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, ]) + expect(recordStoreCommandShopIdFromAdminGid).toHaveBeenCalledWith('gid://shopify/Shop/123') expect(adminUrl).toHaveBeenCalledWith(store, 'unstable', adminSession) expect(graphqlRequest).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/store/src/cli/services/store/execute/admin-transport.ts b/packages/store/src/cli/services/store/execute/admin-transport.ts index 70347e51351..d1f6d707799 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -1,5 +1,6 @@ import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {recordStoreCommandShopIdFromAdminGid} from '../metrics.js' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' @@ -15,12 +16,22 @@ interface ApiVersion { supported: boolean } +interface PublicApiVersionsResponse { + publicApiVersions: ApiVersion[] + shop?: { + id?: string + } +} + const PUBLIC_API_VERSIONS_QUERY = ` query StoreExecutePublicApiVersions { publicApiVersions { handle supported } + shop { + id + } } ` @@ -34,13 +45,14 @@ export async function fetchPublicApiVersions(input: { session: StoredStoreAppSession }): Promise { try { - const response = await graphqlRequest<{publicApiVersions: ApiVersion[]}>({ + const response = await graphqlRequest({ query: PUBLIC_API_VERSIONS_QUERY, api: 'Admin', url: adminUrl(input.adminSession.storeFqdn, 'unstable', input.adminSession), token: input.adminSession.token, responseOptions: {handleErrors: false}, }) + await recordStoreCommandShopIdFromAdminGid(response.shop?.id) return response.publicApiVersions } catch (error) { const status = graphQLClientErrorStatus(error) diff --git a/packages/store/src/cli/services/store/metrics.test.ts b/packages/store/src/cli/services/store/metrics.test.ts index b07f4bc8a2c..bc748ccb741 100644 --- a/packages/store/src/cli/services/store/metrics.test.ts +++ b/packages/store/src/cli/services/store/metrics.test.ts @@ -1,14 +1,19 @@ -import {recordStoreFqdnMetadata} from './metrics.js' +import {recordStoreCommandShopIdFromAdminApi, recordStoreCommandShopIdFromAdminGid, recordStoreFqdnMetadata} from './metrics.js' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {hashString} from '@shopify/cli-kit/node/crypto' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {beforeEach, describe, expect, test, vi} from 'vitest' +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/crypto') vi.mock('@shopify/cli-kit/node/metadata') describe('store command metrics', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') vi.mocked(hashString).mockReturnValue('hashed-store') }) @@ -20,4 +25,33 @@ describe('store command metrics', () => { expect(hashString).toHaveBeenCalledWith('shop.myshopify.com') }) + test('records numeric shop_id from an Admin Shop gid', async () => { + await recordStoreCommandShopIdFromAdminGid('gid://shopify/Shop/123') + + expect(addPublicMetadata).toHaveBeenCalledWith(expect.any(Function)) + expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({shop_id: 123}) + }) + + test('ignores malformed Admin Shop gids', async () => { + await recordStoreCommandShopIdFromAdminGid('gid://shopify/Product/123') + + expect(addPublicMetadata).not.toHaveBeenCalled() + }) + + test('fetches and records shop_id from the Admin API', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({shop: {id: 'gid://shopify/Shop/456'}}) + + await recordStoreCommandShopIdFromAdminApi({store: 'shop.myshopify.com', accessToken: 'token'}) + + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + api: 'Admin', + token: 'token', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + responseOptions: {handleErrors: false}, + }), + ) + expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({shop_id: 456}) + }) }) diff --git a/packages/store/src/cli/services/store/metrics.ts b/packages/store/src/cli/services/store/metrics.ts index 9397ec35cb8..e22cb9dd1b6 100644 --- a/packages/store/src/cli/services/store/metrics.ts +++ b/packages/store/src/cli/services/store/metrics.ts @@ -1,6 +1,58 @@ +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {hashString} from '@shopify/cli-kit/node/crypto' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {tryParseInt} from '@shopify/cli-kit/common/string' + +const STORE_ID_QUERY = `#graphql + query StoreCommandShopId { + shop { + id + } + } +` + +interface StoreIdResponse { + shop?: { + id?: string + } +} export async function recordStoreFqdnMetadata(storeFqdn: string): Promise { await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn)})) } + +export async function recordStoreCommandShopIdFromAdminGid(shopGid: string | undefined): Promise { + const shopId = numericIdFromShopGid(shopGid) + if (shopId === undefined) return + + await addPublicMetadata(() => ({shop_id: shopId})) +} + +export async function recordStoreCommandShopIdFromAdminApi(options: { + store: string + accessToken: string +}): Promise { + try { + const response = await graphqlRequest({ + query: STORE_ID_QUERY, + api: 'Admin', + url: adminUrl(options.store, 'unstable'), + token: options.accessToken, + responseOptions: {handleErrors: false}, + }) + await recordStoreCommandShopIdFromAdminGid(response.shop?.id) + } catch (error) { + outputDebug( + outputContent`Failed to record store command shop_id for ${outputToken.raw(options.store)}: ${outputToken.raw( + error instanceof Error ? error.message : String(error), + )}`, + ) + } +} + +function numericIdFromShopGid(gid: string | undefined): number | undefined { + const id = gid?.match(/^gid:\/\/shopify\/Shop\/(\d+)$/)?.[1] + return tryParseInt(id) +}