From 9efcd1f97dc7d04f5aba8440ef85d469ea1bd70c Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 22 May 2026 15:38:51 +0200 Subject: [PATCH] feat: add telemetry phase 1 --- README.md | 7 + docs-site/content/docs/community/meta.json | 2 +- .../content/docs/community/telemetry.mdx | 65 +++++++ knip.json | 1 + packages/cli/package.json | 1 + .../cli/src/cli-program-telemetry.test.ts | 122 ++++++++++++ packages/cli/src/cli-program.ts | 70 ++++++- .../cli/src/telemetry/command-hook.test.ts | 56 ++++++ packages/cli/src/telemetry/command-hook.ts | 82 +++++++++ packages/cli/src/telemetry/emitter.test.ts | 145 +++++++++++++++ packages/cli/src/telemetry/emitter.ts | 173 ++++++++++++++++++ .../cli/src/telemetry/events.snapshot.test.ts | 58 ++++++ packages/cli/src/telemetry/events.test.ts | 76 ++++++++ packages/cli/src/telemetry/events.ts | 92 ++++++++++ packages/cli/src/telemetry/identity.test.ts | 159 ++++++++++++++++ packages/cli/src/telemetry/identity.ts | 126 +++++++++++++ packages/cli/src/telemetry/index.ts | 80 ++++++++ packages/cli/src/telemetry/scrubber.test.ts | 25 +++ packages/cli/src/telemetry/scrubber.ts | 28 +++ pnpm-lock.yaml | 9 + 20 files changed, 1368 insertions(+), 9 deletions(-) create mode 100644 docs-site/content/docs/community/telemetry.mdx create mode 100644 packages/cli/src/cli-program-telemetry.test.ts create mode 100644 packages/cli/src/telemetry/command-hook.test.ts create mode 100644 packages/cli/src/telemetry/command-hook.ts create mode 100644 packages/cli/src/telemetry/emitter.test.ts create mode 100644 packages/cli/src/telemetry/emitter.ts create mode 100644 packages/cli/src/telemetry/events.snapshot.test.ts create mode 100644 packages/cli/src/telemetry/events.test.ts create mode 100644 packages/cli/src/telemetry/events.ts create mode 100644 packages/cli/src/telemetry/identity.test.ts create mode 100644 packages/cli/src/telemetry/identity.ts create mode 100644 packages/cli/src/telemetry/index.ts create mode 100644 packages/cli/src/telemetry/scrubber.test.ts create mode 100644 packages/cli/src/telemetry/scrubber.ts diff --git a/README.md b/README.md index b6f0494e..285f92a6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ ktx context built: yes Agent integration ready: yes (codex:project) ``` +## Telemetry + +**ktx** collects anonymous usage telemetry from interactive CLI runs to improve +setup, command reliability, and data-agent workflows. See +[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the event +catalog, privacy details, and opt-out options. + ## Common Commands | Command | Purpose | diff --git a/docs-site/content/docs/community/meta.json b/docs-site/content/docs/community/meta.json index e181be6c..199bc1b8 100644 --- a/docs-site/content/docs/community/meta.json +++ b/docs-site/content/docs/community/meta.json @@ -1,5 +1,5 @@ { "title": "Community", "defaultOpen": true, - "pages": ["support", "contributing"] + "pages": ["support", "contributing", "telemetry"] } diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx new file mode 100644 index 00000000..f7942aa6 --- /dev/null +++ b/docs-site/content/docs/community/telemetry.mdx @@ -0,0 +1,65 @@ +--- +title: Telemetry +description: Understand what anonymous usage telemetry ktx collects and how to opt out. +--- + +**ktx** collects anonymous product-usage telemetry from interactive CLI runs so +maintainers can understand which commands work, where setup fails, and which +parts of the data-agent workflow need improvement. + +## Opt out + +Telemetry is opt-out and is disabled automatically in CI and non-interactive +CLI runs. Use any of these mechanisms to disable it: + +| Mechanism | Effect | +|-----------|--------| +| `export KTX_TELEMETRY_DISABLED=1` | Disables telemetry for the shell and child processes | +| `export DO_NOT_TRACK=1` | Disables telemetry using the standard do-not-track environment variable | +| `CI=1` | Disables telemetry automatically in CI | +| Non-TTY output | Disables telemetry automatically for pipes and scripts | +| Edit `~/.ktx/telemetry.json` and set `"enabled": false` | Disables telemetry persistently for the machine | + +There is no `ktx telemetry` command. The first interactive run that can emit +telemetry prints this one-line notice to stderr: + +```text +ktx collects anonymous usage data to improve the product. Opt out: set KTX_TELEMETRY_DISABLED=1. +``` + +## Identity and grouping + +**ktx** stores a random install ID in `~/.ktx/telemetry.json`. This ID is the +PostHog `distinctId` and is not tied to your name, email, Git identity, or +account. + +For project-level analysis, **ktx** sends a salted SHA-256 project ID derived +from the install ID and absolute project directory. The raw project path is not +sent. + +## Events + +Phase 1 telemetry emits these events: + +| Event | When it fires | Fields | +|-------|---------------|--------| +| `install_first_run` | Once when `~/.ktx/telemetry.json` is created | Common envelope only | +| `command` | Once for a registered Commander action that reaches the action hook | `commandPath`, `durationMs`, `outcome`, `errorClass`, `flagsPresent`, `hasProject`, `projectGroupAttached` | + +Common envelope fields are `cliVersion`, `nodeVersion`, `osPlatform`, +`osRelease`, `arch`, `runtime`, and `isCi`. + +## Never collected + +**ktx** telemetry never collects: + +- Argv values, file paths, hostnames, or environment variable values +- `ktx.yaml` contents, connection passwords, API keys, or tokens +- Schema names, table names, column names, SQL text, or query results +- Error messages or stack traces +- Git remote URLs, Git user email, OS user, or hostname + +## Storage and retention + +Telemetry is sent to the GTX PostHog project. Raw event data is retained for +90 days in PostHog. Aggregated counts may be retained indefinitely. diff --git a/knip.json b/knip.json index 08939c28..74c325fc 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "packages/cli": { "entry": [ "src/print-command-tree.ts!", + "src/telemetry/index.ts!", "scripts/**/*.mjs", "src/**/*.test-utils.ts", "src/**/acceptance-fixtures.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index 296276c0..ebe8c55c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -69,6 +69,7 @@ "openai": "^6.37.0", "p-limit": "^7.3.0", "pg": "^8.20.0", + "posthog-node": "^5.0.0", "react": "^19.2.6", "simple-git": "3.36.0", "snowflake-sdk": "^2.4.1", diff --git a/packages/cli/src/cli-program-telemetry.test.ts b/packages/cli/src/cli-program-telemetry.test.ts new file mode 100644 index 00000000..63215f0b --- /dev/null +++ b/packages/cli/src/cli-program-telemetry.test.ts @@ -0,0 +1,122 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runCommanderKtxCli } from './cli-program.js'; +import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; + +function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { + let stdout = ''; + let stderr = ''; + return { + io: { + stdout: { + isTTY: stdoutIsTTY, + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +const info: KtxCliPackageInfo = { name: '@kaelio/ktx', version: '0.4.1' }; + +describe('runCommanderKtxCli telemetry', () => { + let tempDir: string; + const originalEnv = process.env; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-telemetry-')); + await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8'); + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('HOME', tempDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + process.env = originalEnv; + await rm(tempDir, { recursive: true, force: true }); + }); + + it('emits debug command telemetry for registered actions', async () => { + const io = makeIo(true); + await expect( + runCommanderKtxCli( + ['--project-dir', tempDir, 'status', '--help'], + io.io, + {}, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(0); + + expect(io.stderr()).not.toContain('[telemetry]'); + + const statusIo = makeIo(true); + const deps: KtxCliDeps = { doctor: async () => 0 }; + + await expect( + runCommanderKtxCli( + ['--project-dir', tempDir, 'status', '--json'], + statusIo.io, + deps, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(0); + + expect(statusIo.stderr()).toContain('[telemetry]'); + expect(statusIo.stderr()).toContain('"event":"command"'); + expect(statusIo.stderr()).toContain('"commandPath":["ktx","status"]'); + expect(statusIo.stderr()).not.toContain(tempDir); + }); + + it('emits aborted telemetry when project validation aborts after preAction starts', async () => { + const missingProjectDir = join(tempDir, 'missing'); + await mkdir(missingProjectDir, { recursive: true }); + const io = makeIo(true); + + await expect( + runCommanderKtxCli( + ['--project-dir', missingProjectDir, 'connection'], + io.io, + {}, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(1); + + expect(io.stderr()).toContain('[telemetry]'); + expect(io.stderr()).toContain('"outcome":"aborted"'); + expect(io.stderr()).toContain('"hasProject":false'); + expect(io.stderr()).toContain('"projectGroupAttached":false'); + expect(io.stderr()).not.toContain(missingProjectDir); + }); + + it('does not import or emit telemetry for help, version, bare non-TTY, or unknown top-level command', async () => { + const helpIo = makeIo(true); + await expect(runCommanderKtxCli(['--help'], helpIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0); + expect(helpIo.stderr()).not.toContain('[telemetry]'); + + const versionIo = makeIo(true); + await expect(runCommanderKtxCli(['--version'], versionIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0); + expect(versionIo.stderr()).not.toContain('[telemetry]'); + + const bareIo = makeIo(false); + await expect(runCommanderKtxCli([], bareIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(0); + expect(bareIo.stderr()).not.toContain('[telemetry]'); + + const unknownIo = makeIo(true); + await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1); + expect(unknownIo.stderr()).not.toContain('[telemetry]'); + }); +}); diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 84f740f5..f7657d31 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -14,6 +14,7 @@ import { registerAdminCommands } from './admin.js'; import { renderMissingProjectMessage } from './doctor.js'; import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js'; import { profileMark, profileSpan } from './startup-profile.js'; +import type { CommandOutcome } from './telemetry/index.js'; profileMark('module:cli-program'); @@ -43,6 +44,8 @@ export interface BuildKtxProgramOptions { packageInfo: KtxCliPackageInfo; runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise; setExitCode?: (code: number) => void; + argv?: string[]; + setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void; } type CommanderExitLike = { exitCode: number; code: string; message: string }; @@ -327,6 +330,25 @@ function formatCliError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function commandOutcomeForParseResult(error: unknown, exitCode: number): CommandOutcome { + if (error) { + return isKtxProjectMissingAbortError(error) ? 'aborted' : 'error'; + } + return exitCode === 0 ? 'ok' : 'error'; +} + +function shouldAttachCommandProjectGroup(path: string[], hasProject: boolean): boolean { + if (hasProject) { + return true; + } + const rootCommand = path[1]; + const pathKey = path.join(' '); + return ( + (rootCommand !== undefined && COMMANDS_THAT_CREATE_PROJECT.has(rootCommand)) || + COMMANDS_THAT_CREATE_PROJECT.has(pathKey) + ); +} + function firstTopLevelCommandToken(argv: string[]): string | null { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -392,9 +414,24 @@ async function runBareInteractiveCommand( export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); - program.hook('preAction', (_thisCommand, actionCommand) => { - writeProjectDir(options.io, actionCommand as CommandPathNode); - ensureProjectAvailable(options.io, actionCommand as CommandPathNode); + program.hook('preAction', async (_thisCommand, actionCommand) => { + const telemetry = await import('./telemetry/index.js'); + options.setTelemetryModule?.(telemetry); + const commandNode = actionCommand as CommandPathNode; + const path = commandPath(commandNode); + const projectDir = resolveCommandProjectDir(commandNode); + const hasProject = ktxYamlExists(projectDir); + const attachProjectGroup = shouldAttachCommandProjectGroup(path, hasProject); + telemetry.beginCommandSpan({ + commandPath: path, + argv: options.argv ?? [], + projectDir: attachProjectGroup ? projectDir : undefined, + hasProject, + attachProjectGroup, + startedAt: performance.now(), + }); + writeProjectDir(options.io, commandNode); + ensureProjectAvailable(options.io, commandNode); }); const context: KtxCliCommandContext = { @@ -435,14 +472,19 @@ export async function runCommanderKtxCli( ): Promise { profileMark('commander:entry'); let exitCode = 0; + let telemetryModule: typeof import('./telemetry/index.js') | undefined; const program = buildKtxProgram({ io, deps, packageInfo: info, runInit: options.runInit, + argv, setExitCode: (code: number) => { exitCode = code; }, + setTelemetryModule: (telemetry) => { + telemetryModule = telemetry; + }, }); profileMark('commander:program-built'); const context: KtxCliCommandContext = { @@ -477,17 +519,29 @@ export async function runCommanderKtxCli( return 1; } + let parseError: unknown; try { await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' })); } catch (error) { + parseError = error; if (isKtxProjectMissingAbortError(error)) { - return 1; + exitCode = 1; + } else if (isCommanderExit(error)) { + exitCode = error.exitCode === 0 ? 0 : 1; + } else { + io.stderr.write(`${formatCliError(error)}\n`); + exitCode = 1; } - if (isCommanderExit(error)) { - return error.exitCode === 0 ? 0 : 1; + } finally { + if (telemetryModule) { + const completed = telemetryModule.completeCommandSpan({ + completedAt: performance.now(), + outcome: commandOutcomeForParseResult(parseError, exitCode), + error: parseError, + }); + await telemetryModule.emitCompletedCommand({ completed, packageInfo: info, io }); + await telemetryModule.shutdownTelemetryEmitter(); } - io.stderr.write(`${formatCliError(error)}\n`); - return 1; } return exitCode; diff --git a/packages/cli/src/telemetry/command-hook.test.ts b/packages/cli/src/telemetry/command-hook.test.ts new file mode 100644 index 00000000..1db68f66 --- /dev/null +++ b/packages/cli/src/telemetry/command-hook.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + beginCommandSpan, + completeCommandSpan, + extractFlagsPresent, + resetCommandSpan, +} from './command-hook.js'; + +describe('telemetry command hook', () => { + it('extracts only flag names, never flag values', () => { + expect( + extractFlagsPresent(['--project-dir', '/Users/alice/private', '--json', '--limit=5', '-v', 'status']), + ).toEqual({ + 'project-dir': true, + json: true, + limit: true, + v: true, + }); + }); + + it('builds a completed command event from a span', () => { + resetCommandSpan(); + beginCommandSpan({ + commandPath: ['ktx', 'status'], + argv: ['--project-dir', '/tmp/private', 'status', '--json'], + projectDir: '/tmp/private', + hasProject: true, + attachProjectGroup: true, + startedAt: 100, + }); + + expect( + completeCommandSpan({ + completedAt: 125, + outcome: 'ok', + }), + ).toEqual({ + commandPath: ['ktx', 'status'], + durationMs: 25, + outcome: 'ok', + flagsPresent: { + 'project-dir': true, + json: true, + }, + hasProject: true, + projectDir: '/tmp/private', + projectGroupAttached: true, + }); + }); + + it('returns undefined when no preAction span exists', () => { + resetCommandSpan(); + expect(completeCommandSpan({ completedAt: 200, outcome: 'ok' })).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/command-hook.ts b/packages/cli/src/telemetry/command-hook.ts new file mode 100644 index 00000000..c748a398 --- /dev/null +++ b/packages/cli/src/telemetry/command-hook.ts @@ -0,0 +1,82 @@ +import { scrubErrorClass } from './scrubber.js'; + +export type CommandOutcome = 'ok' | 'error' | 'aborted'; + +interface CommandSpan { + commandPath: string[]; + argv: string[]; + projectDir?: string; + hasProject: boolean; + attachProjectGroup: boolean; + startedAt: number; +} + +export interface CompletedCommandSpan { + commandPath: string[]; + durationMs: number; + outcome: CommandOutcome; + errorClass?: string; + flagsPresent: Record; + hasProject: boolean; + projectDir?: string; + projectGroupAttached: boolean; +} + +let activeCommandSpan: CommandSpan | undefined; + +/** @internal */ +export function extractFlagsPresent(argv: string[]): Record { + const flags: Record = {}; + + for (const arg of argv) { + if (arg.startsWith('--') && arg.length > 2) { + const [name] = arg.slice(2).split('=', 1); + if (name) { + flags[name] = true; + } + continue; + } + + if (arg.startsWith('-') && arg.length > 1) { + for (const shortFlag of arg.slice(1)) { + flags[shortFlag] = true; + } + } + } + + return flags; +} + +export function beginCommandSpan(input: CommandSpan): void { + activeCommandSpan = input; +} + +export function completeCommandSpan(input: { + completedAt: number; + outcome: CommandOutcome; + error?: unknown; +}): CompletedCommandSpan | undefined { + const span = activeCommandSpan; + activeCommandSpan = undefined; + if (!span) { + return undefined; + } + + const errorClass = input.error ? scrubErrorClass(input.error) : undefined; + + return { + commandPath: span.commandPath, + durationMs: Math.max(0, input.completedAt - span.startedAt), + outcome: input.outcome, + ...(errorClass ? { errorClass } : {}), + flagsPresent: extractFlagsPresent(span.argv), + hasProject: span.hasProject, + projectDir: span.projectDir, + projectGroupAttached: span.attachProjectGroup, + }; +} + +/** @internal */ +export function resetCommandSpan(): void { + activeCommandSpan = undefined; +} diff --git a/packages/cli/src/telemetry/emitter.test.ts b/packages/cli/src/telemetry/emitter.test.ts new file mode 100644 index 00000000..4bb3dab2 --- /dev/null +++ b/packages/cli/src/telemetry/emitter.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetTelemetryEmitterForTests, + groupIdentifyProject, + shutdownTelemetryEmitter, + trackTelemetryEvent, +} from './emitter.js'; +import type { BuiltTelemetryEvent } from './events.js'; + +const captures: unknown[] = []; +const groupIdentifies: unknown[] = []; +const shutdown = vi.fn(async () => {}); + +function liveConfigId(): string { + return 'fixture'; +} + +vi.mock('posthog-node', () => ({ + PostHog: vi.fn().mockImplementation(function () { + return { + capture: (event: unknown) => captures.push(event), + groupIdentify: (event: unknown) => groupIdentifies.push(event), + shutdown, + }; + }), +})); + +function commandEvent(): BuiltTelemetryEvent<'command'> { + return { + name: 'command', + properties: { + cliVersion: '0.4.1', + nodeVersion: 'v22.0.0', + osPlatform: 'darwin', + osRelease: '25.0.0', + arch: 'arm64', + runtime: 'node', + isCi: false, + commandPath: ['ktx', 'status'], + durationMs: 1, + outcome: 'ok', + flagsPresent: {}, + hasProject: true, + projectGroupAttached: true, + }, + }; +} + +describe('telemetry emitter', () => { + beforeEach(() => { + captures.length = 0; + groupIdentifies.length = 0; + shutdown.mockClear(); + __resetTelemetryEmitterForTests(); + }); + + it('prints debug payloads without importing or sending to PostHog', async () => { + const stderr: string[] = []; + + await trackTelemetryEvent({ + event: commandEvent(), + distinctId: 'install-1', + projectId: 'project-1', + env: { KTX_TELEMETRY_DEBUG: '1' }, + stderr: { write: (chunk) => stderr.push(chunk) }, + }); + + expect(stderr.join('')).toContain('[telemetry]'); + expect(stderr.join('')).toContain('"event":"command"'); + expect(captures).toEqual([]); + }); + + it('does not send when config constants are blank', async () => { + await trackTelemetryEvent({ + event: commandEvent(), + distinctId: 'install-1', + projectId: 'project-1', + env: {}, + stderr: { write: () => {} }, + }); + + expect(captures).toEqual([]); + }); + + it('group-identifies once per project when live config is supplied', async () => { + await groupIdentifyProject({ + distinctId: 'install-1', + projectId: 'project-1', + projectApiKey: liveConfigId(), + host: 'https://us.i.posthog.com', + }); + await groupIdentifyProject({ + distinctId: 'install-1', + projectId: 'project-1', + projectApiKey: liveConfigId(), + host: 'https://us.i.posthog.com', + }); + + expect(groupIdentifies).toEqual([ + { + groupType: 'project', + groupKey: 'project-1', + distinctId: 'install-1', + }, + ]); + }); + + it('captures with distinctId, properties, and groups when live config is supplied', async () => { + await trackTelemetryEvent({ + event: commandEvent(), + distinctId: 'install-1', + projectId: 'project-1', + projectApiKey: liveConfigId(), + host: 'https://us.i.posthog.com', + env: {}, + stderr: { write: () => {} }, + }); + + expect(captures).toHaveLength(1); + expect(captures[0]).toMatchObject({ + distinctId: 'install-1', + event: 'command', + groups: { project: 'project-1' }, + properties: { + cliVersion: '0.4.1', + commandPath: ['ktx', 'status'], + }, + }); + }); + + it('shuts down the client without throwing', async () => { + await trackTelemetryEvent({ + event: commandEvent(), + distinctId: 'install-1', + projectApiKey: liveConfigId(), + host: 'https://us.i.posthog.com', + env: {}, + stderr: { write: () => {} }, + }); + + await expect(shutdownTelemetryEmitter()).resolves.toBeUndefined(); + expect(shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts new file mode 100644 index 00000000..70af1586 --- /dev/null +++ b/packages/cli/src/telemetry/emitter.ts @@ -0,0 +1,173 @@ +import type { BuiltTelemetryEvent } from './events.js'; + +export interface TelemetryEmitterEnv { + KTX_TELEMETRY_DEBUG?: string; + KTX_TELEMETRY_ENDPOINT?: string; +} + +export interface TelemetrySink { + write(chunk: string): void; +} + +type PostHogClient = { + capture(event: { + distinctId: string; + event: string; + properties: Record; + groups?: Record; + disableGeoip?: boolean; + }): void; + groupIdentify(event: { groupType: string; groupKey: string; distinctId?: string }): void; + shutdown(): Promise | void; +}; + +const POSTHOG_PROJECT_API_KEY = ''; +const POSTHOG_HOST = ''; +const SHUTDOWN_TIMEOUT_MS = 1500; + +let clientPromise: Promise | undefined; +const identifiedProjects = new Set(); + +function telemetryHost(env: TelemetryEmitterEnv, explicitHost?: string): string { + return explicitHost ?? env.KTX_TELEMETRY_ENDPOINT ?? POSTHOG_HOST; +} + +function telemetryProjectApiKey(explicitProjectApiKey?: string): string { + return explicitProjectApiKey ?? POSTHOG_PROJECT_API_KEY; +} + +function liveTelemetryConfigured(projectApiKey: string, host: string): boolean { + return projectApiKey.trim() !== '' && host.trim() !== ''; +} + +async function getPostHogClient(projectApiKey: string, host: string): Promise { + if (!liveTelemetryConfigured(projectApiKey, host)) { + return null; + } + + clientPromise ??= import('posthog-node') + .then(({ PostHog }) => new PostHog(projectApiKey, { host, flushAt: 1, flushInterval: 0, disableGeoip: true })) + .catch(() => null); + + return await clientPromise; +} + +function debugEnabled(env: TelemetryEmitterEnv): boolean { + return env.KTX_TELEMETRY_DEBUG === '1'; +} + +function writeDebugPayload(input: { + event: BuiltTelemetryEvent; + distinctId: string; + projectId?: string; + stderr: TelemetrySink; +}): void { + input.stderr.write( + `[telemetry] ${JSON.stringify({ + distinctId: input.distinctId, + event: input.event.name, + properties: input.event.properties, + groups: input.projectId ? { project: input.projectId } : undefined, + })}\n`, + ); +} + +/** @internal */ +export async function groupIdentifyProject(input: { + distinctId: string; + projectId: string; + env?: TelemetryEmitterEnv; + projectApiKey?: string; + host?: string; +}): Promise { + const env = input.env ?? process.env; + const projectApiKey = telemetryProjectApiKey(input.projectApiKey); + const host = telemetryHost(env, input.host); + const projectKey = `${host}:${input.projectId}`; + + if (identifiedProjects.has(projectKey)) { + return; + } + identifiedProjects.add(projectKey); + + const client = await getPostHogClient(projectApiKey, host); + if (!client) { + return; + } + + try { + client.groupIdentify({ + groupType: 'project', + groupKey: input.projectId, + distinctId: input.distinctId, + }); + } catch { + return; + } +} + +export async function trackTelemetryEvent(input: { + event: BuiltTelemetryEvent; + distinctId: string; + projectId?: string; + env?: TelemetryEmitterEnv; + stderr: TelemetrySink; + projectApiKey?: string; + host?: string; +}): Promise { + const env = input.env ?? process.env; + + if (debugEnabled(env)) { + writeDebugPayload(input); + return; + } + + const projectApiKey = telemetryProjectApiKey(input.projectApiKey); + const host = telemetryHost(env, input.host); + const client = await getPostHogClient(projectApiKey, host); + if (!client) { + return; + } + + try { + if (input.projectId) { + await groupIdentifyProject({ + distinctId: input.distinctId, + projectId: input.projectId, + env, + projectApiKey, + host, + }); + } + + client.capture({ + distinctId: input.distinctId, + event: input.event.name, + properties: input.event.properties, + groups: input.projectId ? { project: input.projectId } : undefined, + disableGeoip: true, + }); + } catch { + return; + } +} + +export async function shutdownTelemetryEmitter(): Promise { + const client = await clientPromise; + if (!client) { + return; + } + + await Promise.race([ + Promise.resolve(client.shutdown()).catch(() => undefined), + new Promise((resolve) => { + setTimeout(resolve, SHUTDOWN_TIMEOUT_MS); + }), + ]); +} + +/** @internal */ +export function __resetTelemetryEmitterForTests(): void { + clientPromise = undefined; + identifiedProjects.clear(); +} diff --git a/packages/cli/src/telemetry/events.snapshot.test.ts b/packages/cli/src/telemetry/events.snapshot.test.ts new file mode 100644 index 00000000..ca0fd483 --- /dev/null +++ b/packages/cli/src/telemetry/events.snapshot.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTelemetryEvent, type TelemetryCommonEnvelope } from './events.js'; + +const BLACKLIST = [ + '/Users/', + '/home/', + 'C:\\', + 'localhost', + '.local', + 'kaelio.com', + 'select ', + 'SELECT ', + 'INSERT', + 'CREATE', + '@', + 'password', + 'secret', + 'token', + 'key', +]; + +const envelope: TelemetryCommonEnvelope = { + cliVersion: '0.4.1', + nodeVersion: 'v22.0.0', + osPlatform: 'darwin', + osRelease: '25.0.0', + arch: 'arm64', + runtime: 'node', + isCi: false, +}; + +describe('telemetry privacy snapshot', () => { + it('does not emit known private substrings from phase 1 event payloads', () => { + const events = [ + buildTelemetryEvent('install_first_run', envelope, {}), + buildTelemetryEvent('command', envelope, { + commandPath: ['ktx', 'sql'], + durationMs: 10, + outcome: 'error', + errorClass: 'KtxProjectMissingAbortError', + flagsPresent: { + 'project-dir': true, + connection: true, + c: true, + }, + hasProject: false, + projectGroupAttached: false, + }), + ]; + + const payload = JSON.stringify(events); + + for (const forbidden of BLACKLIST) { + expect(payload).not.toContain(forbidden); + } + }); +}); diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts new file mode 100644 index 00000000..8735390b --- /dev/null +++ b/packages/cli/src/telemetry/events.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTelemetryEvent, + telemetryEventCatalog, + telemetryEventSchemas, + type TelemetryCommonEnvelope, +} from './events.js'; + +const envelope: TelemetryCommonEnvelope = { + cliVersion: '0.4.1', + nodeVersion: 'v22.0.0', + osPlatform: 'darwin', + osRelease: '25.0.0', + arch: 'arm64', + runtime: 'node', + isCi: false, +}; + +describe('telemetry event schemas', () => { + it('catalogs only phase 1 events', () => { + expect(telemetryEventCatalog.map((event) => event.name)).toEqual(['install_first_run', 'command']); + }); + + it('builds a strict install_first_run event', () => { + expect(buildTelemetryEvent('install_first_run', envelope, {})).toEqual({ + name: 'install_first_run', + properties: envelope, + }); + }); + + it('builds a strict command event with project grouping fields', () => { + expect( + buildTelemetryEvent('command', envelope, { + commandPath: ['ktx', 'status'], + durationMs: 12, + outcome: 'ok', + flagsPresent: { json: true }, + hasProject: true, + projectGroupAttached: true, + }), + ).toEqual({ + name: 'command', + properties: { + ...envelope, + commandPath: ['ktx', 'status'], + durationMs: 12, + outcome: 'ok', + flagsPresent: { json: true }, + hasProject: true, + projectGroupAttached: true, + }, + }); + }); + + it('rejects unmodeled event properties', () => { + expect(() => + telemetryEventSchemas.command.parse({ + ...envelope, + commandPath: ['ktx', 'status'], + durationMs: 12, + outcome: 'ok', + flagsPresent: {}, + hasProject: true, + projectGroupAttached: true, + tableName: 'private_table', + }), + ).toThrow(); + }); + + it('rejects raw string fields that are not in the phase 1 schema', () => { + expect(JSON.stringify(telemetryEventSchemas)).not.toContain('tableName'); + expect(JSON.stringify(telemetryEventSchemas)).not.toContain('sql'); + expect(JSON.stringify(telemetryEventSchemas)).not.toContain('path'); + }); +}); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts new file mode 100644 index 00000000..b72fdc26 --- /dev/null +++ b/packages/cli/src/telemetry/events.ts @@ -0,0 +1,92 @@ +import { arch, platform, release } from 'node:os'; +import { z } from 'zod'; + +const telemetryCommonEnvelopeSchema = z + .object({ + cliVersion: z.string(), + nodeVersion: z.string(), + osPlatform: z.string(), + osRelease: z.string(), + arch: z.string(), + runtime: z.literal('node'), + isCi: z.boolean(), + }) + .strict(); + +const installFirstRunSchema = telemetryCommonEnvelopeSchema.strict(); + +const commandSchema = telemetryCommonEnvelopeSchema + .extend({ + commandPath: z.array(z.string()).min(1), + durationMs: z.number().nonnegative(), + outcome: z.enum(['ok', 'error', 'aborted']), + errorClass: z.string().optional(), + flagsPresent: z.record(z.string(), z.boolean()), + hasProject: z.boolean(), + projectGroupAttached: z.boolean(), + }) + .strict(); + +/** @internal */ +export const telemetryEventSchemas = { + install_first_run: installFirstRunSchema, + command: commandSchema, +} as const; + +/** @internal */ +export const telemetryEventCatalog = [ + { + name: 'install_first_run', + description: 'Emitted once when ~/.ktx/telemetry.json is created.', + fields: [], + }, + { + name: 'command', + description: 'Emitted once for each Commander action that reaches preAction.', + fields: [ + 'commandPath', + 'durationMs', + 'outcome', + 'errorClass', + 'flagsPresent', + 'hasProject', + 'projectGroupAttached', + ], + }, +] as const; + +export type TelemetryEventName = keyof typeof telemetryEventSchemas; +export type TelemetryCommonEnvelope = z.infer; + +export type TelemetryEventProperties = z.infer< + (typeof telemetryEventSchemas)[Name] +>; + +export interface BuiltTelemetryEvent { + name: Name; + properties: TelemetryEventProperties; +} + +export function buildCommonEnvelope(input: { cliVersion: string; isCi: boolean }): TelemetryCommonEnvelope { + return { + cliVersion: input.cliVersion, + nodeVersion: process.version, + osPlatform: platform(), + osRelease: release(), + arch: arch(), + runtime: 'node', + isCi: input.isCi, + }; +} + +export function buildTelemetryEvent( + name: Name, + envelope: TelemetryCommonEnvelope, + fields: Omit, keyof TelemetryCommonEnvelope>, +): BuiltTelemetryEvent { + const schema = telemetryEventSchemas[name]; + return { + name, + properties: schema.parse({ ...envelope, ...fields }) as TelemetryEventProperties, + }; +} diff --git a/packages/cli/src/telemetry/identity.test.ts b/packages/cli/src/telemetry/identity.test.ts new file mode 100644 index 00000000..15dfeae3 --- /dev/null +++ b/packages/cli/src/telemetry/identity.test.ts @@ -0,0 +1,159 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + computeTelemetryProjectId, + loadTelemetryIdentity, + TELEMETRY_NOTICE, + type TelemetryIdentityEnv, +} from './identity.js'; + +function makeIo(stdoutIsTTY = true) { + let stderr = ''; + return { + io: { + stdout: { isTTY: stdoutIsTTY, write: () => {} }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +describe('telemetry identity', () => { + let homeDir: string; + let env: TelemetryIdentityEnv; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-home-')); + env = {}; + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it('creates the telemetry file and one-line notice on first interactive enabled load', async () => { + const testIo = makeIo(true); + + const identity = await loadTelemetryIdentity({ + homeDir, + env, + stdoutIsTTY: true, + stderr: testIo.io.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }); + + expect(identity.enabled).toBe(true); + expect(identity.installId).toMatch(/^[0-9a-f-]{36}$/); + expect(identity.createdFile).toBe(true); + expect(identity.noticeShown).toBe(true); + expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`); + + const stored = JSON.parse(await readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')) as { + enabled: boolean; + noticeShownVersion: number; + }; + expect(stored.enabled).toBe(true); + expect(stored.noticeShownVersion).toBe(1); + }); + + it('does not create a file when env disables telemetry', async () => { + const identity = await loadTelemetryIdentity({ + homeDir, + env: { KTX_TELEMETRY_DISABLED: '1' }, + stdoutIsTTY: true, + stderr: makeIo(true).io.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }); + + expect(identity.enabled).toBe(false); + await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); + }); + + it('does not create a file for CI or non-TTY command invocations', async () => { + await expect( + loadTelemetryIdentity({ + homeDir, + env: { CI: '1' }, + stdoutIsTTY: true, + stderr: makeIo(true).io.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }), + ).resolves.toMatchObject({ enabled: false, createdFile: false }); + + await expect( + loadTelemetryIdentity({ + homeDir, + env: {}, + stdoutIsTTY: false, + stderr: makeIo(false).io.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }), + ).resolves.toMatchObject({ enabled: false, createdFile: false }); + }); + + it('honors persistent enabled false', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + join(homeDir, '.ktx', 'telemetry.json'), + JSON.stringify( + { + installId: '00000000-0000-4000-8000-000000000000', + enabled: false, + noticeShownAt: '2026-05-22T14:33:02.000Z', + noticeShownVersion: 1, + createdAt: '2026-05-22T14:33:02.000Z', + }, + null, + 2, + ) + '\n', + 'utf-8', + ); + + await expect( + loadTelemetryIdentity({ + homeDir, + env, + stdoutIsTTY: true, + stderr: makeIo(true).io.stderr, + now: () => new Date('2026-05-22T15:00:00.000Z'), + }), + ).resolves.toMatchObject({ + installId: '00000000-0000-4000-8000-000000000000', + enabled: false, + createdFile: false, + }); + }); + + it('recreates a corrupted file instead of surfacing an error to users', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile(join(homeDir, '.ktx', 'telemetry.json'), '{bad json', 'utf-8'); + + const identity = await loadTelemetryIdentity({ + homeDir, + env, + stdoutIsTTY: true, + stderr: makeIo(true).io.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }); + + expect(identity.enabled).toBe(true); + expect(identity.createdFile).toBe(true); + }); + + it('derives a salted project hash without exposing the path', () => { + const projectDir = resolve('/tmp/acme-private-project'); + const projectId = computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir); + + expect(projectId).toMatch(/^[a-f0-9]{64}$/); + expect(projectId).not.toContain('acme'); + expect(computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir)).toBe(projectId); + expect(computeTelemetryProjectId('11111111-1111-4111-8111-111111111111', projectDir)).not.toBe(projectId); + }); +}); diff --git a/packages/cli/src/telemetry/identity.ts b/packages/cli/src/telemetry/identity.ts new file mode 100644 index 00000000..1d6c2fcf --- /dev/null +++ b/packages/cli/src/telemetry/identity.ts @@ -0,0 +1,126 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { z } from 'zod'; + +/** @internal */ +export const TELEMETRY_NOTICE = + 'ktx collects anonymous usage data to improve the product. Opt out: set KTX_TELEMETRY_DISABLED=1.'; + +const NOTICE_VERSION = 1; + +const telemetryFileSchema = z + .object({ + installId: z.uuid(), + enabled: z.boolean(), + noticeShownAt: z.string().optional(), + noticeShownVersion: z.number().int().optional(), + createdAt: z.string(), + }) + .strict(); + +/** @internal */ +export interface TelemetryIdentityEnv { + KTX_TELEMETRY_DISABLED?: string; + DO_NOT_TRACK?: string; + CI?: string; +} + +export interface LoadTelemetryIdentityOptions { + homeDir?: string; + env?: TelemetryIdentityEnv; + stdoutIsTTY: boolean; + stderr: { write(chunk: string): void }; + now?: () => Date; +} + +export interface TelemetryIdentityState { + installId?: string; + enabled: boolean; + createdFile: boolean; + noticeShown: boolean; + path: string; +} + +function telemetryPath(homeDir: string): string { + return join(homeDir, '.ktx', 'telemetry.json'); +} + +function envDisablesTelemetry(env: TelemetryIdentityEnv): boolean { + return Boolean(env.KTX_TELEMETRY_DISABLED || env.DO_NOT_TRACK || env.CI); +} + +async function readTelemetryFile(path: string): Promise | null> { + try { + return telemetryFileSchema.parse(JSON.parse(await readFile(path, 'utf-8'))); + } catch { + return null; + } +} + +async function writeTelemetryFile(path: string, value: z.infer): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8'); +} + +export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOptions): Promise { + const env = options.env ?? process.env; + const path = telemetryPath(options.homeDir ?? homedir()); + + if (envDisablesTelemetry(env) || options.stdoutIsTTY !== true) { + const existing = await readTelemetryFile(path); + return { + installId: existing?.installId, + enabled: false, + createdFile: false, + noticeShown: false, + path, + }; + } + + const existing = await readTelemetryFile(path); + if (existing) { + return { + installId: existing.installId, + enabled: existing.enabled, + createdFile: false, + noticeShown: false, + path, + }; + } + + const timestamp = (options.now ?? (() => new Date()))().toISOString(); + const next = { + installId: randomUUID(), + enabled: true, + noticeShownAt: timestamp, + noticeShownVersion: NOTICE_VERSION, + createdAt: timestamp, + }; + + try { + await writeTelemetryFile(path, next); + } catch { + return { + enabled: false, + createdFile: false, + noticeShown: false, + path, + }; + } + + options.stderr.write(`${TELEMETRY_NOTICE}\n`); + + return { + installId: next.installId, + enabled: true, + createdFile: true, + noticeShown: true, + path, + }; +} + +export function computeTelemetryProjectId(installId: string, projectDir: string): string { + return createHash('sha256').update(`${installId}:${resolve(projectDir)}`).digest('hex'); +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts new file mode 100644 index 00000000..617c4b7f --- /dev/null +++ b/packages/cli/src/telemetry/index.ts @@ -0,0 +1,80 @@ +import type { KtxCliIo, KtxCliPackageInfo } from '../cli-runtime.js'; +import { + beginCommandSpan, + completeCommandSpan, + type CommandOutcome, + type CompletedCommandSpan, +} from './command-hook.js'; +import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js'; +import { buildCommonEnvelope, buildTelemetryEvent } from './events.js'; +import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js'; + +export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter }; +export type { CommandOutcome, CompletedCommandSpan }; + +async function emitInstallFirstRunIfNeeded(input: { + identity: Awaited>; + packageInfo: KtxCliPackageInfo; + io: KtxCliIo; +}): Promise { + if (!input.identity.enabled || !input.identity.createdFile || !input.identity.installId) { + return; + } + + await trackTelemetryEvent({ + event: buildTelemetryEvent( + 'install_first_run', + buildCommonEnvelope({ + cliVersion: input.packageInfo.version, + isCi: Boolean(process.env.CI), + }), + {}, + ), + distinctId: input.identity.installId, + env: process.env, + stderr: input.io.stderr, + }); +} + +export async function emitCompletedCommand(input: { + completed: CompletedCommandSpan | undefined; + packageInfo: KtxCliPackageInfo; + io: KtxCliIo; +}): Promise { + if (!input.completed) { + return; + } + + const identity = await loadTelemetryIdentity({ + stdoutIsTTY: input.io.stdout.isTTY === true, + stderr: input.io.stderr, + env: process.env, + }); + + if (!identity.enabled || !identity.installId) { + return; + } + + await emitInstallFirstRunIfNeeded({ identity, packageInfo: input.packageInfo, io: input.io }); + + const projectId = + input.completed.projectGroupAttached && input.completed.projectDir + ? computeTelemetryProjectId(identity.installId, input.completed.projectDir) + : undefined; + + const { projectDir: _projectDir, ...eventFields } = input.completed; + await trackTelemetryEvent({ + event: buildTelemetryEvent( + 'command', + buildCommonEnvelope({ + cliVersion: input.packageInfo.version, + isCi: Boolean(process.env.CI), + }), + eventFields, + ), + distinctId: identity.installId, + projectId, + env: process.env, + stderr: input.io.stderr, + }); +} diff --git a/packages/cli/src/telemetry/scrubber.test.ts b/packages/cli/src/telemetry/scrubber.test.ts new file mode 100644 index 00000000..87eb74d4 --- /dev/null +++ b/packages/cli/src/telemetry/scrubber.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { scrubErrorClass } from './scrubber.js'; + +class KtxProjectMissingAbortError extends Error {} + +describe('scrubErrorClass', () => { + it('keeps normal JavaScript class names', () => { + expect(scrubErrorClass(new KtxProjectMissingAbortError('missing'))).toBe('KtxProjectMissingAbortError'); + }); + + it('drops path-like, URL-like, email-like, and long values', () => { + expect(scrubErrorClass({ constructor: { name: '/Users/alice/project' } })).toBeUndefined(); + expect(scrubErrorClass({ constructor: { name: 'https://example.test/error' } })).toBeUndefined(); + expect(scrubErrorClass({ constructor: { name: 'alice@example.test' } })).toBeUndefined(); + expect(scrubErrorClass({ constructor: { name: 'A'.repeat(81) } })).toBeUndefined(); + }); + + it('drops lowercase, spaced, and non-error-like values', () => { + expect(scrubErrorClass({ constructor: { name: 'lowercaseError' } })).toBeUndefined(); + expect(scrubErrorClass({ constructor: { name: 'Bad Error' } })).toBeUndefined(); + expect(scrubErrorClass('plain string')).toBeUndefined(); + expect(scrubErrorClass(null)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/scrubber.ts b/packages/cli/src/telemetry/scrubber.ts new file mode 100644 index 00000000..27e41f87 --- /dev/null +++ b/packages/cli/src/telemetry/scrubber.ts @@ -0,0 +1,28 @@ +const MAX_ERROR_CLASS_LENGTH = 80; +const ERROR_CLASS_PATTERN = /^[A-Z][A-Za-z0-9_]*$/; +const PRIVATE_STRING_MARKERS = ['/', '\\', '@', '://']; + +export function scrubErrorClass(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + + const constructorName = (error as { constructor?: { name?: unknown } }).constructor?.name; + if (typeof constructorName !== 'string') { + return undefined; + } + + if (constructorName.length > MAX_ERROR_CLASS_LENGTH) { + return undefined; + } + + if (PRIVATE_STRING_MARKERS.some((marker) => constructorName.includes(marker))) { + return undefined; + } + + if (!ERROR_CLASS_PATTERN.test(constructorName)) { + return undefined; + } + + return constructorName; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d513e057..de0d2c24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + posthog-node: + specifier: ^5.0.0 + version: 5.0.0 react: specifier: ^19.2.6 version: 19.2.6 @@ -4944,6 +4947,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthog-node@5.0.0: + resolution: {integrity: sha512-gontigBt1pGHGXZme3+ojDdCYL66h/vvo+6KaQ6A51xqUOYgRvyzCLkS9Xv816jNBesRO8ouRjG428SDb2fFkg==} + engines: {node: '>=20'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -11215,6 +11222,8 @@ snapshots: dependencies: xtend: 4.0.2 + posthog-node@5.0.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2