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..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,9 +1,11 @@ import {authenticateStoreWithApp} from './index.js' import {setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {describe, expect, test, vi} from 'vitest' vi.mock('./session-store.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')})) @@ -52,6 +54,7 @@ describe('store auth service', () => { }), ) expect(presenter.success).toHaveBeenCalledWith(result) + 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 7fa23187af0..61fffc2e4af 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 {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' @@ -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.') } + 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 5fa4241820c..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 @@ -3,9 +3,11 @@ import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.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('@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. @@ -37,6 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => { const result = await prepareAdminStoreGraphQLContext({store}) expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + 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 095c08520f1..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,6 +1,7 @@ import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.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' @@ -37,6 +38,7 @@ export async function prepareAdminStoreGraphQLContext(input: { userSpecifiedVersion?: string }): Promise { const session = await loadStoredStoreSession(input.store) + 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 new file mode 100644 index 00000000000..b07f4bc8a2c --- /dev/null +++ b/packages/store/src/cli/services/store/metrics.test.ts @@ -0,0 +1,23 @@ +import {recordStoreFqdnMetadata} from './metrics.js' +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/crypto') +vi.mock('@shopify/cli-kit/node/metadata') + +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') + }) + +}) 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..9397ec35cb8 --- /dev/null +++ b/packages/store/src/cli/services/store/metrics.ts @@ -0,0 +1,6 @@ +import {hashString} from '@shopify/cli-kit/node/crypto' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' + +export async function recordStoreFqdnMetadata(storeFqdn: string): Promise { + await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn)})) +} 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 }