mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat: add telemetry phase 1
This commit is contained in:
parent
fd2ba62d92
commit
9efcd1f97d
20 changed files with 1368 additions and 9 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"title": "Community",
|
||||
"defaultOpen": true,
|
||||
"pages": ["support", "contributing"]
|
||||
"pages": ["support", "contributing", "telemetry"]
|
||||
}
|
||||
|
|
|
|||
65
docs-site/content/docs/community/telemetry.mdx
Normal file
65
docs-site/content/docs/community/telemetry.mdx
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
122
packages/cli/src/cli-program-telemetry.test.ts
Normal file
122
packages/cli/src/cli-program-telemetry.test.ts
Normal 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]');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
56
packages/cli/src/telemetry/command-hook.test.ts
Normal file
56
packages/cli/src/telemetry/command-hook.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/telemetry/command-hook.ts
Normal file
82
packages/cli/src/telemetry/command-hook.ts
Normal 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;
|
||||
}
|
||||
145
packages/cli/src/telemetry/emitter.test.ts
Normal file
145
packages/cli/src/telemetry/emitter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
packages/cli/src/telemetry/emitter.ts
Normal file
173
packages/cli/src/telemetry/emitter.ts
Normal 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();
|
||||
}
|
||||
58
packages/cli/src/telemetry/events.snapshot.test.ts
Normal file
58
packages/cli/src/telemetry/events.snapshot.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
packages/cli/src/telemetry/events.test.ts
Normal file
76
packages/cli/src/telemetry/events.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
92
packages/cli/src/telemetry/events.ts
Normal file
92
packages/cli/src/telemetry/events.ts
Normal 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>,
|
||||
};
|
||||
}
|
||||
159
packages/cli/src/telemetry/identity.test.ts
Normal file
159
packages/cli/src/telemetry/identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
packages/cli/src/telemetry/identity.ts
Normal file
126
packages/cli/src/telemetry/identity.ts
Normal 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');
|
||||
}
|
||||
80
packages/cli/src/telemetry/index.ts
Normal file
80
packages/cli/src/telemetry/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
25
packages/cli/src/telemetry/scrubber.test.ts
Normal file
25
packages/cli/src/telemetry/scrubber.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/cli/src/telemetry/scrubber.ts
Normal file
28
packages/cli/src/telemetry/scrubber.ts
Normal 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
9
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue