diff --git a/.changeset/app-context-telemetry-everywhere.md b/.changeset/app-context-telemetry-everywhere.md new file mode 100644 index 00000000000..d26ea7fd7d6 --- /dev/null +++ b/.changeset/app-context-telemetry-everywhere.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Attach local app metadata to analytics for any command run inside an app project. diff --git a/packages/app/src/cli/hooks/public_metadata.test.ts b/packages/app/src/cli/hooks/public_metadata.test.ts new file mode 100644 index 00000000000..1b52352b938 --- /dev/null +++ b/packages/app/src/cli/hooks/public_metadata.test.ts @@ -0,0 +1,52 @@ +import gatherPublicMetadata from './public_metadata.js' +import {localAppContext} from '../services/app-context.js' +import metadata from '../metadata.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {cwd} from '@shopify/cli-kit/node/path' + +vi.mock('../services/app-context.js') +vi.mock('@shopify/cli-kit/node/path') + +describe('gatherPublicMetadata', () => { + beforeEach(() => { + vi.mocked(cwd).mockReturnValue('/some/app/dir') + vi.mocked(localAppContext).mockResolvedValue({} as Awaited>) + }) + + test('opportunistically enriches metadata from the current directory and returns the public metadata', async () => { + // Given + vi.spyOn(metadata, 'getAllPublicMetadata').mockReturnValueOnce({}).mockReturnValue({api_key: 'from-loader'}) + + // When + const result = await (gatherPublicMetadata as () => Promise)() + + // Then + expect(localAppContext).toHaveBeenCalledWith({directory: '/some/app/dir', skipPrompts: true}) + expect(result).toEqual(metadata.getAllPublicMetadata()) + }) + + test('skips local app loading when api_key is already set', async () => { + // Given + vi.spyOn(metadata, 'getAllPublicMetadata').mockReturnValue({api_key: 'already-set'}) + + // When + const result = await (gatherPublicMetadata as () => Promise)() + + // Then + expect(localAppContext).not.toHaveBeenCalled() + expect(result).toEqual(metadata.getAllPublicMetadata()) + }) + + test('still returns metadata when best-effort app loading fails', async () => { + // Given + vi.spyOn(metadata, 'getAllPublicMetadata').mockReturnValue({}) + vi.mocked(localAppContext).mockRejectedValue(new Error('not an app')) + + // When + const result = await (gatherPublicMetadata as () => Promise)() + + // Then + expect(localAppContext).toHaveBeenCalledOnce() + expect(result).toEqual(metadata.getAllPublicMetadata()) + }) +}) diff --git a/packages/app/src/cli/hooks/public_metadata.ts b/packages/app/src/cli/hooks/public_metadata.ts index 5448a5fb40b..03b0a8bc7b2 100644 --- a/packages/app/src/cli/hooks/public_metadata.ts +++ b/packages/app/src/cli/hooks/public_metadata.ts @@ -1,7 +1,31 @@ import metadata from '../metadata.js' +import {localAppContext} from '../services/app-context.js' import {FanoutHookFunction} from '@shopify/cli-kit/node/plugins' +import {cwd} from '@shopify/cli-kit/node/path' + +const APP_CONTEXT_METADATA_TIMEOUT_MS = 3000 + +async function logAppContextMetadata(directory: string): Promise { + let timer: ReturnType | undefined + try { + if (metadata.getAllPublicMetadata().api_key !== undefined) return + + await Promise.race([ + localAppContext({directory, skipPrompts: true}), + new Promise((resolve) => { + timer = setTimeout(resolve, APP_CONTEXT_METADATA_TIMEOUT_MS) + }), + ]) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Metadata is strictly best-effort: never surface errors or affect the command. + } finally { + if (timer) clearTimeout(timer) + } +} const gatherPublicMetadata: FanoutHookFunction<'public_command_metadata', '@shopify/app'> = async () => { + await logAppContextMetadata(cwd()) return metadata.getAllPublicMetadata() } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 904db5f86fc..f402aff0bb2 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2276,6 +2276,7 @@ describe('load', () => { await loadTestingApp() expect(metadata.getAllPublicMetadata()).toMatchObject({ + api_key: 'test-client-id', project_type: 'node', env_package_manager_workspaces: false, cmd_app_all_configs_any: true, diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index e912099fb6d..28bac00d9d4 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -1025,6 +1025,7 @@ async function logMetadataForLoadedApp( const appName = app.name const appDirectory = app.directory + const clientId = app.configuration.client_id const sortedAppScopes = getAppScopesArray(app.configuration).sort() await logMetadataForLoadedAppUsingRawValues( @@ -1033,6 +1034,7 @@ async function logMetadataForLoadedApp( loadingStrategy, appName, appDirectory, + clientId, sortedAppScopes, usesWorkspaces, ) @@ -1044,6 +1046,7 @@ async function logMetadataForLoadedAppUsingRawValues( loadingStrategy: {usedCustomLayoutForWeb: boolean; usedCustomLayoutForExtensions: boolean}, appName: string, appDirectory: string, + clientId: string, sortedAppScopes: string[], appUsesWorkspaces: boolean, ) { @@ -1071,6 +1074,7 @@ async function logMetadataForLoadedAppUsingRawValues( } return { + api_key: clientId, project_type: projectType, app_extensions_any: extensionTotalCount > 0, app_extensions_breakdown: JSON.stringify(extensionsBreakdownMapping), diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 8c980f1f309..4000c37764e 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -61,10 +61,12 @@ interface LoadedAppContextOptions { * * @param directory - The directory containing the app. * @param userProvidedConfigName - The name of an existing config file in the app, if not provided, the cached/default one will be used. + * @param skipPrompts - When true, never prompts the user (e.g. to re-select a config). Required for non-interactive callers such as telemetry. */ interface LocalAppContextOptions { directory: string userProvidedConfigName?: string + skipPrompts?: boolean } /** @@ -188,8 +190,9 @@ interface LocalAppContextOutput { export async function localAppContext({ directory, userProvidedConfigName, + skipPrompts = false, }: LocalAppContextOptions): Promise { - const {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName) + const {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName, {skipPrompts}) if (activeConfig.file.errors.length > 0) { throw new AbortError(activeConfig.file.errors.map((err) => err.message).join('\n'))