diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 10637a3d..27c5004a 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -55,6 +55,10 @@ const emittedProjectSnapshots = new Set(); const MCP_SAMPLE_RATE = 0.1 as const; let mcpSampled: boolean | undefined; +function telemetryDebugEnabled(): boolean { + return process.env.KTX_TELEMETRY_DEBUG === '1'; +} + export function shouldEmitMcpTelemetry(): boolean { mcpSampled ??= Math.random() < MCP_SAMPLE_RATE; return mcpSampled; @@ -71,19 +75,21 @@ export async function emitTelemetryEvent(input: packageInfo?: KtxCliPackageInfo; projectDir?: string; }): Promise { + const debug = telemetryDebugEnabled(); const identity = await loadTelemetryIdentity({ stdoutIsTTY: input.io.stdout.isTTY === true, stderr: input.io.stderr, env: process.env, }); - if (!identity.enabled || !identity.installId) { + if ((!identity.enabled || !identity.installId) && !debug) { return; } const packageInfo = input.packageInfo ?? getKtxCliPackageInfo(); + const installId = identity.installId ?? 'debug'; - const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined; + const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined; await trackTelemetryEvent({ event: buildTelemetryEvent( input.name, @@ -93,7 +99,7 @@ export async function emitTelemetryEvent(input: }), input.fields, ), - distinctId: identity.installId, + distinctId: installId, projectId, env: process.env, stderr: input.io.stderr, diff --git a/packages/cli/test/telemetry/index.test.ts b/packages/cli/test/telemetry/index.test.ts new file mode 100644 index 00000000..8d8f932b --- /dev/null +++ b/packages/cli/test/telemetry/index.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { emitTelemetryEvent } from '../../src/telemetry/index.js'; + +function makeIo(): { io: KtxCliIo; stderr: () => string } { + let stderr = ''; + return { + io: { + stdout: { + isTTY: true, + write: () => {}, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +describe('emitTelemetryEvent', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-index-')); + vi.stubEnv('HOME', homeDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('prints debug telemetry when live telemetry is disabled without creating an identity file', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + vi.stubEnv('DO_NOT_TRACK', '1'); + const testIo = makeIo(); + const projectDir = join(homeDir, 'private-project'); + + await emitTelemetryEvent({ + name: 'connection_added', + projectDir, + io: testIo.io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + fields: { + driver: 'sqlite', + isDemoConnection: false, + }, + }); + + expect(testIo.stderr()).toContain('[telemetry]'); + expect(testIo.stderr()).toContain('"event":"connection_added"'); + expect(testIo.stderr()).not.toContain(projectDir); + await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); + }); +});