Skip to content

Commit 5de7d27

Browse files
authored
Merge pull request #7478 from Shopify/dlm-store-command-metrics-followup
Avoid metrics-only store analytics lookups
2 parents 0392ec5 + 61ca960 commit 5de7d27

19 files changed

Lines changed: 273 additions & 176 deletions

packages/cli-kit/src/public/node/metadata.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,7 @@ export function createRuntimeMetadataContainer<
177177

178178
// We want to track anything that ends up getting sent to monorail as `cmd_all_*`,
179179
// `cmd_app_*`, `cmd_theme_*`, `store_*`, and `env_auto_upgrade_*`
180-
type CmdFieldsFromMonorail = Pick<MonorailEventPublic, 'shop_id'> &
181-
PickByPrefix<MonorailEventPublic, 'cmd_all_'> &
180+
type CmdFieldsFromMonorail = PickByPrefix<MonorailEventPublic, 'cmd_all_'> &
182181
PickByPrefix<MonorailEventPublic, 'cmd_app_'> &
183182
PickByPrefix<MonorailEventPublic, 'cmd_create_app_'> &
184183
PickByPrefix<MonorailEventPublic, 'cmd_theme_'> &

packages/cli-kit/src/public/node/monorail.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as http from './http.js'
2-
import {publishMonorailEvent} from './monorail.js'
2+
import {MONORAIL_COMMAND_TOPIC, publishMonorailEvent} from './monorail.js'
33
import {mockAndCaptureOutput} from './testing/output.js'
44
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
55

@@ -52,4 +52,59 @@ describe('monorail', () => {
5252
expect(outputMock.debug()).toContain('"api_key": "****"')
5353
expect(outputMock.debug()).not.toContain('some-api-key')
5454
})
55+
56+
test('builds a request for the real command topic including validated store attribution', async () => {
57+
const res = await publishMonorailEvent(
58+
MONORAIL_COMMAND_TOPIC,
59+
{
60+
command: 'shopify store auth',
61+
time_start: 1643709600000,
62+
time_end: 1643709601000,
63+
total_time: 1000,
64+
success: true,
65+
cli_version: '3.94.0',
66+
uname: 'Darwin test',
67+
ruby_version: '3.3.0',
68+
node_version: '22.0.0',
69+
is_employee: false,
70+
user_id: '42',
71+
store_fqdn_hash: 'hashed-store',
72+
store_fqdn_validated: true,
73+
},
74+
{
75+
args: '--store shop.myshopify.com',
76+
store_fqdn: 'shop.myshopify.com',
77+
},
78+
)
79+
80+
expect(res.type).toEqual('ok')
81+
expect(http.fetch).toHaveBeenCalledWith(
82+
expectedURL,
83+
{
84+
method: 'POST',
85+
body: JSON.stringify({
86+
schema_id: MONORAIL_COMMAND_TOPIC,
87+
payload: {
88+
command: 'shopify store auth',
89+
time_start: 1643709600000,
90+
time_end: 1643709601000,
91+
total_time: 1000,
92+
success: true,
93+
cli_version: '3.94.0',
94+
uname: 'Darwin test',
95+
ruby_version: '3.3.0',
96+
node_version: '22.0.0',
97+
is_employee: false,
98+
user_id: '42',
99+
store_fqdn_hash: 'hashed-store',
100+
store_fqdn_validated: true,
101+
args: '--store shop.myshopify.com',
102+
store_fqdn: 'shop.myshopify.com',
103+
},
104+
}),
105+
headers: expectedHeaders,
106+
},
107+
'non-blocking',
108+
)
109+
})
55110
})

packages/cli-kit/src/public/node/monorail.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const url = 'https://monorail-edge.shopifysvc.com/v1/produce'
1010
type Optional<T> = T | null
1111

1212
// This is the topic name of the main event we log to Monorail, the command tracker
13-
export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.23'
13+
export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.24'
1414

1515
export interface Schemas {
1616
[MONORAIL_COMMAND_TOPIC]: {
@@ -45,7 +45,7 @@ export interface Schemas {
4545
node_version: string
4646
is_employee: boolean
4747
store_fqdn_hash?: Optional<string>
48-
shop_id?: Optional<number>
48+
store_fqdn_validated?: Optional<boolean>
4949
user_id: string
5050

5151
// Any and all commands

packages/store/src/cli/commands/store/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
44
import {describe, expect, test, vi} from 'vitest'
55

66
vi.mock('../../services/store/auth/index.js')
7-
vi.mock('../../services/store/metrics.js')
7+
vi.mock('../../services/store/attribution.js')
88
vi.mock('../../services/store/auth/result.js', () => ({
99
createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})),
1010
}))

packages/store/src/cli/commands/store/execute.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'
55

66
vi.mock('../../services/store/execute/index.js')
77
vi.mock('../../services/store/execute/result.js')
8-
vi.mock('../../services/store/metrics.js')
8+
vi.mock('../../services/store/attribution.js')
99

1010
describe('store execute command', () => {
1111
beforeEach(() => {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {recordStoreFqdnMetadata} from './attribution.js'
2+
import {hashString} from '@shopify/cli-kit/node/crypto'
3+
import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
4+
import {beforeEach, describe, expect, test, vi} from 'vitest'
5+
6+
vi.mock('@shopify/cli-kit/node/crypto')
7+
vi.mock('@shopify/cli-kit/node/metadata')
8+
9+
describe('store command attribution', () => {
10+
beforeEach(() => {
11+
vi.clearAllMocks()
12+
vi.mocked(hashString).mockReturnValue('hashed-store')
13+
})
14+
15+
test('records the sensitive, hashed, and validation state for a store fqdn', async () => {
16+
await recordStoreFqdnMetadata('shop.myshopify.com', true)
17+
18+
expect(addSensitiveMetadata).toHaveBeenCalledWith(expect.any(Function))
19+
expect(vi.mocked(addSensitiveMetadata).mock.calls[0]![0]()).toEqual({store_fqdn: 'shop.myshopify.com'})
20+
expect(addPublicMetadata).toHaveBeenCalledWith(expect.any(Function))
21+
expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({
22+
store_fqdn_hash: 'hashed-store',
23+
store_fqdn_validated: true,
24+
})
25+
expect(hashString).toHaveBeenCalledWith('shop.myshopify.com')
26+
})
27+
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {hashString} from '@shopify/cli-kit/node/crypto'
2+
import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
3+
4+
export async function recordStoreFqdnMetadata(storeFqdn: string, validated: boolean): Promise<void> {
5+
await addSensitiveMetadata(() => ({store_fqdn: storeFqdn}))
6+
await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn), store_fqdn_validated: validated}))
7+
}

packages/store/src/cli/services/store/auth/index.test.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import {authenticateStoreWithApp} from './index.js'
22
import {setStoredStoreAppSession} from './session-store.js'
33
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
4-
import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js'
4+
import {recordStoreFqdnMetadata} from '../attribution.js'
55
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
66
import {describe, expect, test, vi} from 'vitest'
77

88
vi.mock('./session-store.js')
9-
vi.mock('../metrics.js')
9+
vi.mock('../attribution.js')
1010
vi.mock('@shopify/cli-kit/node/session')
1111
vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)}))
1212
vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')}))
@@ -56,8 +56,9 @@ describe('store auth service', () => {
5656
}),
5757
)
5858
expect(presenter.success).toHaveBeenCalledWith(result)
59+
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'shop.myshopify.com', false)
60+
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'shop.myshopify.com', true)
5961
expect(setLastSeenUserId).toHaveBeenCalledWith('42')
60-
expect(recordStoreCommandShopIdFromAdminApi).toHaveBeenCalledWith({store: 'shop.myshopify.com', accessToken: 'token'})
6162

6263
const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0]
6364
expect(storedSession.store).toBe('shop.myshopify.com')
@@ -271,6 +272,123 @@ describe('store auth service', () => {
271272
expect(presenter.success).toHaveBeenCalledWith(result)
272273
})
273274

275+
test('authenticateStoreWithApp records fqdn metadata before resolving existing scopes', async () => {
276+
await expect(
277+
authenticateStoreWithApp(
278+
{
279+
store: 'shop.myshopify.com',
280+
scopes: 'read_products',
281+
},
282+
{
283+
resolveExistingScopes: vi.fn().mockRejectedValue(new Error('scope lookup failed')),
284+
presenter: {
285+
openingBrowser: vi.fn(),
286+
manualAuthUrl: vi.fn(),
287+
success: vi.fn(),
288+
},
289+
},
290+
),
291+
).rejects.toThrow('scope lookup failed')
292+
293+
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
294+
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
295+
expect(setLastSeenUserId).not.toHaveBeenCalled()
296+
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
297+
})
298+
299+
test('authenticateStoreWithApp records fqdn metadata before waiting for the auth callback', async () => {
300+
const waitForStoreAuthCodeMock = vi.fn().mockRejectedValue(new Error('callback failed'))
301+
302+
await expect(
303+
authenticateStoreWithApp(
304+
{
305+
store: 'shop.myshopify.com',
306+
scopes: 'read_products',
307+
},
308+
{
309+
openURL: vi.fn().mockResolvedValue(true),
310+
waitForStoreAuthCode: waitForStoreAuthCodeMock,
311+
exchangeStoreAuthCodeForToken: vi.fn(),
312+
presenter: {
313+
openingBrowser: vi.fn(),
314+
manualAuthUrl: vi.fn(),
315+
success: vi.fn(),
316+
},
317+
},
318+
),
319+
).rejects.toThrow('callback failed')
320+
321+
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
322+
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
323+
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
324+
})
325+
326+
test('authenticateStoreWithApp does not mark the store validated when token exchange fails', async () => {
327+
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
328+
await options.onListening?.()
329+
return 'abc123'
330+
})
331+
332+
await expect(
333+
authenticateStoreWithApp(
334+
{
335+
store: 'shop.myshopify.com',
336+
scopes: 'read_products',
337+
},
338+
{
339+
openURL: vi.fn().mockResolvedValue(true),
340+
waitForStoreAuthCode: waitForStoreAuthCodeMock,
341+
exchangeStoreAuthCodeForToken: vi.fn().mockRejectedValue(new Error('token exchange failed')),
342+
presenter: {
343+
openingBrowser: vi.fn(),
344+
manualAuthUrl: vi.fn(),
345+
success: vi.fn(),
346+
},
347+
},
348+
),
349+
).rejects.toThrow('token exchange failed')
350+
351+
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
352+
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
353+
expect(setLastSeenUserId).not.toHaveBeenCalled()
354+
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
355+
})
356+
357+
test('authenticateStoreWithApp marks the store validated after token exchange but before rejecting missing associated user data', async () => {
358+
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
359+
await options.onListening?.()
360+
return 'abc123'
361+
})
362+
363+
await expect(
364+
authenticateStoreWithApp(
365+
{
366+
store: 'shop.myshopify.com',
367+
scopes: 'read_products',
368+
},
369+
{
370+
openURL: vi.fn().mockResolvedValue(true),
371+
waitForStoreAuthCode: waitForStoreAuthCodeMock,
372+
exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({
373+
access_token: 'token',
374+
scope: 'read_products',
375+
expires_in: 86400,
376+
}),
377+
presenter: {
378+
openingBrowser: vi.fn(),
379+
manualAuthUrl: vi.fn(),
380+
success: vi.fn(),
381+
},
382+
},
383+
),
384+
).rejects.toThrow('Shopify did not return associated user information for the online access token.')
385+
386+
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'shop.myshopify.com', false)
387+
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'shop.myshopify.com', true)
388+
expect(setLastSeenUserId).not.toHaveBeenCalled()
389+
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
390+
})
391+
274392
test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => {
275393
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
276394
await options.onListening?.()

packages/store/src/cli/services/store/auth/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {createPkceBootstrap} from './pkce.js'
66
import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js'
77
import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js'
88
import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js'
9-
import {recordStoreCommandShopIdFromAdminApi} from '../metrics.js'
9+
import {recordStoreFqdnMetadata} from '../attribution.js'
1010
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
1111
import {openURL} from '@shopify/cli-kit/node/system'
1212
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
@@ -40,6 +40,7 @@ export async function authenticateStoreWithApp(
4040
): Promise<StoreAuthResult> {
4141
const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies}
4242
const store = normalizeStoreFqdn(input.store)
43+
await recordStoreFqdnMetadata(store, false)
4344
const requestedScopes = parseStoreAuthScopes(input.scopes)
4445
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
4546
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
@@ -70,13 +71,13 @@ export async function authenticateStoreWithApp(
7071
},
7172
})
7273
const tokenResponse = await bootstrap.exchangeCodeForToken(code)
74+
await recordStoreFqdnMetadata(store, true)
7375

7476
const userId = tokenResponse.associated_user?.id?.toString()
7577
if (!userId) {
7678
throw new AbortError('Shopify did not return associated user information for the online access token.')
7779
}
7880
setLastSeenUserId(userId)
79-
await recordStoreCommandShopIdFromAdminApi({store, accessToken: tokenResponse.access_token})
8081

8182
const now = Date.now()
8283
const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined

0 commit comments

Comments
 (0)