feat: add telemetry phase 1

This commit is contained in:
Andrey Avtomonov 2026-05-22 15:38:51 +02:00
parent fd2ba62d92
commit 9efcd1f97d
20 changed files with 1368 additions and 9 deletions

View file

@ -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 |

View file

@ -1,5 +1,5 @@
{
"title": "Community",
"defaultOpen": true,
"pages": ["support", "contributing"]
"pages": ["support", "contributing", "telemetry"]
}

View file

@ -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.

View file

@ -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",

View file

@ -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",

View file

@ -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]');
});
});

View file

@ -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<number>;
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<number> {
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;

View file

@ -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();
});
});

View file

@ -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<string, boolean>;
hasProject: boolean;
projectDir?: string;
projectGroupAttached: boolean;
}
let activeCommandSpan: CommandSpan | undefined;
/** @internal */
export function extractFlagsPresent(argv: string[]): Record<string, boolean> {
const flags: Record<string, boolean> = {};
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;
}

View file

@ -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);
});
});

View file

@ -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<string, unknown>;
groups?: Record<string, string>;
disableGeoip?: boolean;
}): void;
groupIdentify(event: { groupType: string; groupKey: string; distinctId?: string }): void;
shutdown(): Promise<void> | void;
};
const POSTHOG_PROJECT_API_KEY = '';
const POSTHOG_HOST = '';
const SHUTDOWN_TIMEOUT_MS = 1500;
let clientPromise: Promise<PostHogClient | null> | undefined;
const identifiedProjects = new Set<string>();
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<PostHogClient | null> {
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<void> {
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<void> {
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<void> {
const client = await clientPromise;
if (!client) {
return;
}
await Promise.race([
Promise.resolve(client.shutdown()).catch(() => undefined),
new Promise<void>((resolve) => {
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS);
}),
]);
}
/** @internal */
export function __resetTelemetryEmitterForTests(): void {
clientPromise = undefined;
identifiedProjects.clear();
}

View file

@ -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);
}
});
});

View file

@ -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');
});
});

View file

@ -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<typeof telemetryCommonEnvelopeSchema>;
export type TelemetryEventProperties<Name extends TelemetryEventName> = z.infer<
(typeof telemetryEventSchemas)[Name]
>;
export interface BuiltTelemetryEvent<Name extends TelemetryEventName = TelemetryEventName> {
name: Name;
properties: TelemetryEventProperties<Name>;
}
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 extends TelemetryEventName>(
name: Name,
envelope: TelemetryCommonEnvelope,
fields: Omit<TelemetryEventProperties<Name>, keyof TelemetryCommonEnvelope>,
): BuiltTelemetryEvent<Name> {
const schema = telemetryEventSchemas[name];
return {
name,
properties: schema.parse({ ...envelope, ...fields }) as TelemetryEventProperties<Name>,
};
}

View file

@ -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);
});
});

View file

@ -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<z.infer<typeof telemetryFileSchema> | null> {
try {
return telemetryFileSchema.parse(JSON.parse(await readFile(path, 'utf-8')));
} catch {
return null;
}
}
async function writeTelemetryFile(path: string, value: z.infer<typeof telemetryFileSchema>): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
}
export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOptions): Promise<TelemetryIdentityState> {
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');
}

View file

@ -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<ReturnType<typeof loadTelemetryIdentity>>;
packageInfo: KtxCliPackageInfo;
io: KtxCliIo;
}): Promise<void> {
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<void> {
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,
});
}

View file

@ -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();
});
});

View file

@ -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;
}

9
pnpm-lock.yaml generated
View file

@ -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