Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type CreateAppDevelopmentStoreMutation = {
createAppDevelopmentStore: {
shopAdminUrl?: string | null
shopDomain?: string | null
shopifyShopId?: string | null
userErrors?: {code?: string | null; field: string[]; message: string}[] | null
}
}
Expand Down Expand Up @@ -91,6 +92,7 @@ export const CreateAppDevelopmentStore = {
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'shopAdminUrl'}},
{kind: 'Field', name: {kind: 'Name', value: 'shopDomain'}},
{kind: 'Field', name: {kind: 'Name', value: 'shopifyShopId'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mutation CreateAppDevelopmentStore($shopName: String!, $priceLookupKey: String!,
) {
shopAdminUrl
shopDomain
shopifyShopId
userErrors {
code
field
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {createDevStore} from './create-dev-store.js'
import {recordStoreFqdnMetadata} from '../store-attribution.js'
import {describe, expect, test, vi, beforeEach} from 'vitest'

import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
Expand Down Expand Up @@ -32,13 +33,21 @@ vi.mock('@shopify/cli-kit/node/system', () => ({
sleep: vi.fn(),
}))

vi.mock('../store-attribution.js')

const defaultOrg = {id: '123', businessName: 'Test Org'}
const defaultMutationResult = {
createAppDevelopmentStore: {
shopAdminUrl: 'https://test-store.myshopify.com/admin',
shopDomain: 'test-store.myshopify.com',
userErrors: [],
},

// The default shop id is a global id because that's what Business Platform returns. Tests that
// assert on the recorded id pass their own, so the value under test sits next to its expectation.
function mutationResult(shopifyShopId: string | null = 'gid://shopify/Shop/456') {
return {
createAppDevelopmentStore: {
shopAdminUrl: 'https://test-store.myshopify.com/admin',
shopDomain: 'test-store.myshopify.com',
shopifyShopId,
userErrors: [],
},
}
}

beforeEach(() => {
Expand All @@ -52,7 +61,7 @@ beforeEach(() => {
describe('createDevStore', () => {
test('returns the polled shop domain without rendering output when summary is false', async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc)
.mockResolvedValueOnce(defaultMutationResult)
.mockResolvedValueOnce(mutationResult())
.mockResolvedValueOnce({
organization: {id: '123', storeCreation: {status: 'COMPLETE'}},
})
Expand All @@ -75,4 +84,64 @@ describe('createDevStore', () => {
expect(renderSuccess).not.toHaveBeenCalled()
expect(outputResult).not.toHaveBeenCalled()
})

test('records the created store with the numeric id decoded from the returned global id', async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc)
.mockResolvedValueOnce(mutationResult('gid://shopify/Shop/456'))
.mockResolvedValueOnce({organization: {id: '123', storeCreation: {status: 'COMPLETE'}}})

await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false})

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({
storeFqdn: 'test-store.myshopify.com',
validated: true,
storeId: '456',
})
})

test('records a shop id that is already numeric unchanged', async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc)
.mockResolvedValueOnce(mutationResult('456'))
.mockResolvedValueOnce({organization: {id: '123', storeCreation: {status: 'COMPLETE'}}})

await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false})

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({
storeFqdn: 'test-store.myshopify.com',
validated: true,
storeId: '456',
})
})

test('records the store domain without an id when no shop id is returned', async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc)
.mockResolvedValueOnce(mutationResult(null))
.mockResolvedValueOnce({organization: {id: '123', storeCreation: {status: 'COMPLETE'}}})

await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false})

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({
storeFqdn: 'test-store.myshopify.com',
validated: true,
storeId: undefined,
})
})

// The store exists server-side even when polling never reports COMPLETE, so attribution has to be
// recorded before the wait rather than after it.
test('records the created store even when polling reports a failure', async () => {
vi.mocked(businessPlatformOrganizationsRequestDoc)
.mockResolvedValueOnce(mutationResult('456'))
.mockResolvedValueOnce({organization: {id: '123', storeCreation: {status: 'FAILED'}}})

await expect(
createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false}),
).rejects.toThrow('Store creation failed with status: FAILED')

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({
storeFqdn: 'test-store.myshopify.com',
validated: true,
storeId: '456',
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
} from '../../api/graphql/business-platform-organizations/generated/poll_store_creation.js'
import {Organization} from '../../models/organization.js'
import {businessPlatformTokenRefreshHandler} from '../business-platform.js'
import {recordStoreFqdnMetadata} from '../store-attribution.js'
import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {numericIdFromGid} from '@shopify/cli-kit/common/gid'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputResult} from '@shopify/cli-kit/node/output'
import {sleep} from '@shopify/cli-kit/node/system'
Expand Down Expand Up @@ -89,11 +91,16 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<st
throw new AbortError(`Failed to create dev store: ${messages}`)
}

const {shopDomain, shopAdminUrl} = createAppDevelopmentStore
const {shopDomain, shopAdminUrl, shopifyShopId} = createAppDevelopmentStore
if (!shopDomain) {
throw new AbortError('Store creation succeeded but no shop domain was returned.')
}

// Recorded before polling so that a store which is created but never reaches COMPLETE is still
// attributed to the CLI. Business Platform can't tell the CLI apart from the dev dashboard in its
// own `store_creations` data, so command analytics are what make the split visible.
await recordStoreFqdnMetadata({storeFqdn: shopDomain, validated: true, storeId: numericShopId(shopifyShopId)})

await renderSingleTask({
title: outputContent`Waiting for store to be ready`,
task: async (updateStatus) => {
Expand Down Expand Up @@ -175,6 +182,15 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise<st
return shopDomain
}

/**
* Business Platform returns the new shop's id as a global id (`gid://shopify/Shop/123`), while store
* metadata records a numeric id. Values that are already numeric pass through unchanged.
*/
function numericShopId(shopifyShopId: string | null | undefined): string | undefined {
if (!shopifyShopId) return undefined
return shopifyShopId.startsWith('gid://') ? numericIdFromGid(shopifyShopId) : shopifyShopId
}

function pushRow(rows: InlineToken[][], label: string, value: InlineToken | undefined): void {
if (value !== undefined && value !== null && value !== '') {
rows.push([label, value])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {recordStoreFqdnMetadata} from './attribution.js'
import {recordStoreFqdnMetadata} from './store-attribution.js'
import {hashString} from '@shopify/cli-kit/node/crypto'
import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
import {beforeEach, describe, expect, test, vi} from 'vitest'
Expand All @@ -12,7 +12,7 @@ describe('store command attribution', () => {
})

test('records the sensitive, hashed, validation, and public store domain for a store fqdn', async () => {
await recordStoreFqdnMetadata('shop.myshopify.com', true)
await recordStoreFqdnMetadata({storeFqdn: 'shop.myshopify.com', validated: true})

expect(addSensitiveMetadata).toHaveBeenCalledWith(expect.any(Function))
expect(vi.mocked(addSensitiveMetadata).mock.calls[0]![0]()).toEqual({store_fqdn: 'shop.myshopify.com'})
Expand All @@ -26,7 +26,7 @@ describe('store command attribution', () => {
})

test('records the numeric store id when provided', async () => {
await recordStoreFqdnMetadata('shop.myshopify.com', true, '123')
await recordStoreFqdnMetadata({storeFqdn: 'shop.myshopify.com', validated: true, storeId: '123'})

expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({
store_fqdn_hash: 'hashed-store',
Expand All @@ -35,4 +35,14 @@ describe('store command attribution', () => {
store_id: 123,
})
})

test('omits the store id when it is not numeric', async () => {
await recordStoreFqdnMetadata({storeFqdn: 'shop.myshopify.com', validated: true, storeId: 'gid://shopify/Shop/123'})

expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({
store_fqdn_hash: 'hashed-store',
store_fqdn_validated: true,
store_domain: 'shop.myshopify.com',
})
})
})
29 changes: 29 additions & 0 deletions packages/organizations/src/cli/services/store-attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {hashString} from '@shopify/cli-kit/node/crypto'
import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
import {tryParseInt} from '@shopify/cli-kit/common/string'

interface RecordStoreFqdnMetadataOptions {
/** The store's fully qualified domain name. */
storeFqdn: string
/** Whether the fqdn was confirmed against the platform, rather than taken from user input as-is. */
validated: boolean
/** The store's numeric id as a string. Ignored when it isn't numeric. */
storeId?: string
}

/**
* Records the store a command acted on, so command analytics can be grouped by store.
*
* @param options - The store to attribute the command to.
*/
export async function recordStoreFqdnMetadata(options: RecordStoreFqdnMetadataOptions): Promise<void> {
const {storeFqdn, validated, storeId} = options

await addSensitiveMetadata(() => ({store_fqdn: storeFqdn}))
await addPublicMetadata(() => ({
store_fqdn_hash: hashString(storeFqdn),
store_fqdn_validated: validated,
store_domain: storeFqdn,
store_id: tryParseInt(storeId),
}))
}
1 change: 1 addition & 0 deletions packages/organizations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {selectOrg} from './cli/services/select.js'
export {selectOrganizationPrompt} from './cli/prompts/organization.js'
export type {Organization} from './cli/models/organization.js'
export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js'
export {recordStoreFqdnMetadata} from './cli/services/store-attribution.js'
export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js'
export type {CreateDevStoreOptions, DevStorePlan} from './cli/services/dev/create-dev-store.js'
export {devStoreNamePrompt, devStorePlanPrompt, devStoreDemoDataPrompt} from './cli/prompts/dev.js'
14 changes: 1 addition & 13 deletions packages/store/src/cli/services/store/attribution.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1 @@
import {hashString} from '@shopify/cli-kit/node/crypto'
import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata'
import {tryParseInt} from '@shopify/cli-kit/common/string'

export async function recordStoreFqdnMetadata(storeFqdn: string, validated: boolean, storeId?: string): Promise<void> {
await addSensitiveMetadata(() => ({store_fqdn: storeFqdn}))
await addPublicMetadata(() => ({
store_fqdn_hash: hashString(storeFqdn),
store_fqdn_validated: validated,
store_domain: storeFqdn,
store_id: tryParseInt(storeId),
}))
}
export {recordStoreFqdnMetadata} from '@shopify/organizations'
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('loadAdminSessionFromStoreAuth', () => {
const got = await loadAdminSessionFromStoreAuth('https://preview.myshopify.com/admin')

expect(loadStoredStoreSession).toHaveBeenCalledWith('preview.myshopify.com')
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('preview.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({storeFqdn: 'preview.myshopify.com', validated: true})
expect(setLastSeenUserId).toHaveBeenCalledWith('preview:123')
expect(got).toEqual({
adminSession: {token: 'shpat_token', storeFqdn: 'preview.myshopify.com'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export async function loadAdminSessionFromStoreAuth(store: string): Promise<{
session: StoredStoreAppSession
}> {
const session = await loadStoredStoreSession(normalizeStoreFqdn(store))
await recordStoreFqdnMetadata(session.store, true)
await recordStoreFqdnMetadata({storeFqdn: session.store, validated: true})
setLastSeenUserId(session.userId)

return {
Expand Down
20 changes: 10 additions & 10 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ describe('store auth service', () => {
}),
)
expect(presenter.success).toHaveBeenCalledWith(result)
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'shop.myshopify.com', false)
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'shop.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, {storeFqdn: 'shop.myshopify.com', validated: false})
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, {storeFqdn: 'shop.myshopify.com', validated: true})
expect(setLastSeenUserId).toHaveBeenCalledWith('42')

const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0]
Expand Down Expand Up @@ -370,8 +370,8 @@ describe('store auth service', () => {
),
).rejects.toThrow('scope lookup failed')

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: false})
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: true})
expect(setLastSeenUserId).not.toHaveBeenCalled()
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
})
Expand All @@ -398,8 +398,8 @@ describe('store auth service', () => {
),
).rejects.toThrow('callback failed')

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: false})
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: true})
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -428,8 +428,8 @@ describe('store auth service', () => {
),
).rejects.toThrow('token exchange failed')

expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('shop.myshopify.com', false)
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith('shop.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: false})
expect(recordStoreFqdnMetadata).not.toHaveBeenCalledWith({storeFqdn: 'shop.myshopify.com', validated: true})
expect(setLastSeenUserId).not.toHaveBeenCalled()
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -463,8 +463,8 @@ describe('store auth service', () => {
),
).rejects.toThrow('Shopify did not return associated user information for the online access token.')

expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'shop.myshopify.com', false)
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'shop.myshopify.com', true)
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, {storeFqdn: 'shop.myshopify.com', validated: false})
expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, {storeFqdn: 'shop.myshopify.com', validated: true})
expect(setLastSeenUserId).not.toHaveBeenCalled()
expect(setStoredStoreAppSession).not.toHaveBeenCalled()
})
Expand Down
4 changes: 2 additions & 2 deletions packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function authenticateStoreWithApp(

throwIfPreviewStore(store, resolvedDependencies)

await recordStoreFqdnMetadata(store, false)
await recordStoreFqdnMetadata({storeFqdn: store, validated: false})
const requestedScopes = parseStoreAuthScopes(input.scopes)
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
Expand Down Expand Up @@ -80,7 +80,7 @@ export async function authenticateStoreWithApp(
},
})
const tokenResponse = await bootstrap.exchangeCodeForToken(code)
await recordStoreFqdnMetadata(store, true)
await recordStoreFqdnMetadata({storeFqdn: store, validated: true})

const userId = tokenResponse.associated_user?.id?.toString()
if (!userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('prepareBulkAdminContext', () => {
const result = await prepareBulkAdminContext(store)

expect(loadStoredStoreSession).toHaveBeenCalledWith(store)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith({storeFqdn: store, validated: true})
expect(setLastSeenUserId).toHaveBeenCalledWith('42')
expect(result).toEqual({token: 'token', storeFqdn: store})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {AdminSession} from '@shopify/cli-kit/node/session'
*/
export async function prepareBulkAdminContext(store: string): Promise<AdminSession> {
const session = await loadStoredStoreSession(store)
await recordStoreFqdnMetadata(session.store, true)
await recordStoreFqdnMetadata({storeFqdn: session.store, validated: true})
setLastSeenUserId(session.userId)

return {
Expand Down
Loading
Loading