From 759144f0fe839203c56920fcee836e3e0d684f61 Mon Sep 17 00:00:00 2001 From: agusayerza Date: Fri, 3 Jul 2026 23:04:04 +0200 Subject: [PATCH 1/2] feat(telemetry): implement CLI usage tracking - Added telemetry support for CLI commands to track usage events. - Introduced device ID generation and storage for anonymous tracking. - Updated CLI options to enable/disable telemetry. - Created a new endpoint for receiving telemetry data on the server. - Enhanced headers in HTTP requests to include device ID for tracking. This change aims to improve insights into CLI usage patterns while respecting user privacy preferences. --- packages/cli/lib/index.ts | 30 +++++++++++- .../cli/lib/services/telemetry.service.ts | 34 ++++++++++++++ packages/cli/lib/state.ts | 14 ++++++ packages/cli/lib/types.ts | 1 + packages/cli/lib/utils.ts | 31 ++++++++++-- packages/cli/lib/zeroYaml/deploy.ts | 4 +- .../lib/controllers/cli/postTelemetry.ts | 47 +++++++++++++++++++ .../lib/controllers/sync/deploy/postDeploy.ts | 12 ++++- .../server/lib/middleware/cliVersionCheck.ts | 17 +++++++ packages/server/lib/routes.public.ts | 5 ++ packages/shared/lib/utils/productTracking.ts | 33 ++++++++++++- packages/types/lib/api.endpoints.ts | 2 + packages/types/lib/cli/api.ts | 25 ++++++++++ packages/types/lib/index.ts | 1 + 14 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 packages/cli/lib/services/telemetry.service.ts create mode 100644 packages/server/lib/controllers/cli/postTelemetry.ts create mode 100644 packages/types/lib/cli/api.ts diff --git a/packages/cli/lib/index.ts b/packages/cli/lib/index.ts index 62791c6ca2..d8be64d19d 100644 --- a/packages/cli/lib/index.ts +++ b/packages/cli/lib/index.ts @@ -21,6 +21,7 @@ import { Ensure } from './services/ensure.service.js'; import { create } from './services/function-create.service.js'; import { inferIntegrationsFromConnectionId } from './services/interactive.service.js'; import { pullFromCatalog, pullFunction } from './services/pull.service.js'; +import { trackCliEvent } from './services/telemetry.service.js'; import { generateTests } from './services/test.service.js'; import verificationService from './services/verification.service.js'; import { getNangoRootPath, isCI, printDebug, upgradeAction } from './utils.js'; @@ -34,7 +35,21 @@ import { initZero } from './zeroYaml/init.js'; import { ReadableError } from './zeroYaml/utils.js'; import type { DeployOptions, GlobalOptions } from './types.js'; -import type { NangoYamlParsed, ScriptTypeLiteral } from '@nangohq/types'; +import type { CliTelemetryEvent, NangoYamlParsed, ScriptTypeLiteral } from '@nangohq/types'; + +const commandTelemetryEvents: Record = { + init: 'cli:init', + create: 'cli:create', + compile: 'cli:compile', + dev: 'cli:dev', + dryrun: 'cli:dryrun', + 'generate:docs': 'cli:generate:docs', + 'generate:tests': 'cli:generate:tests', + clone: 'cli:clone', + 'migrate-to-zero-yaml': 'cli:migrate_to_zero_yaml', + deploy: 'cli:deploy', + pull: 'cli:pull' +}; class NangoCommand extends Command { override createCommand(name: string) { @@ -46,6 +61,7 @@ class NangoCommand extends Command { // Passing --no-interactive will set it to false. cmd.option('--no-interactive', 'Disable interactive prompts for missing arguments.'); cmd.option('--no-dependency-update', 'Skip automatic dependency updates and package installation.'); + cmd.option('--no-telemetry', 'Disable anonymous CLI usage telemetry.'); cmd.hook('preAction', async function (this: Command, actionCommand: Command) { const opts = actionCommand.opts(); @@ -77,6 +93,18 @@ class NangoCommand extends Command { } printDebug(`Dependency update: ${opts.dependencyUpdate ? 'enabled' : 'disabled'}`, opts.debug); + if (opts.telemetry === false) { + process.env['NANGO_CLI_TELEMETRY'] = 'false'; + } + if (opts.debug) { + process.env['NANGO_CLI_DEBUG'] = 'true'; + } + + const telemetryEvent = commandTelemetryEvents[actionCommand.name()]; + if (telemetryEvent) { + trackCliEvent(telemetryEvent); + } + if (opts.debug && fs.existsSync('.env')) { printDebug('.env file detected and loaded', opts.debug); } diff --git a/packages/cli/lib/services/telemetry.service.ts b/packages/cli/lib/services/telemetry.service.ts new file mode 100644 index 0000000000..fa994fcf3d --- /dev/null +++ b/packages/cli/lib/services/telemetry.service.ts @@ -0,0 +1,34 @@ +import { getDeviceId } from '../state.js'; +import { getCliHeaders, isCliDebugEnabled, isTelemetryDisabled, printDebug, resolveHostport } from '../utils.js'; + +import type { CliTelemetryEvent, PostCliTelemetry } from '@nangohq/types'; + +const TELEMETRY_TIMEOUT_MS = 2000; + +export { isTelemetryDisabled }; + +/** + * Send an anonymous CLI usage event. Fire-and-forget + */ +export function trackCliEvent(event: CliTelemetryEvent, properties?: Record): void { + if (isTelemetryDisabled()) { + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS); + + const body: PostCliTelemetry['Body'] = { deviceId: getDeviceId(), event, properties }; + void fetch(new URL('/cli/telemetry', resolveHostport()), { + method: 'POST', + body: JSON.stringify(body), + headers: new Headers({ ...getCliHeaders(), 'content-type': 'application/json' }), + signal: controller.signal + }) + .catch((err) => { + printDebug(`Telemetry delivery failed: ${err instanceof Error ? err.message : String(err)}`, isCliDebugEnabled()); + }) + .finally(() => { + clearTimeout(timeout); + }); +} diff --git a/packages/cli/lib/state.ts b/packages/cli/lib/state.ts index 786dd57052..41e2824dbb 100644 --- a/packages/cli/lib/state.ts +++ b/packages/cli/lib/state.ts @@ -1,8 +1,22 @@ +import { randomUUID } from 'crypto'; + import Conf from 'conf'; const schema = { lastIgnoreUpgrade: { type: 'number' + }, + deviceId: { + type: 'string' } }; export const state = new Conf({ projectName: 'nango', schema }); + +export function getDeviceId(): string { + let deviceId = state.get('deviceId') as string | undefined; + if (!deviceId) { + deviceId = randomUUID(); + state.set('deviceId', deviceId); + } + return deviceId; +} diff --git a/packages/cli/lib/types.ts b/packages/cli/lib/types.ts index a92a081297..41a5ceac3d 100644 --- a/packages/cli/lib/types.ts +++ b/packages/cli/lib/types.ts @@ -3,6 +3,7 @@ export interface GlobalOptions { debug: boolean; interactive: boolean; dependencyUpdate: boolean; + telemetry?: boolean; } export type ENV = 'local' | 'cloud'; diff --git a/packages/cli/lib/utils.ts b/packages/cli/lib/utils.ts index 08738189c4..c81042b395 100644 --- a/packages/cli/lib/utils.ts +++ b/packages/cli/lib/utils.ts @@ -15,7 +15,7 @@ import semver from 'semver'; import { serializeError } from 'serialize-error'; import { cloudHost, localhostUrl } from './constants.js'; -import { state } from './state.js'; +import { getDeviceId, state } from './state.js'; import { Err, Ok } from './utils/result.js'; import { NANGO_VERSION } from './version.js'; @@ -273,10 +273,25 @@ export function enrichHeaders(headers: Record return headers; } +export function isTelemetryDisabled(): boolean { + return process.env['TELEMETRY'] === 'false' || process.env['NANGO_CLI_TELEMETRY'] === 'false'; +} + +export function isCliDebugEnabled(): boolean { + return process.env['NANGO_CLI_DEBUG'] === 'true'; +} + const defaultHttpsAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false }); export const http = axios.create({ - httpsAgent: defaultHttpsAgent, - headers: { 'User-Agent': getUserAgent() } + httpsAgent: defaultHttpsAgent +}); + +http.interceptors.request.use((config) => { + const cliHeaders = getCliHeaders(); + for (const [key, value] of Object.entries(cliHeaders)) { + config.headers.set(key, value); + } + return config; }); export function getUserAgent(): string { @@ -288,6 +303,16 @@ export function getUserAgent(): string { return `nango-cli/${clientVersion} (${osName}/${osVersion}; node.js/${nodeVersion})`; } +export function getCliHeaders(): Record { + const headers: Record = { + 'User-Agent': getUserAgent() + }; + if (!isTelemetryDisabled()) { + headers['Nango-CLI-Device-Id'] = getDeviceId(); + } + return headers; +} + export function getNangoRootPath(debug = false): string { const packagePath = getPackagePath(debug); const rootPath = path.resolve(packagePath, '..'); diff --git a/packages/cli/lib/zeroYaml/deploy.ts b/packages/cli/lib/zeroYaml/deploy.ts index 44c0ba60ae..937fef75f3 100644 --- a/packages/cli/lib/zeroYaml/deploy.ts +++ b/packages/cli/lib/zeroYaml/deploy.ts @@ -5,7 +5,7 @@ import chalk from 'chalk'; import columnify from 'columnify'; import promptly from 'promptly'; -import { isCI, parseSecretKey, printDebug, resolveHostport } from '../utils.js'; +import { getCliHeaders, isCI, parseSecretKey, printDebug, resolveHostport } from '../utils.js'; import { Err, Ok } from '../utils/result.js'; import { Spinner } from '../utils/spinner.js'; import { NANGO_VERSION } from '../version.js'; @@ -378,6 +378,7 @@ async function postConfirmation({ method: 'POST', body: JSON.stringify(body), headers: new Headers({ + ...getCliHeaders(), authorization: `Bearer ${process.env['NANGO_SECRET_KEY']}`, 'content-type': 'application/json' }) @@ -410,6 +411,7 @@ async function postDeploy({ hostport, body }: { hostport: string; body: PostDepl method: 'POST', body: JSON.stringify(body), headers: new Headers({ + ...getCliHeaders(), authorization: `Bearer ${process.env['NANGO_SECRET_KEY']}`, 'content-type': 'application/json' }) diff --git a/packages/server/lib/controllers/cli/postTelemetry.ts b/packages/server/lib/controllers/cli/postTelemetry.ts new file mode 100644 index 0000000000..f7d705b7ad --- /dev/null +++ b/packages/server/lib/controllers/cli/postTelemetry.ts @@ -0,0 +1,47 @@ +import * as z from 'zod'; + +import { productTracking } from '@nangohq/shared'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +import { asyncWrapper } from '../../utils/asyncWrapper.js'; + +import type { PostCliTelemetry } from '@nangohq/types'; + +const bodySchema = z + .object({ + deviceId: z.string().uuid(), + event: z.enum([ + 'cli:init', + 'cli:create', + 'cli:compile', + 'cli:dev', + 'cli:dryrun', + 'cli:generate:docs', + 'cli:generate:tests', + 'cli:clone', + 'cli:migrate_to_zero_yaml', + 'cli:deploy', + 'cli:pull' + ]), + properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional() + }) + .strict(); + +export const postCliTelemetry = asyncWrapper((req, res) => { + const emptyQuery = requireEmptyQuery(req); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const val = bodySchema.safeParse(req.body); + if (!val.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(val.error) } }); + return; + } + + const { deviceId, event, properties } = val.data; + productTracking.trackAnonymous({ name: event, distinctId: deviceId, ...(properties ? { eventProperties: properties } : {}) }); + + res.status(204).send(); +}); diff --git a/packages/server/lib/controllers/sync/deploy/postDeploy.ts b/packages/server/lib/controllers/sync/deploy/postDeploy.ts index fc7d454c54..1ca9a3fd51 100644 --- a/packages/server/lib/controllers/sync/deploy/postDeploy.ts +++ b/packages/server/lib/controllers/sync/deploy/postDeploy.ts @@ -4,6 +4,7 @@ import { logContextGetter } from '@nangohq/logs'; import { cleanIncomingFlow, deploy, errorManager, getAndReconcileDifferences, NangoError, productTracking, startTrial } from '@nangohq/shared'; import { getLogger, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; +import { getCliContext } from '../../../middleware/cliVersionCheck.js'; import { startFunctionDeletion } from '../../../tasks/startFunctionDeletion.js'; import { asyncWrapper } from '../../../utils/asyncWrapper.js'; import { getOrchestrator } from '../../../utils/utils.js'; @@ -31,6 +32,13 @@ export const postDeploy = asyncWrapper(async (req, res) => { const body: PostDeploy['Body'] = val.data; const { environment, account, plan } = res.locals; + const { cliVersion } = getCliContext(req); + const trackingProperties: Record = { + 'cli-version': cliVersion || 'unknown', + source: body.source ?? 'repo', + 'flow-count': body.flowConfigs.length + }; + // Prevent concurrent deploys per environment, fail immediately if another deploy is in flight. const locking = await getLocking(); const ttlMs = process.env['DEPLOY_LOCK_TTL_MS'] ? parseInt(process.env['DEPLOY_LOCK_TTL_MS']) : 10 * 60 * 1000; // max expected deploy duration @@ -75,6 +83,7 @@ export const postDeploy = asyncWrapper(async (req, res) => { } if (!success || !syncConfigDeployResult) { + productTracking.track({ name: 'deploy:error', team: account, eventProperties: { ...trackingProperties, 'error-code': error?.type || 'unknown' } }); errorManager.errResFromNangoErr(res, error); return; } @@ -93,6 +102,7 @@ export const postDeploy = asyncWrapper(async (req, res) => { onFunctionDeleted: ({ syncConfigId, models }) => startFunctionDeletion({ syncConfigId, environmentId: environment.id, models }) }); if (!success) { + productTracking.track({ name: 'deploy:error', team: account, eventProperties: { ...trackingProperties, 'error-code': 'reconcile_failed' } }); res.status(500).send({ error: { code: 'server_error', @@ -103,7 +113,7 @@ export const postDeploy = asyncWrapper(async (req, res) => { } } - productTracking.track({ name: 'deploy:success', team: account }); + productTracking.track({ name: 'deploy:success', team: account, eventProperties: trackingProperties }); res.send(syncConfigDeployResult.result); } finally { diff --git a/packages/server/lib/middleware/cliVersionCheck.ts b/packages/server/lib/middleware/cliVersionCheck.ts index 0d7d6097e6..b15fcf17ae 100644 --- a/packages/server/lib/middleware/cliVersionCheck.ts +++ b/packages/server/lib/middleware/cliVersionCheck.ts @@ -8,6 +8,23 @@ import type { NextFunction, Request, Response } from 'express'; const logger = getLogger('CliVersionCheck'); const VERSION_REGEX = /nango-cli\/([0-9.]+)/; + +export function getCliContext(req: Request): { cliVersion?: string; deviceId?: string } { + const context: { cliVersion?: string; deviceId?: string } = {}; + + const match = req.headers['user-agent']?.match(VERSION_REGEX); + if (match?.[1]) { + context.cliVersion = match[1]; + } + + const deviceId = req.headers['nango-cli-device-id']; + if (typeof deviceId === 'string' && deviceId) { + context.deviceId = deviceId; + } + + return context; +} + export function cliMinVersion(minVersion: string) { return (req: Request, res: Response, next: NextFunction) => { const userAgent = req.headers['user-agent']; diff --git a/packages/server/lib/routes.public.ts b/packages/server/lib/routes.public.ts index f573b2f436..abc4d0d61b 100644 --- a/packages/server/lib/routes.public.ts +++ b/packages/server/lib/routes.public.ts @@ -21,6 +21,7 @@ import { postPublicTbaAuthorization } from './controllers/auth/postTba.js'; import { postPublicTwoStepAuthorization } from './controllers/auth/postTwoStep.js'; import { postPublicUnauthenticated } from './controllers/auth/postUnauthenticated.js'; import { getClientMetadata } from './controllers/clientMetadata/environmentUuid/getClientMetadata.js'; +import { postCliTelemetry } from './controllers/cli/postTelemetry.js'; import configController from './controllers/config.controller.js'; import { deleteConnectSession } from './controllers/connect/deleteSession.js'; import { getConnectSession } from './controllers/connect/getSession.js'; @@ -271,6 +272,10 @@ publicAPI.route('/sync/deploy').post(apiAuth, withScope('environment:deploy'), c publicAPI.route('/sync/deploy/confirmation').post(apiAuth, withScope('environment:deploy'), cliMinVersion('0.39.25'), postDeployConfirmation); publicAPI.route('/sync/deploy/internal').post(apiAuth, withScope('environment:deploy'), postDeployInternal); +// CLI +publicAPI.use('/cli', jsonContentTypeMiddleware); +publicAPI.route('/cli/telemetry').post(rateLimiterMiddleware, postCliTelemetry); + // Syncs publicAPI.route('/sync/update-connection-frequency').put(apiAuth, withScope('environment:syncs:update'), putSyncConnectionFrequency); diff --git a/packages/shared/lib/utils/productTracking.ts b/packages/shared/lib/utils/productTracking.ts index 2c0d37439d..9915aa88f8 100644 --- a/packages/shared/lib/utils/productTracking.ts +++ b/packages/shared/lib/utils/productTracking.ts @@ -2,15 +2,17 @@ import { PostHog } from 'posthog-node'; import { baseUrl, NANGO_VERSION, report } from '@nangohq/utils'; -import type { DBTeam, DBUser } from '@nangohq/types'; +import type { CliTelemetryEvent, DBTeam, DBUser } from '@nangohq/types'; export type ProductTrackingTypes = + | CliTelemetryEvent | 'account:trial:extend' | 'account:trial:started' | 'account:billing:plan_changed' | 'account:billing:downgraded' | 'account:billing:upgraded' | 'deploy:success' + | 'deploy:error' | 'prod:connections:threshold_hit' | 'server:resource_capped:connection_creation' | 'server:resource_capped:connection_imported' @@ -80,6 +82,35 @@ class ProductTracking { report(err); } } + + /** + * Track an event that isn't tied to a resolved team, e.g. CLI events sent before or without authentication. + * The distinctId is a client-generated device id + */ + public trackAnonymous({ + name, + distinctId, + eventProperties + }: { + name: ProductTrackingTypes; + distinctId: string; + eventProperties?: Record; + }) { + try { + if (this.client == null) { + return; + } + + eventProperties = eventProperties || {}; + eventProperties['host'] = baseUrl; + eventProperties['nango-server-version'] = NANGO_VERSION || 'unknown'; + eventProperties['device-id'] = distinctId; + + this.client.capture({ event: name, distinctId, properties: eventProperties }); + } catch (err) { + report(err); + } + } } export const productTracking = new ProductTracking(); diff --git a/packages/types/lib/api.endpoints.ts b/packages/types/lib/api.endpoints.ts index 2477b13eaa..aa963f400d 100644 --- a/packages/types/lib/api.endpoints.ts +++ b/packages/types/lib/api.endpoints.ts @@ -28,6 +28,7 @@ import type { PostPublicUnauthenticatedAuthorization } from './auth/http.api.js'; import type { GetPublicClientMetadata } from './clientMetadata/http.api.js'; +import type { PostCliTelemetry } from './cli/api.js'; import type { DeleteConnectSession, GetConnectSession, @@ -153,6 +154,7 @@ export type PublicApiEndpoints = | PatchPublicPruneRecords | GetPublicScriptsConfig | PostPublicConnectTelemetry + | PostCliTelemetry | PutPublicSyncConnectionFrequency | PostPublicIntegration | PostPublicQuickstartIntegration diff --git a/packages/types/lib/cli/api.ts b/packages/types/lib/cli/api.ts new file mode 100644 index 0000000000..cd69292389 --- /dev/null +++ b/packages/types/lib/cli/api.ts @@ -0,0 +1,25 @@ +import type { Endpoint } from '../api.js'; + +export type CliTelemetryEvent = + | 'cli:init' + | 'cli:create' + | 'cli:compile' + | 'cli:dev' + | 'cli:dryrun' + | 'cli:generate:docs' + | 'cli:generate:tests' + | 'cli:clone' + | 'cli:migrate_to_zero_yaml' + | 'cli:deploy' + | 'cli:pull'; + +export type PostCliTelemetry = Endpoint<{ + Method: 'POST'; + Path: '/cli/telemetry'; + Body: { + deviceId: string; + event: CliTelemetryEvent; + properties?: Record | undefined; + }; + Success: never; +}>; diff --git a/packages/types/lib/index.ts b/packages/types/lib/index.ts index 956cf95ff3..ef5593ae2a 100644 --- a/packages/types/lib/index.ts +++ b/packages/types/lib/index.ts @@ -60,6 +60,7 @@ export type * from './deploy/api.js'; export type * from './deploy/index.js'; export type * from './deploy/incomingFlow.js'; export type * from './endpoints/db.js'; +export type * from './cli/api.js'; export type * from './connect/api.js'; export type * from './connect/session.js'; export type * from './endUser/index.js'; From ceeb15e5594287404cee41bb7a0cb3364159a1d0 Mon Sep 17 00:00:00 2001 From: agusayerza Date: Tue, 7 Jul 2026 10:54:25 -0300 Subject: [PATCH 2/2] PR fixes --- .../cli/lib/services/telemetry.service.ts | 5 +++-- packages/cli/lib/state.ts | 18 ++++++++++----- packages/cli/lib/utils.ts | 8 +++++-- .../lib/controllers/cli/postTelemetry.ts | 22 +++++-------------- .../lib/controllers/sync/deploy/postDeploy.ts | 6 ++++- packages/server/lib/routes.public.ts | 2 +- packages/shared/lib/utils/productTracking.ts | 22 +++++++++++++++++++ packages/types/lib/api.endpoints.ts | 2 +- packages/types/lib/cli/api.ts | 3 ++- packages/utils/lib/cli-telemetry-events.ts | 20 +++++++++++++++++ packages/utils/lib/index.ts | 1 + 11 files changed, 78 insertions(+), 31 deletions(-) create mode 100644 packages/utils/lib/cli-telemetry-events.ts diff --git a/packages/cli/lib/services/telemetry.service.ts b/packages/cli/lib/services/telemetry.service.ts index fa994fcf3d..39a401f84c 100644 --- a/packages/cli/lib/services/telemetry.service.ts +++ b/packages/cli/lib/services/telemetry.service.ts @@ -10,7 +10,7 @@ export { isTelemetryDisabled }; /** * Send an anonymous CLI usage event. Fire-and-forget */ -export function trackCliEvent(event: CliTelemetryEvent, properties?: Record): void { +export function trackCliEvent(event: CliTelemetryEvent): void { if (isTelemetryDisabled()) { return; } @@ -18,7 +18,8 @@ export function trackCliEvent(event: CliTelemetryEvent, properties?: Record controller.abort(), TELEMETRY_TIMEOUT_MS); - const body: PostCliTelemetry['Body'] = { deviceId: getDeviceId(), event, properties }; + const { deviceId, ephemeral } = getDeviceId(); + const body: PostCliTelemetry['Body'] = { deviceId, event, ephemeral }; void fetch(new URL('/cli/telemetry', resolveHostport()), { method: 'POST', body: JSON.stringify(body), diff --git a/packages/cli/lib/state.ts b/packages/cli/lib/state.ts index 41e2824dbb..6c21f68179 100644 --- a/packages/cli/lib/state.ts +++ b/packages/cli/lib/state.ts @@ -12,11 +12,17 @@ const schema = { }; export const state = new Conf({ projectName: 'nango', schema }); -export function getDeviceId(): string { - let deviceId = state.get('deviceId') as string | undefined; - if (!deviceId) { - deviceId = randomUUID(); - state.set('deviceId', deviceId); +export function getDeviceId(): { deviceId: string; ephemeral: boolean } { + try { + let deviceId = state.get('deviceId') as string | undefined; + if (!deviceId) { + deviceId = randomUUID(); + state.set('deviceId', deviceId); + } + return { deviceId, ephemeral: false }; + } catch { + // ephemeral means the id couldn't be persisted (e.g. read-only FS), so it's a throwaway + // that can't correlate across runs. Callers should flag it and skip device correlation. + return { deviceId: randomUUID(), ephemeral: true }; } - return deviceId; } diff --git a/packages/cli/lib/utils.ts b/packages/cli/lib/utils.ts index c81042b395..efe0b0b4d5 100644 --- a/packages/cli/lib/utils.ts +++ b/packages/cli/lib/utils.ts @@ -274,7 +274,7 @@ export function enrichHeaders(headers: Record } export function isTelemetryDisabled(): boolean { - return process.env['TELEMETRY'] === 'false' || process.env['NANGO_CLI_TELEMETRY'] === 'false'; + return process.env['NANGO_CLI_TELEMETRY'] === 'false'; } export function isCliDebugEnabled(): boolean { @@ -308,7 +308,11 @@ export function getCliHeaders(): Record { 'User-Agent': getUserAgent() }; if (!isTelemetryDisabled()) { - headers['Nango-CLI-Device-Id'] = getDeviceId(); + const { deviceId, ephemeral } = getDeviceId(); + // Only correlate persisted ids; a throwaway id would just create junk aliases server-side. + if (!ephemeral) { + headers['Nango-CLI-Device-Id'] = deviceId; + } } return headers; } diff --git a/packages/server/lib/controllers/cli/postTelemetry.ts b/packages/server/lib/controllers/cli/postTelemetry.ts index f7d705b7ad..d9d7853a67 100644 --- a/packages/server/lib/controllers/cli/postTelemetry.ts +++ b/packages/server/lib/controllers/cli/postTelemetry.ts @@ -1,7 +1,7 @@ import * as z from 'zod'; import { productTracking } from '@nangohq/shared'; -import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; +import { cliTelemetryEvents, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; import { asyncWrapper } from '../../utils/asyncWrapper.js'; @@ -10,20 +10,8 @@ import type { PostCliTelemetry } from '@nangohq/types'; const bodySchema = z .object({ deviceId: z.string().uuid(), - event: z.enum([ - 'cli:init', - 'cli:create', - 'cli:compile', - 'cli:dev', - 'cli:dryrun', - 'cli:generate:docs', - 'cli:generate:tests', - 'cli:clone', - 'cli:migrate_to_zero_yaml', - 'cli:deploy', - 'cli:pull' - ]), - properties: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional() + event: z.enum(cliTelemetryEvents), + ephemeral: z.boolean().optional() }) .strict(); @@ -40,8 +28,8 @@ export const postCliTelemetry = asyncWrapper((req, res) => { return; } - const { deviceId, event, properties } = val.data; - productTracking.trackAnonymous({ name: event, distinctId: deviceId, ...(properties ? { eventProperties: properties } : {}) }); + const { deviceId, event, ephemeral } = val.data; + productTracking.trackAnonymous({ name: event, distinctId: deviceId, ...(ephemeral ? { eventProperties: { 'device-id-ephemeral': true } } : {}) }); res.status(204).send(); }); diff --git a/packages/server/lib/controllers/sync/deploy/postDeploy.ts b/packages/server/lib/controllers/sync/deploy/postDeploy.ts index 1ca9a3fd51..e0ba1f67b1 100644 --- a/packages/server/lib/controllers/sync/deploy/postDeploy.ts +++ b/packages/server/lib/controllers/sync/deploy/postDeploy.ts @@ -32,13 +32,17 @@ export const postDeploy = asyncWrapper(async (req, res) => { const body: PostDeploy['Body'] = val.data; const { environment, account, plan } = res.locals; - const { cliVersion } = getCliContext(req); + const { cliVersion, deviceId } = getCliContext(req); const trackingProperties: Record = { 'cli-version': cliVersion || 'unknown', source: body.source ?? 'repo', 'flow-count': body.flowConfigs.length }; + if (deviceId) { + productTracking.alias({ deviceId, team: account }); + } + // Prevent concurrent deploys per environment, fail immediately if another deploy is in flight. const locking = await getLocking(); const ttlMs = process.env['DEPLOY_LOCK_TTL_MS'] ? parseInt(process.env['DEPLOY_LOCK_TTL_MS']) : 10 * 60 * 1000; // max expected deploy duration diff --git a/packages/server/lib/routes.public.ts b/packages/server/lib/routes.public.ts index abc4d0d61b..b7f4f73bea 100644 --- a/packages/server/lib/routes.public.ts +++ b/packages/server/lib/routes.public.ts @@ -20,8 +20,8 @@ import { postPublicSignatureAuthorization } from './controllers/auth/postSignatu import { postPublicTbaAuthorization } from './controllers/auth/postTba.js'; import { postPublicTwoStepAuthorization } from './controllers/auth/postTwoStep.js'; import { postPublicUnauthenticated } from './controllers/auth/postUnauthenticated.js'; -import { getClientMetadata } from './controllers/clientMetadata/environmentUuid/getClientMetadata.js'; import { postCliTelemetry } from './controllers/cli/postTelemetry.js'; +import { getClientMetadata } from './controllers/clientMetadata/environmentUuid/getClientMetadata.js'; import configController from './controllers/config.controller.js'; import { deleteConnectSession } from './controllers/connect/deleteSession.js'; import { getConnectSession } from './controllers/connect/getSession.js'; diff --git a/packages/shared/lib/utils/productTracking.ts b/packages/shared/lib/utils/productTracking.ts index 9915aa88f8..177dc95428 100644 --- a/packages/shared/lib/utils/productTracking.ts +++ b/packages/shared/lib/utils/productTracking.ts @@ -111,6 +111,28 @@ class ProductTracking { report(err); } } + + /** + * Link an anonymous CLI device id to an identified team/user, so anonymous CLI + * events (tracked via trackAnonymous) merge into the identified profile in PostHog. + * Called from authenticated CLI requests that carry a device id, e.g. deploy. + */ + public alias({ deviceId, team, user }: { deviceId: string; team: Pick; user?: Pick | undefined }) { + try { + if (this.client == null) { + return; + } + + let alias = `team-${team.id}`; + if (user) { + alias += `-user-${user.id}`; + } + + this.client.alias({ distinctId: deviceId, alias }); + } catch (err) { + report(err); + } + } } export const productTracking = new ProductTracking(); diff --git a/packages/types/lib/api.endpoints.ts b/packages/types/lib/api.endpoints.ts index aa963f400d..fe3f583006 100644 --- a/packages/types/lib/api.endpoints.ts +++ b/packages/types/lib/api.endpoints.ts @@ -27,8 +27,8 @@ import type { PostPublicTwoStepAuthorization, PostPublicUnauthenticatedAuthorization } from './auth/http.api.js'; -import type { GetPublicClientMetadata } from './clientMetadata/http.api.js'; import type { PostCliTelemetry } from './cli/api.js'; +import type { GetPublicClientMetadata } from './clientMetadata/http.api.js'; import type { DeleteConnectSession, GetConnectSession, diff --git a/packages/types/lib/cli/api.ts b/packages/types/lib/cli/api.ts index cd69292389..b5c4ba9d73 100644 --- a/packages/types/lib/cli/api.ts +++ b/packages/types/lib/cli/api.ts @@ -19,7 +19,8 @@ export type PostCliTelemetry = Endpoint<{ Body: { deviceId: string; event: CliTelemetryEvent; - properties?: Record | undefined; + // True when deviceId is a throwaway id that couldn't be persisted, so it shouldn't be treated as a stable device. + ephemeral?: boolean; }; Success: never; }>; diff --git a/packages/utils/lib/cli-telemetry-events.ts b/packages/utils/lib/cli-telemetry-events.ts new file mode 100644 index 0000000000..1e750bbde2 --- /dev/null +++ b/packages/utils/lib/cli-telemetry-events.ts @@ -0,0 +1,20 @@ +import type { CliTelemetryEvent } from '@nangohq/types'; + +export const cliTelemetryEvents = [ + 'cli:init', + 'cli:create', + 'cli:compile', + 'cli:dev', + 'cli:dryrun', + 'cli:generate:docs', + 'cli:generate:tests', + 'cli:clone', + 'cli:migrate_to_zero_yaml', + 'cli:deploy', + 'cli:pull' +] as const satisfies readonly CliTelemetryEvent[]; + +// The `satisfies` above rejects entries that aren't valid `CliTelemetryEvent`s; +// the assertion below rejects any `CliTelemetryEvent` missing from this array. +// Together they keep the two lists in sync. +true satisfies [Exclude] extends [never] ? true : never; diff --git a/packages/utils/lib/index.ts b/packages/utils/lib/index.ts index 189cb0c321..6f929008e9 100644 --- a/packages/utils/lib/index.ts +++ b/packages/utils/lib/index.ts @@ -1,5 +1,6 @@ export * from './roles.js'; export * from './api-key-scopes.js'; +export * from './cli-telemetry-events.js'; export * from './environment/constants.js'; export * from './environment/detection.js'; export * from './environment/parse.js';