Skip to content
5 changes: 5 additions & 0 deletions .changeset/app-context-telemetry-everywhere.md
Comment thread
isaacroldan marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Attach local app metadata to analytics for any command run inside an app project.
52 changes: 52 additions & 0 deletions packages/app/src/cli/hooks/public_metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof localAppContext>>)
})

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<unknown>)()

// 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<unknown>)()

// 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<unknown>)()

// Then
expect(localAppContext).toHaveBeenCalledOnce()
expect(result).toEqual(metadata.getAllPublicMetadata())
})
})
24 changes: 24 additions & 0 deletions packages/app/src/cli/hooks/public_metadata.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
if (metadata.getAllPublicMetadata().api_key !== undefined) return

await Promise.race([
localAppContext({directory, skipPrompts: true}),
new Promise<void>((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()
}

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -1033,6 +1034,7 @@ async function logMetadataForLoadedApp(
loadingStrategy,
appName,
appDirectory,
clientId,
sortedAppScopes,
usesWorkspaces,
)
Expand All @@ -1044,6 +1046,7 @@ async function logMetadataForLoadedAppUsingRawValues(
loadingStrategy: {usedCustomLayoutForWeb: boolean; usedCustomLayoutForExtensions: boolean},
appName: string,
appDirectory: string,
clientId: string,
sortedAppScopes: string[],
appUsesWorkspaces: boolean,
) {
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/cli/services/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
nelsonwittwer marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -188,8 +190,9 @@ interface LocalAppContextOutput {
export async function localAppContext({
directory,
userProvidedConfigName,
skipPrompts = false,
}: LocalAppContextOptions): Promise<LocalAppContextOutput> {
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'))
Expand Down
Loading