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
30 changes: 29 additions & 1 deletion packages/cli-kit/src/public/node/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {reportAnalyticsEvent, recordTiming, recordError, recordRetry, recordEvent} from './analytics.js'
import {
reportAnalyticsEvent,
sendAnalyticsEventFromFile,
recordTiming,
recordError,
recordRetry,
recordEvent,
} from './analytics.js'
import * as os from './os.js'
import {
analyticsDisabled,
Expand All @@ -16,6 +23,7 @@ import {mockAndCaptureOutput} from './testing/output.js'
import {addPublicMetadata} from './metadata.js'
import {sendErrorToBugsnag} from './error-handler.js'
import {hashString} from './crypto.js'
import {exec} from './system.js'
import * as store from '../../private/node/analytics/storage.js'
import {startAnalytics} from '../../private/node/analytics.js'
import {CLI_KIT_VERSION} from '../common/version.js'
Expand All @@ -32,10 +40,12 @@ vi.mock('../../version.js')
vi.mock('./monorail.js')
vi.mock('./cli.js')
vi.mock('./error-handler.js')
vi.mock('./system.js')

describe('event tracking', () => {
const currentDate = new Date(Date.UTC(2022, 1, 1, 10, 0, 0))
let publishEventMock: MockedFunction<typeof publishMonorailEvent>
let execMock: MockedFunction<typeof exec>

beforeEach(() => {
vi.setSystemTime(currentDate)
Expand All @@ -49,6 +59,7 @@ describe('event tracking', () => {
vi.mocked(cloudEnvironment).mockReturnValue({platform: 'localhost', editor: false})
vi.mocked(os.platformAndArch).mockReturnValue({platform: 'darwin', arch: 'arm64'})
publishEventMock = vi.mocked(publishMonorailEvent).mockReturnValue(Promise.resolve({type: 'ok'}))
execMock = vi.mocked(exec).mockResolvedValue(undefined)
})

afterEach(() => {
Expand All @@ -64,6 +75,19 @@ describe('event tracking', () => {
})
}

async function sendReportedAnalyticsPayload(): Promise<void> {
expect(execMock).toHaveBeenCalledOnce()
const execArgs = execMock.mock.calls[0]![1]
expect(execArgs.slice(1, 3)).toEqual(['send-analytics', '--payload-file'])

const payloadFile = execArgs[3]
if (!payloadFile) {
throw new Error('Expected send-analytics to receive a payload file')
}

await sendAnalyticsEventFromFile(payloadFile)
}

test('sends the expected data to Monorail with cached app info', async () => {
await inProjectWithFile('package.json', async (args) => {
// Given
Expand All @@ -87,6 +111,7 @@ describe('event tracking', () => {
plugins: pluginsMap,
} as any
await reportAnalyticsEvent({config, exitMode: 'ok'})
await sendReportedAnalyticsPayload()
// Then
const version = CLI_KIT_VERSION
const expectedPayloadPublic = {
Expand Down Expand Up @@ -137,6 +162,7 @@ describe('event tracking', () => {
plugins: [],
} as any
await reportAnalyticsEvent({config, errorMessage: 'Permission denied', exitMode: 'unexpected_error'})
await sendReportedAnalyticsPayload()

// Then
const version = CLI_KIT_VERSION
Expand Down Expand Up @@ -177,6 +203,7 @@ describe('event tracking', () => {
plugins: [],
} as any
await reportAnalyticsEvent({config, exitMode: 'ok'})
await sendReportedAnalyticsPayload()

// Then
const expectedPayloadSensitive = {
Expand Down Expand Up @@ -204,6 +231,7 @@ describe('event tracking', () => {
plugins: [],
} as any
await reportAnalyticsEvent({config, exitMode: 'ok'})
await sendReportedAnalyticsPayload()

// Then
const sensitivePayload = publishEventMock.mock.calls[0]![2]
Expand Down
113 changes: 82 additions & 31 deletions packages/cli-kit/src/public/node/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,56 @@ interface ReportAnalyticsEventOptions {
exitMode: CommandExitMode
}

export async function sendAnalyticsEventFromFile(payloadFile: string): Promise<void> {
const {readFile, removeFile} = await import('./fs.js')
try {
const payloadStr = await readFile(payloadFile)
const payload = JSON.parse(payloadStr)

// remove file
await removeFile(payloadFile)

const doMonorail = async () => {
if (payload.skipMonorailAnalytics) return
const response = await publishMonorailEvent(MONORAIL_COMMAND_TOPIC, payload.public, payload.sensitive)
if (response.type === 'error') {
outputDebug(response.message)
}
}

const doOpenTelemetry = async () => {
if (payload.skipMetricAnalytics) return

const active = payload.public.cmd_all_timing_active_ms ?? 0
const network = payload.public.cmd_all_timing_network_ms ?? 0
const prompt = payload.public.cmd_all_timing_prompts_ms ?? 0

return recordMetrics(
{
skipMetricAnalytics: payload.skipMetricAnalytics,
cliVersion: payload.public.cli_version,
owningPlugin: payload.public.cmd_all_plugin ?? '@shopify/cli',
command: payload.public.command,
exitMode: payload.public.cmd_all_exit,
},
{
active,
network,
prompt,
},
)
}

await Promise.all([doMonorail(), doOpenTelemetry()])
} catch (error) {
if (error instanceof Error) {
outputDebug(`Failed to send analytics in background: ${error.message}`)
} else {
throw error
}
}
}

/**
* Report an analytics event, sending it off to Monorail -- Shopify's internal analytics service.
*
Expand All @@ -45,8 +95,7 @@ interface ReportAnalyticsEventOptions {
export async function reportAnalyticsEvent(options: ReportAnalyticsEventOptions): Promise<void> {
try {
const payload = await buildPayload(options)
if (payload === undefined) {
// Nothing to log
if (payload === undefined || payload.public.command === 'send-analytics') {
return
}

Expand All @@ -65,40 +114,42 @@ export async function reportAnalyticsEvent(options: ReportAnalyticsEventOptions)

const skipMonorailAnalytics = !alwaysLogAnalytics() && analyticsDisabled()
const skipMetricAnalytics = !alwaysLogMetrics() && analyticsDisabled()
if (skipMonorailAnalytics || skipMetricAnalytics) {
if (skipMonorailAnalytics && skipMetricAnalytics) {
outputDebug(outputContent`Skipping command analytics, payload: ${outputToken.json(payload)}`)
return
}

const doMonorail = async () => {
if (skipMonorailAnalytics) {
return
}
const response = await publishMonorailEvent(MONORAIL_COMMAND_TOPIC, payload.public, payload.sensitive)
if (response.type === 'error') {
outputDebug(response.message)
}
}
const doOpenTelemetry = async () => {
const active = payload.public.cmd_all_timing_active_ms ?? 0
const network = payload.public.cmd_all_timing_network_ms ?? 0
const prompt = payload.public.cmd_all_timing_prompts_ms ?? 0
outputDebug(outputContent`Sending command analytics in background, payload: ${outputToken.json(payload)}`)

return recordMetrics(
{
skipMetricAnalytics,
cliVersion: payload.public.cli_version,
owningPlugin: payload.public.cmd_all_plugin ?? '@shopify/cli',
command: payload.public.command,
exitMode: options.exitMode,
},
{
active,
network,
prompt,
},
)
const {joinPath} = await import('./path.js')
const {tmpdir} = await import('node:os')
const {writeFile} = await import('./fs.js')

const payloadPath = joinPath(tmpdir(), `shopify-cli-analytics-${Date.now()}.json`)

const fullPayload = {
...payload,
skipMonorailAnalytics,
skipMetricAnalytics,
}
await Promise.all([doMonorail(), doOpenTelemetry()])

await writeFile(payloadPath, JSON.stringify(fullPayload))

const {exec} = await import('./system.js')
const argv = process.argv
if (!argv[0] || !argv[1]) return
const nodeBinary = argv[0]
const shopifyBinary = argv[1]
const args = [shopifyBinary, 'send-analytics', '--payload-file', payloadPath]

// eslint-disable-next-line no-void
void exec(nodeBinary, args, {
background: true,
env: {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'},
externalErrorHandler: async (error: unknown) => {
outputDebug(`Failed to send analytics in background: ${(error as Error).message}`)
},
})

// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-kit/src/public/node/hooks/postrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ export const hook: Hook.Postrun = async ({config, Command}) => {
const command = Command.id.replace(/:/g, ' ')
outputDebug(`Completed command ${command}`)

if (!command.includes('notifications') && !command.includes('upgrade')) await autoUpgradeIfNeeded()
if (!command.includes('notifications') && !command.includes('upgrade') && !command.includes('send-analytics'))
await autoUpgradeIfNeeded()
postRunHookCompleted = true
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/src/public/node/notifications-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const COMMANDS_TO_SKIP = [
'theme:init',
'hydrogen:init',
'cache:clear',
'send-analytics',
]

function url(): string {
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5669,6 +5669,33 @@
"strict": true,
"usage": "search [query]"
},
"send-analytics": {
"aliases": [
],
"args": {
},
"enableJsonFlag": false,
"flags": {
"payload-file": {
"env": "SHOPIFY_FLAG_PAYLOAD_FILE",
"hasDynamicHelp": false,
"hidden": true,
"multiple": false,
"name": "payload-file",
"required": true,
"type": "option"
}
},
"hasDynamicHelp": false,
"hidden": true,
"hiddenAliases": [
],
"id": "send-analytics",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true
},
"store:auth": {
"aliases": [
],
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/cli/commands/send-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Command from '@shopify/cli-kit/node/base-command'
import {Flags} from '@oclif/core'
import {sendAnalyticsEventFromFile} from '@shopify/cli-kit/node/analytics'

export default class SendAnalytics extends Command {
static hidden = true

static flags = {
'payload-file': Flags.string({
hidden: true,
env: 'SHOPIFY_FLAG_PAYLOAD_FILE',
required: true,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(SendAnalytics)
await sendAnalyticsEventFromFile(flags['payload-file'])
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import VersionCommand from './cli/commands/version.js'
import Search from './cli/commands/search.js'
import Upgrade from './cli/commands/upgrade.js'
import SendAnalytics from './cli/commands/send-analytics.js'
import Logout from './cli/commands/auth/logout.js'
import Login from './cli/commands/auth/login.js'
import CommandFlags from './cli/commands/debug/command-flags.js'
Expand Down Expand Up @@ -147,6 +148,7 @@ export const COMMANDS: any = {
search: Search,
upgrade: Upgrade,
version: VersionCommand,
'send-analytics': SendAnalytics,
help: HelpCommand,
'auth:logout': Logout,
'auth:login': Login,
Expand Down
Loading