From 215ba82d269ef2de278a4b888cc5c3a743afffaa Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 29 Apr 2026 15:24:33 -0400 Subject: [PATCH 1/2] Add store command analytics attribution --- .../cli-kit/src/public/node/session.test.ts | 12 +++++-- packages/cli-kit/src/public/node/session.ts | 9 +++++ .../store/src/cli/commands/store/auth.test.ts | 1 + .../src/cli/commands/store/execute.test.ts | 1 + .../src/cli/services/store/auth/index.test.ts | 3 ++ .../src/cli/services/store/auth/index.ts | 2 ++ .../store/execute/admin-context.test.ts | 3 ++ .../services/store/execute/admin-context.ts | 2 ++ .../src/cli/services/store/metrics.test.ts | 30 ++++++++++++++++ .../store/src/cli/services/store/metrics.ts | 11 ++++++ .../src/cli/utilities/store-command.test.ts | 35 +++++++++++++++++++ .../store/src/cli/utilities/store-command.ts | 2 ++ 12 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/store/src/cli/services/store/metrics.test.ts create mode 100644 packages/store/src/cli/services/store/metrics.ts create mode 100644 packages/store/src/cli/utilities/store-command.test.ts diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index c7ea4b627d5..810f9acc1c1 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -6,12 +6,13 @@ import { ensureAuthenticatedPartners, ensureAuthenticatedStorefront, ensureAuthenticatedThemes, + setLastSeenUserId, } from './session.js' import {getAppAutomationToken} from './environment.js' import {shopifyFetch} from './http.js' -import {ApplicationToken} from '../../private/node/session/schema.js' import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js' +import {ApplicationToken} from '../../private/node/session/schema.js' import { exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, @@ -30,10 +31,17 @@ const partnersToken: ApplicationToken = { vi.mock('../../private/node/session.js') vi.mock('../../private/node/session/exchange.js') -vi.mock('../../private/node/session/store.js') vi.mock('./environment.js') vi.mock('./http.js') +describe('store command analytics session helpers', () => { + test('sets last seen user id through the public session helper', () => { + setLastSeenUserId('store-user-id') + + expect(setLastSeenUserIdAfterAuth).toHaveBeenCalledWith('store-user-id') + }) +}) + describe('ensureAuthenticatedStorefront', () => { test('returns only storefront token if success', async () => { // Given diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 73a3f28862d..1ebffdeaf47 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -42,6 +42,15 @@ export interface Session { export type AccountInfo = UserAccountInfo | ServiceAccountInfo | UnknownAccountInfo +/** + * Records the user ID that should be attached to command analytics for this process. + * + * @param userId - User identifier to report on the command analytics event. + */ +export function setLastSeenUserId(userId: string): void { + setLastSeenUserIdAfterAuth(userId) +} + interface UserAccountInfo { type: 'UserAccount' email: string diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 684bce8876a..2082f819281 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -4,6 +4,7 @@ import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/metrics.js') vi.mock('../../services/store/auth/result.js', () => ({ createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), })) diff --git a/packages/store/src/cli/commands/store/execute.test.ts b/packages/store/src/cli/commands/store/execute.test.ts index d4b7b8b333b..a512f0243cf 100644 --- a/packages/store/src/cli/commands/store/execute.test.ts +++ b/packages/store/src/cli/commands/store/execute.test.ts @@ -5,6 +5,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/execute/index.js') vi.mock('../../services/store/execute/result.js') +vi.mock('../../services/store/metrics.js') describe('store execute command', () => { beforeEach(() => { 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 e3dc4be55c5..d886a08c509 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,9 +1,11 @@ import {authenticateStoreWithApp} from './index.js' import {setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {recordStoreCommandUserId} from '../metrics.js' import {describe, expect, test, vi} from 'vitest' vi.mock('./session-store.js') +vi.mock('../metrics.js') 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')})) @@ -52,6 +54,7 @@ describe('store auth service', () => { }), ) expect(presenter.success).toHaveBeenCalledWith(result) + expect(recordStoreCommandUserId).toHaveBeenCalledWith('42') 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 7fa23187af0..0253fdc48a8 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 {recordStoreCommandUserId} from '../metrics.js' import {openURL} from '@shopify/cli-kit/node/system' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' @@ -73,6 +74,7 @@ export async function authenticateStoreWithApp( if (!userId) { throw new AbortError('Shopify did not return associated user information for the online access token.') } + recordStoreCommandUserId(userId) 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 5fa4241820c..ac1083a1119 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,10 +2,12 @@ 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 {recordStoreCommandUserId} from '../metrics.js' import {AbortError} from '@shopify/cli-kit/node/error' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('../metrics.js') vi.mock('./admin-transport.js', () => ({ fetchPublicApiVersions: vi.fn(), // runAdminStoreGraphQLOperation isn't exercised here, but we re-export it for type completeness. @@ -37,6 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => { const result = await prepareAdminStoreGraphQLContext({store}) expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(recordStoreCommandUserId).toHaveBeenCalledWith('42') expect(fetchPublicApiVersions).toHaveBeenCalledWith({ adminSession: {token: 'token', storeFqdn: store}, session: storedSession, 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 095c08520f1..2a4f84d2389 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 {recordStoreCommandUserId} from '../metrics.js' import {AbortError} from '@shopify/cli-kit/node/error' import type {AdminSession} from '@shopify/cli-kit/node/session' import type {StoredStoreAppSession} from '../auth/session-store.js' @@ -37,6 +38,7 @@ export async function prepareAdminStoreGraphQLContext(input: { userSpecifiedVersion?: string }): Promise { const session = await loadStoredStoreSession(input.store) + recordStoreCommandUserId(session.userId) const adminSession = { token: session.accessToken, storeFqdn: session.store, diff --git a/packages/store/src/cli/services/store/metrics.test.ts b/packages/store/src/cli/services/store/metrics.test.ts new file mode 100644 index 00000000000..098bd731e89 --- /dev/null +++ b/packages/store/src/cli/services/store/metrics.test.ts @@ -0,0 +1,30 @@ +import {recordStoreCommandUserId, recordStoreFqdnMetadata} from './metrics.js' +import {hashString} from '@shopify/cli-kit/node/crypto' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/crypto') +vi.mock('@shopify/cli-kit/node/metadata') +vi.mock('@shopify/cli-kit/node/session') + +describe('store command metrics', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(hashString).mockReturnValue('hashed-store') + }) + + test('records the hashed store fqdn', async () => { + await recordStoreFqdnMetadata('shop.myshopify.com') + + expect(addPublicMetadata).toHaveBeenCalledWith(expect.any(Function)) + expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({store_fqdn_hash: 'hashed-store'}) + expect(hashString).toHaveBeenCalledWith('shop.myshopify.com') + }) + + test('sets the command analytics user id from store auth identity', () => { + recordStoreCommandUserId('42') + + expect(setLastSeenUserId).toHaveBeenCalledWith('42') + }) +}) diff --git a/packages/store/src/cli/services/store/metrics.ts b/packages/store/src/cli/services/store/metrics.ts new file mode 100644 index 00000000000..1688a9b4419 --- /dev/null +++ b/packages/store/src/cli/services/store/metrics.ts @@ -0,0 +1,11 @@ +import {hashString} from '@shopify/cli-kit/node/crypto' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' + +export async function recordStoreFqdnMetadata(storeFqdn: string): Promise { + await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn)})) +} + +export function recordStoreCommandUserId(userId: string): void { + setLastSeenUserId(userId) +} diff --git a/packages/store/src/cli/utilities/store-command.test.ts b/packages/store/src/cli/utilities/store-command.test.ts new file mode 100644 index 00000000000..5b3b51a4f4e --- /dev/null +++ b/packages/store/src/cli/utilities/store-command.test.ts @@ -0,0 +1,35 @@ +import StoreCommand from './store-command.js' +import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {hashString} from '@shopify/cli-kit/node/crypto' +import {Flags} from '@oclif/core' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/metadata') +vi.mock('@shopify/cli-kit/node/crypto') + +class TestStoreCommand extends StoreCommand { + static flags = { + store: Flags.string({required: true}), + } + + async run(): Promise { + await this.parse(TestStoreCommand) + } +} + +describe('StoreCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(hashString).mockReturnValue('hashed-store') + }) + + test('records shared store command metadata when parsing --store', async () => { + await TestStoreCommand.run(['--store', 'shop.myshopify.com']) + + expect(addSensitiveMetadata).toHaveBeenCalledWith(expect.any(Function)) + expect(vi.mocked(addSensitiveMetadata).mock.calls[0]![0]()).toEqual({store_fqdn: 'shop.myshopify.com'}) + expect(addPublicMetadata).toHaveBeenCalledWith(expect.any(Function)) + const publicMetadataCalls = vi.mocked(addPublicMetadata).mock.calls.map((call) => call[0]()) + expect(publicMetadataCalls).toContainEqual({store_fqdn_hash: 'hashed-store'}) + }) +}) diff --git a/packages/store/src/cli/utilities/store-command.ts b/packages/store/src/cli/utilities/store-command.ts index 480cb3ec1c1..90c2a061282 100644 --- a/packages/store/src/cli/utilities/store-command.ts +++ b/packages/store/src/cli/utilities/store-command.ts @@ -1,3 +1,4 @@ +import {recordStoreFqdnMetadata} from '../services/store/metrics.js' import Command, {type ArgOutput, type FlagOutput} from '@shopify/cli-kit/node/base-command' import {addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' @@ -21,6 +22,7 @@ export default abstract class StoreCommand extends Command { const storeFqdn = (result.flags as {store?: unknown}).store if (typeof storeFqdn === 'string' && storeFqdn.length > 0) { await addSensitiveMetadata(() => ({store_fqdn: storeFqdn})) + await recordStoreFqdnMetadata(storeFqdn) } return result } From c9d19370af535c05d117100a81608b2a8bdd7a65 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 4 May 2026 15:39:56 -0400 Subject: [PATCH 2/2] Inline store analytics user-id helper --- packages/store/src/cli/services/store/auth/index.test.ts | 6 +++--- packages/store/src/cli/services/store/auth/index.ts | 4 ++-- .../src/cli/services/store/execute/admin-context.test.ts | 6 +++--- .../src/cli/services/store/execute/admin-context.ts | 4 ++-- packages/store/src/cli/services/store/metrics.test.ts | 9 +-------- packages/store/src/cli/services/store/metrics.ts | 5 ----- 6 files changed, 11 insertions(+), 23 deletions(-) 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 d886a08c509..cf4a19c5289 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,11 +1,11 @@ import {authenticateStoreWithApp} from './index.js' import {setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {recordStoreCommandUserId} 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')})) @@ -54,7 +54,7 @@ describe('store auth service', () => { }), ) expect(presenter.success).toHaveBeenCalledWith(result) - expect(recordStoreCommandUserId).toHaveBeenCalledWith('42') + expect(setLastSeenUserId).toHaveBeenCalledWith('42') 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 0253fdc48a8..61fffc2e4af 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -6,7 +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 {recordStoreCommandUserId} 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' import {AbortError} from '@shopify/cli-kit/node/error' @@ -74,7 +74,7 @@ export async function authenticateStoreWithApp( if (!userId) { throw new AbortError('Shopify did not return associated user information for the online access token.') } - recordStoreCommandUserId(userId) + setLastSeenUserId(userId) 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 ac1083a1119..492bf15d1c7 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,12 +2,12 @@ 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 {recordStoreCommandUserId} 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(), // runAdminStoreGraphQLOperation isn't exercised here, but we re-export it for type completeness. @@ -39,7 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => { const result = await prepareAdminStoreGraphQLContext({store}) expect(loadStoredStoreSession).toHaveBeenCalledWith(store) - expect(recordStoreCommandUserId).toHaveBeenCalledWith('42') + expect(setLastSeenUserId).toHaveBeenCalledWith('42') expect(fetchPublicApiVersions).toHaveBeenCalledWith({ adminSession: {token: 'token', storeFqdn: store}, session: storedSession, 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 2a4f84d2389..d1e3bbcd6f6 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.ts @@ -1,7 +1,7 @@ import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {recordStoreCommandUserId} 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' import type {StoredStoreAppSession} from '../auth/session-store.js' @@ -38,7 +38,7 @@ export async function prepareAdminStoreGraphQLContext(input: { userSpecifiedVersion?: string }): Promise { const session = await loadStoredStoreSession(input.store) - recordStoreCommandUserId(session.userId) + setLastSeenUserId(session.userId) const adminSession = { token: session.accessToken, storeFqdn: session.store, diff --git a/packages/store/src/cli/services/store/metrics.test.ts b/packages/store/src/cli/services/store/metrics.test.ts index 098bd731e89..b07f4bc8a2c 100644 --- a/packages/store/src/cli/services/store/metrics.test.ts +++ b/packages/store/src/cli/services/store/metrics.test.ts @@ -1,12 +1,10 @@ -import {recordStoreCommandUserId, recordStoreFqdnMetadata} from './metrics.js' +import {recordStoreFqdnMetadata} from './metrics.js' import {hashString} from '@shopify/cli-kit/node/crypto' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/crypto') vi.mock('@shopify/cli-kit/node/metadata') -vi.mock('@shopify/cli-kit/node/session') describe('store command metrics', () => { beforeEach(() => { @@ -22,9 +20,4 @@ describe('store command metrics', () => { expect(hashString).toHaveBeenCalledWith('shop.myshopify.com') }) - test('sets the command analytics user id from store auth identity', () => { - recordStoreCommandUserId('42') - - expect(setLastSeenUserId).toHaveBeenCalledWith('42') - }) }) diff --git a/packages/store/src/cli/services/store/metrics.ts b/packages/store/src/cli/services/store/metrics.ts index 1688a9b4419..9397ec35cb8 100644 --- a/packages/store/src/cli/services/store/metrics.ts +++ b/packages/store/src/cli/services/store/metrics.ts @@ -1,11 +1,6 @@ import {hashString} from '@shopify/cli-kit/node/crypto' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' export async function recordStoreFqdnMetadata(storeFqdn: string): Promise { await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn)})) } - -export function recordStoreCommandUserId(userId: string): void { - setLastSeenUserId(userId) -}