diff --git a/packages/cli/bin/cli.ts b/packages/cli/bin/cli.ts index ad6dea5..a451ecc 100644 --- a/packages/cli/bin/cli.ts +++ b/packages/cli/bin/cli.ts @@ -2,6 +2,8 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { APP_NAME, CLI_VERSION } from '@meta'; +import { flushTelemetry, telemetryMiddleware } from '@utils/index.js'; import { loginCommand, logoutCommand, @@ -18,7 +20,6 @@ import { pluginCommand, migrateCommand, } from '../src/commands/index.js'; -import { APP_NAME, CLI_VERSION } from '@meta'; const cli = yargs(hideBin(process.argv)) .scriptName(APP_NAME) @@ -65,6 +66,12 @@ const cli = yargs(hideBin(process.argv)) describe: 'Verbose/diagnostic output', hidden: true, }) + .option('telemetry', { + type: 'boolean', + default: true, + describe: 'Collect anonymous usage telemetry (disable with --no-telemetry)', + }) + .middleware(telemetryMiddleware) .command(loginCommand) .command(logoutCommand) .command(whoamiCommand) @@ -91,4 +98,8 @@ const cli = yargs(hideBin(process.argv)) .help() .version(CLI_VERSION); -cli.parse(); +try { + await cli.parseAsync(); +} finally { + await flushTelemetry(); +} diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts index 898a718..78bc1f4 100644 --- a/packages/cli/src/commands/docs.ts +++ b/packages/cli/src/commands/docs.ts @@ -1,6 +1,6 @@ import type { Argv } from 'yargs'; import { searchDocs, grepDocs, readDocs } from '@features/docs/index.js'; -import { safely } from '../utils/safely.js'; +import { safely } from '@utils/index.js'; export const docsCommand = { command: 'docs ', diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 7ce53f6..328ec58 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -8,7 +8,7 @@ import { deleteEvent, eventUsage, } from '@features/events/index.js'; -import { safely } from '../utils/safely.js'; +import { safely } from '@utils/index.js'; export const eventsCommand = { command: 'events ', diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index 896e7dc..373f226 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -10,7 +10,7 @@ import { targetFlagCmd, archiveFlagCmd, } from '@features/flags/index.js'; -import { safely } from '../utils/safely.js'; +import { safely } from '@utils/index.js'; export const flagsCommand = { command: 'flags ', diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index 0fe0c1e..9d0322a 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -1,7 +1,7 @@ import type { Argv } from 'yargs'; import { getProviders } from '@spotify-confidence/core'; import { detectAndPrint, launchMigration } from '@features/migrate/index.js'; -import { safely } from '../utils/safely.js'; +import { safely } from '@utils/index.js'; export const migrateCommand = { command: 'migrate ', diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index 6eaf199..a291edc 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -11,7 +11,7 @@ import { uninstallPluginForIde, updatePluginForIde, } from '@features/plugin/index.js'; -import { safely } from '@utils/safely.js'; +import { safely } from '@utils/index.js'; export const pluginCommand = { command: 'plugin ', diff --git a/packages/cli/src/commands/recordings.ts b/packages/cli/src/commands/recordings.ts index ab4638f..603ba14 100644 --- a/packages/cli/src/commands/recordings.ts +++ b/packages/cli/src/commands/recordings.ts @@ -10,8 +10,7 @@ import { showTargetingKeys, addTargetingKey, } from '@features/recordings/index.js'; -import { safely } from '../utils/safely.js'; -import { validateRange } from '../utils/validation.js'; +import { safely, validateRange } from '@utils/index.js'; export const recordingsCommand = { command: 'recordings ', diff --git a/packages/cli/src/features/docs/grep.ts b/packages/cli/src/features/docs/grep.ts index 5d406b6..84f4440 100644 --- a/packages/cli/src/features/docs/grep.ts +++ b/packages/cli/src/features/docs/grep.ts @@ -1,7 +1,7 @@ import ora from 'ora'; import { grepDocumentation } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const grepDocs = withAuth(async function grepDocs(argv, token) { const pattern = argv.pattern as string; diff --git a/packages/cli/src/features/docs/read.ts b/packages/cli/src/features/docs/read.ts index 72e40fc..1349ae6 100644 --- a/packages/cli/src/features/docs/read.ts +++ b/packages/cli/src/features/docs/read.ts @@ -2,7 +2,7 @@ import ora from 'ora'; import { CONFIDENCE_DOCS_URL } from '@spotify-confidence/core'; import { getFullSource } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; function normalizeSource(input: string): string { if (input.includes('://')) return input; diff --git a/packages/cli/src/features/docs/search.ts b/packages/cli/src/features/docs/search.ts index d3f6135..d28b4c1 100644 --- a/packages/cli/src/features/docs/search.ts +++ b/packages/cli/src/features/docs/search.ts @@ -1,7 +1,7 @@ import ora from 'ora'; import { searchDocumentation } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const searchDocs = withAuth(async function searchDocs(argv, token) { const query = argv.query as string; diff --git a/packages/cli/src/features/events/create.ts b/packages/cli/src/features/events/create.ts index 9d7f55d..e079a3b 100644 --- a/packages/cli/src/features/events/create.ts +++ b/packages/cli/src/features/events/create.ts @@ -1,7 +1,7 @@ import type { JsonObject } from '@spotify-confidence/shared-kernel'; import { createEventDefinition } from '@network/index.js'; import { message, printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { parseFieldArg } from './field-spec.js'; diff --git a/packages/cli/src/features/events/delete.ts b/packages/cli/src/features/events/delete.ts index 215f099..67adab8 100644 --- a/packages/cli/src/features/events/delete.ts +++ b/packages/cli/src/features/events/delete.ts @@ -2,7 +2,7 @@ import confirm from '@inquirer/confirm'; import { extractText } from '@spotify-confidence/core'; import { deleteEventDefinition } from '@network/index.js'; import { message, fail } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const deleteEvent = withAuth(async function deleteEvent(argv, token) { const name = argv.name as string; diff --git a/packages/cli/src/features/events/get.ts b/packages/cli/src/features/events/get.ts index 6388868..e67814f 100644 --- a/packages/cli/src/features/events/get.ts +++ b/packages/cli/src/features/events/get.ts @@ -1,6 +1,6 @@ import { getEventDefinition } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const getEvent = withAuth(async function getEvent(argv, token) { const name = argv.name as string; diff --git a/packages/cli/src/features/events/list.ts b/packages/cli/src/features/events/list.ts index db5ce82..f2aa0bc 100644 --- a/packages/cli/src/features/events/list.ts +++ b/packages/cli/src/features/events/list.ts @@ -1,6 +1,6 @@ import { listEventDefinitions } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const listEvents = withAuth(async function listEvents(argv, token) { const result = await listEventDefinitions(token, { diff --git a/packages/cli/src/features/events/update.ts b/packages/cli/src/features/events/update.ts index a72c9da..b7795fb 100644 --- a/packages/cli/src/features/events/update.ts +++ b/packages/cli/src/features/events/update.ts @@ -1,6 +1,6 @@ import { updateEventDefinition } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { parseFieldArg } from './field-spec.js'; export const updateEvent = withAuth(async function updateEvent(argv, token) { diff --git a/packages/cli/src/features/events/usage.ts b/packages/cli/src/features/events/usage.ts index b7f78bd..459d45f 100644 --- a/packages/cli/src/features/events/usage.ts +++ b/packages/cli/src/features/events/usage.ts @@ -1,7 +1,7 @@ import { isDefined } from '@spotify-confidence/shared-kernel'; import { queryEventsUsage } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const eventUsage = withAuth(async function eventUsage(argv, token) { const name = argv.name as string; diff --git a/packages/cli/src/features/flags/archive.ts b/packages/cli/src/features/flags/archive.ts index 53e1c31..14128f1 100644 --- a/packages/cli/src/features/flags/archive.ts +++ b/packages/cli/src/features/flags/archive.ts @@ -1,7 +1,7 @@ import confirm from '@inquirer/confirm'; import { archiveFlag } from '@network/index.js'; import { message, fail } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const archiveFlagCmd = withAuth(async function archiveFlagCmd(argv, token) { const flagKey = argv['flag-key'] as string; diff --git a/packages/cli/src/features/flags/create.ts b/packages/cli/src/features/flags/create.ts index 53de9ed..f762265 100644 --- a/packages/cli/src/features/flags/create.ts +++ b/packages/cli/src/features/flags/create.ts @@ -1,6 +1,6 @@ import { createFlag } from '@network/index.js'; import { message, printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; export const createFlagCmd = withAuth(async function createFlagCmd(argv, token) { diff --git a/packages/cli/src/features/flags/get.ts b/packages/cli/src/features/flags/get.ts index e6179ea..6b6ac4f 100644 --- a/packages/cli/src/features/flags/get.ts +++ b/packages/cli/src/features/flags/get.ts @@ -1,6 +1,6 @@ import { getFlag } from '@network/index.js'; import { resolveFormat, formatJson, extractFlags, fail, message, print } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { flagStatus } from './status.js'; export const getFlagCmd = withAuth(async function getFlagCmd(argv, token) { diff --git a/packages/cli/src/features/flags/list.ts b/packages/cli/src/features/flags/list.ts index 3f2cf5e..53f6b68 100644 --- a/packages/cli/src/features/flags/list.ts +++ b/packages/cli/src/features/flags/list.ts @@ -1,6 +1,6 @@ import { listFlags } from '@network/index.js'; import { resolveFormat, formatJson, extractFlags, fail, message, print } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { flagStatus } from './status.js'; export const listFlagsCmd = withAuth(async function listFlagsCmd(argv, token) { diff --git a/packages/cli/src/features/flags/resolve.ts b/packages/cli/src/features/flags/resolve.ts index 7879c27..9ea5826 100644 --- a/packages/cli/src/features/flags/resolve.ts +++ b/packages/cli/src/features/flags/resolve.ts @@ -1,6 +1,6 @@ import { resolveFlag } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; function parseContextPair(spec: string): [string, string] { const eqIndex = spec.indexOf('='); diff --git a/packages/cli/src/features/flags/target.ts b/packages/cli/src/features/flags/target.ts index de3384d..b17f8fc 100644 --- a/packages/cli/src/features/flags/target.ts +++ b/packages/cli/src/features/flags/target.ts @@ -9,7 +9,7 @@ import { print, printMcpResult, } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; function parseAllocations(spec: string): Record { diff --git a/packages/cli/src/features/flags/toggle.ts b/packages/cli/src/features/flags/toggle.ts index 66439d0..a92b9a6 100644 --- a/packages/cli/src/features/flags/toggle.ts +++ b/packages/cli/src/features/flags/toggle.ts @@ -1,6 +1,6 @@ import { toggleFlag } from '@network/index.js'; import { fail, message } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; export const toggleFlagCmd = withAuth(async function toggleFlagCmd(argv, token) { const flagKey = argv['flag-key'] as string; diff --git a/packages/cli/src/features/flags/update.ts b/packages/cli/src/features/flags/update.ts index e70cc39..d6328cd 100644 --- a/packages/cli/src/features/flags/update.ts +++ b/packages/cli/src/features/flags/update.ts @@ -1,7 +1,7 @@ import type { JsonObject } from '@spotify-confidence/shared-kernel'; import { getFlag, updateFlag } from '@network/index.js'; import { resolveFormat, formatJson, extractFlags, fail, message, print } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { flagStatus } from './status.js'; import { resolveInput } from '@input/index.js'; diff --git a/packages/cli/src/features/recordings/add-rule.ts b/packages/cli/src/features/recordings/add-rule.ts index 9c5db55..8c5a5f0 100644 --- a/packages/cli/src/features/recordings/add-rule.ts +++ b/packages/cli/src/features/recordings/add-rule.ts @@ -1,6 +1,6 @@ import { addRecordingRule } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys, validateRange } from '@utils/validation.js'; import { tryHandleMcpError } from './format-mcp-error.js'; diff --git a/packages/cli/src/features/recordings/add-targeting-key.ts b/packages/cli/src/features/recordings/add-targeting-key.ts index dece591..499e810 100644 --- a/packages/cli/src/features/recordings/add-targeting-key.ts +++ b/packages/cli/src/features/recordings/add-targeting-key.ts @@ -1,6 +1,6 @@ import { addContextField } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import { tryHandleMcpError } from './format-mcp-error.js'; diff --git a/packages/cli/src/features/recordings/create-policy.ts b/packages/cli/src/features/recordings/create-policy.ts index 5a2401d..42c452c 100644 --- a/packages/cli/src/features/recordings/create-policy.ts +++ b/packages/cli/src/features/recordings/create-policy.ts @@ -1,6 +1,6 @@ import { createRecordingPolicy } from '@network/index.js'; import { printMcpResult } from '@output/index.js'; -import { withAuth } from '@utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { resolveInput } from '@input/index.js'; import { requireKeys } from '@utils/validation.js'; import { tryHandleMcpError } from './format-mcp-error.js'; diff --git a/packages/cli/src/features/recordings/get-policy.ts b/packages/cli/src/features/recordings/get-policy.ts index 7ef7d7d..f80c071 100644 --- a/packages/cli/src/features/recordings/get-policy.ts +++ b/packages/cli/src/features/recordings/get-policy.ts @@ -1,7 +1,7 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; import { getRecordingPolicy } from '@network/index.js'; import { resolveFormat, formatJson, message, print, extractFlags } from '@output/index.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { tryHandleMcpError } from './format-mcp-error.js'; type PolicyDetail = { diff --git a/packages/cli/src/features/recordings/list-policies.ts b/packages/cli/src/features/recordings/list-policies.ts index 3f71bd3..e161e56 100644 --- a/packages/cli/src/features/recordings/list-policies.ts +++ b/packages/cli/src/features/recordings/list-policies.ts @@ -1,7 +1,7 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; import { listRecordingPolicies } from '@network/index.js'; import { resolveFormat, formatJson, message, print, extractFlags } from '@output/index.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { tryHandleMcpError } from './format-mcp-error.js'; type PolicyRow = { diff --git a/packages/cli/src/features/recordings/set-rule-enabled.ts b/packages/cli/src/features/recordings/set-rule-enabled.ts index 5674326..0523ffc 100644 --- a/packages/cli/src/features/recordings/set-rule-enabled.ts +++ b/packages/cli/src/features/recordings/set-rule-enabled.ts @@ -1,6 +1,6 @@ import { setRecordingRuleEnabled } from '@network/index.js'; import { printMcpResult } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { tryHandleMcpError } from './format-mcp-error.js'; function makeToggle(enabled: boolean) { diff --git a/packages/cli/src/features/recordings/show-targeting-keys.ts b/packages/cli/src/features/recordings/show-targeting-keys.ts index 13fb6a9..4d7667b 100644 --- a/packages/cli/src/features/recordings/show-targeting-keys.ts +++ b/packages/cli/src/features/recordings/show-targeting-keys.ts @@ -1,7 +1,7 @@ import { extractText, parseToolJson } from '@spotify-confidence/core'; import { getContextSchema } from '@network/index.js'; import { message, print, extractFlags } from '@output/print.js'; -import { withAuth } from '../../utils/require-auth.js'; +import { withAuth } from '@utils/index.js'; import { tryHandleMcpError } from './format-mcp-error.js'; type ContextField = { diff --git a/packages/cli/src/utils/require-auth.ts b/packages/cli/src/utils/auth.ts similarity index 100% rename from packages/cli/src/utils/require-auth.ts rename to packages/cli/src/utils/auth.ts diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts new file mode 100644 index 0000000..0d2e793 --- /dev/null +++ b/packages/cli/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './auth.js'; +export * from './safely.js'; +export * from './telemetry.js'; +export * from './validation.js'; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts new file mode 100644 index 0000000..6733d36 --- /dev/null +++ b/packages/cli/src/utils/telemetry.ts @@ -0,0 +1,11 @@ +import { initTelemetry, isTelemetryEnabled, getTelemetry, flush } from '@spotify-confidence/core'; + +export { flush as flushTelemetry }; + +export function telemetryMiddleware(argv: { telemetry: boolean; _: (string | number)[] }) { + if (!isTelemetryEnabled() || argv.telemetry === false) return; + initTelemetry({ source: 'cli' }); + + const command = argv._.join('.') || 'unknown'; + getTelemetry().track({ step: command, action: 'invoked' }); +} diff --git a/packages/core/__tests__/telemetry/telemetry.test.ts b/packages/core/__tests__/telemetry/telemetry.test.ts index b5d6074..4c01d06 100644 --- a/packages/core/__tests__/telemetry/telemetry.test.ts +++ b/packages/core/__tests__/telemetry/telemetry.test.ts @@ -38,7 +38,7 @@ describe('when telemetry is initialized', () => { }), ); - initTelemetry({ sessionId: 'test-session-id' }); + initTelemetry({ source: 'test', sessionId: 'test-session-id' }); const sut = getTelemetry(); sut.track({ step: 'welcome.menu', action: 'start' }); @@ -55,7 +55,7 @@ describe('when telemetry is initialized', () => { event_definition: 'eventDefinitions/agent-telemetry', payload: { session_id: 'test-session-id', - skill: 'wizard', + skill: 'test', step: 'welcome.menu', action: 'start', sentiment: 'neutral', @@ -81,7 +81,7 @@ describe('when telemetry is initialized', () => { }), ); - initTelemetry({ sessionId: 'region-test' }); + initTelemetry({ source: 'test', sessionId: 'region-test' }); const sut = getTelemetry(); sut.track({ step: 'warmup', action: 'init' }); @@ -109,7 +109,7 @@ describe('when telemetry is initialized', () => { }), ); - initTelemetry({ sessionId: 'defaults-test' }); + initTelemetry({ source: 'test', sessionId: 'defaults-test' }); getTelemetry().track({ step: 'test.step', action: 'test' }); await vi.waitFor(() => { @@ -133,7 +133,7 @@ describe('when telemetry is initialized', () => { }), ); - initTelemetry({ sessionId: 'explicit-test' }); + initTelemetry({ source: 'test', sessionId: 'explicit-test' }); getTelemetry().track({ step: 'done.action', action: 'exit', @@ -166,7 +166,7 @@ describe('when key acquisition fails', () => { }), ); - initTelemetry({ sessionId: 'fail-test' }); + initTelemetry({ source: 'test', sessionId: 'fail-test' }); const sut = getTelemetry(); sut.track({ step: 'welcome.menu', action: 'start' }); @@ -185,7 +185,7 @@ describe('when event publishing fails', () => { }), ); - initTelemetry({ sessionId: 'publish-fail-test' }); + initTelemetry({ source: 'test', sessionId: 'publish-fail-test' }); const sut = getTelemetry(); expect(() => sut.track({ step: 'test', action: 'test' })).not.toThrow(); diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index e3c15de..12591cf 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -2,9 +2,11 @@ export { initTelemetry, getTelemetry, track, + flush, resetTelemetry, isTelemetryEnabled, type TelemetryClient, + type TelemetryOptions, type TelemetryEvent, type TelemetrySentiment, type TelemetryCompletion, diff --git a/packages/core/src/telemetry/telemetry.ts b/packages/core/src/telemetry/telemetry.ts index 09c9652..e89ef5b 100644 --- a/packages/core/src/telemetry/telemetry.ts +++ b/packages/core/src/telemetry/telemetry.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { env, isCI } from '../system/env.js'; const TELEMETRY_KEY_URL = env( @@ -11,12 +12,15 @@ const TELEMETRY_EVENTS_URL_TEMPLATE = env( ); const TELEMETRY_EVENT_DEFINITION = 'eventDefinitions/agent-telemetry'; -const TELEMETRY_SOURCE = 'wizard'; +const TELEMETRY_TIMEOUT_MS = 3000; type TelemetryRegion = 'EU' | 'US'; +type TelemetryKeyResponse = { + clientSecret?: string; + client_secret?: string; +}; export type TelemetrySentiment = 'positive' | 'neutral' | 'confused' | 'frustrated'; - export type TelemetryCompletion = 'starting' | 'in_progress' | 'completing' | 'done'; export type TelemetryEvent = { @@ -26,22 +30,30 @@ export type TelemetryEvent = { completion?: TelemetryCompletion; }; +export type TelemetryOptions = { + source: string; + sessionId?: string; + region?: TelemetryRegion; +}; + export type TelemetryClient = { - track: (event: TelemetryEvent) => void; updateRegion: (region: TelemetryRegion) => void; + track: (event: TelemetryEvent) => void; + flush: () => Promise; }; function eventsUrl(region: TelemetryRegion): string { return TELEMETRY_EVENTS_URL_TEMPLATE.replace('{region}', region.toLowerCase()); } -function createTelemetryClient(opts: { - sessionId: string; - region?: TelemetryRegion; -}): TelemetryClient { +function createTelemetryClient(opts: TelemetryOptions): TelemetryClient { + const sessionId = opts.sessionId ?? randomUUID(); + const pending = new Set>(); + const source = opts.source; + let region = opts.region ?? 'EU'; - let clientSecret: string | null = null; - let acquirePromise: Promise | null = null; + let secret: string | null = null; + let acquire: Promise | null = null; let failed = false; async function acquireKey(): Promise { @@ -49,25 +61,30 @@ function createTelemetryClient(opts: { const res = await fetch(TELEMETRY_KEY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: opts.sessionId }), - signal: AbortSignal.timeout(5000), + body: JSON.stringify({ session_id: sessionId }), + signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS), }); + if (!res.ok) { failed = true; return; } - const data = (await res.json()) as { clientSecret?: string; client_secret?: string }; - clientSecret = data.clientSecret ?? data.client_secret ?? null; - if (!clientSecret) failed = true; + + const data = (await res.json()) as TelemetryKeyResponse; + secret = data.clientSecret ?? data.client_secret ?? null; + + if (!secret) { + failed = true; + } } catch { failed = true; } } - async function ensureKey(): Promise { - if (clientSecret) return; - if (!acquirePromise) acquirePromise = acquireKey(); - await acquirePromise; + async function ensureKey(): Promise { + if (secret) return true; + await (acquire ??= acquireKey()); + return !!secret; } async function publish(event: TelemetryEvent): Promise { @@ -77,13 +94,13 @@ function createTelemetryClient(opts: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - client_secret: clientSecret, + client_secret: secret, events: [ { event_definition: TELEMETRY_EVENT_DEFINITION, payload: { - session_id: opts.sessionId, - skill: TELEMETRY_SOURCE, + session_id: sessionId, + skill: source, step: event.step, action: event.action, sentiment: event.sentiment ?? 'neutral', @@ -94,7 +111,7 @@ function createTelemetryClient(opts: { ], send_time: now, }), - signal: AbortSignal.timeout(5000), + signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS), }); } catch { // fire-and-forget @@ -102,18 +119,28 @@ function createTelemetryClient(opts: { } async function doTrack(event: TelemetryEvent): Promise { - if (failed) return; - await ensureKey(); - if (!clientSecret) return; - await publish(event); + try { + if (failed) return; + if (!(await ensureKey())) return; + await publish(event); + } catch { + // Don't surface telemetry errors. + } } + void ensureKey(); + return { + updateRegion(updated: TelemetryRegion): void { + region = updated; + }, track(event: TelemetryEvent): void { - doTrack(event).catch(() => {}); + const p = doTrack(event).finally(() => pending.delete(p)); + pending.add(p); }, - updateRegion(newRegion: TelemetryRegion): void { - region = newRegion; + async flush(): Promise { + const timeout = new Promise((r) => setTimeout(r, TELEMETRY_TIMEOUT_MS).unref()); + await Promise.race([Promise.allSettled([...pending]), timeout]); }, }; } @@ -121,6 +148,7 @@ function createTelemetryClient(opts: { function createNoopClient(): TelemetryClient { return { track() {}, + async flush() {}, updateRegion() {}, }; } @@ -138,7 +166,7 @@ export function isTelemetryEnabled(): boolean { let client: TelemetryClient = createNoopClient(); -export function initTelemetry(opts: { sessionId: string; region?: TelemetryRegion }): void { +export function initTelemetry(opts: TelemetryOptions): void { if (!isTelemetryEnabled()) return; client = createTelemetryClient(opts); } @@ -151,6 +179,10 @@ export function track(event: TelemetryEvent): void { client.track(event); } +export async function flush(): Promise { + await client.flush(); +} + export function resetTelemetry(): void { client = createNoopClient(); } diff --git a/packages/quickstart/src/ui/start-tui.ts b/packages/quickstart/src/ui/start-tui.ts index b83c00c..8629efe 100644 --- a/packages/quickstart/src/ui/start-tui.ts +++ b/packages/quickstart/src/ui/start-tui.ts @@ -6,7 +6,7 @@ import { initTelemetry } from '@spotify-confidence/core'; export async function startTui(opts?: StoreOptions) { store.init(opts); - initTelemetry({ sessionId: $session.get().sessionId }); + initTelemetry({ source: 'wizard', sessionId: $session.get().sessionId }); const debug = $session.get().debug;