mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-13 11:22:11 +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)
|
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
|
## Common Commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"title": "Community",
|
"title": "Community",
|
||||||
"defaultOpen": true,
|
"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": {
|
"packages/cli": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"src/print-command-tree.ts!",
|
"src/print-command-tree.ts!",
|
||||||
|
"src/telemetry/index.ts!",
|
||||||
"scripts/**/*.mjs",
|
"scripts/**/*.mjs",
|
||||||
"src/**/*.test-utils.ts",
|
"src/**/*.test-utils.ts",
|
||||||
"src/**/acceptance-fixtures.ts",
|
"src/**/acceptance-fixtures.ts",
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
"posthog-node": "^5.0.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"simple-git": "3.36.0",
|
"simple-git": "3.36.0",
|
||||||
"snowflake-sdk": "^2.4.1",
|
"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 { renderMissingProjectMessage } from './doctor.js';
|
||||||
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
|
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
|
||||||
import { profileMark, profileSpan } from './startup-profile.js';
|
import { profileMark, profileSpan } from './startup-profile.js';
|
||||||
|
import type { CommandOutcome } from './telemetry/index.js';
|
||||||
|
|
||||||
profileMark('module:cli-program');
|
profileMark('module:cli-program');
|
||||||
|
|
||||||
|
|
@ -43,6 +44,8 @@ export interface BuildKtxProgramOptions {
|
||||||
packageInfo: KtxCliPackageInfo;
|
packageInfo: KtxCliPackageInfo;
|
||||||
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||||
setExitCode?: (code: number) => void;
|
setExitCode?: (code: number) => void;
|
||||||
|
argv?: string[];
|
||||||
|
setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommanderExitLike = { exitCode: number; code: string; message: string };
|
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);
|
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 {
|
function firstTopLevelCommandToken(argv: string[]): string | null {
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
|
|
@ -392,9 +414,24 @@ async function runBareInteractiveCommand(
|
||||||
|
|
||||||
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
||||||
const program = createBaseProgram(options.packageInfo, options.io);
|
const program = createBaseProgram(options.packageInfo, options.io);
|
||||||
program.hook('preAction', (_thisCommand, actionCommand) => {
|
program.hook('preAction', async (_thisCommand, actionCommand) => {
|
||||||
writeProjectDir(options.io, actionCommand as CommandPathNode);
|
const telemetry = await import('./telemetry/index.js');
|
||||||
ensureProjectAvailable(options.io, actionCommand as CommandPathNode);
|
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 = {
|
const context: KtxCliCommandContext = {
|
||||||
|
|
@ -435,14 +472,19 @@ export async function runCommanderKtxCli(
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
profileMark('commander:entry');
|
profileMark('commander:entry');
|
||||||
let exitCode = 0;
|
let exitCode = 0;
|
||||||
|
let telemetryModule: typeof import('./telemetry/index.js') | undefined;
|
||||||
const program = buildKtxProgram({
|
const program = buildKtxProgram({
|
||||||
io,
|
io,
|
||||||
deps,
|
deps,
|
||||||
packageInfo: info,
|
packageInfo: info,
|
||||||
runInit: options.runInit,
|
runInit: options.runInit,
|
||||||
|
argv,
|
||||||
setExitCode: (code: number) => {
|
setExitCode: (code: number) => {
|
||||||
exitCode = code;
|
exitCode = code;
|
||||||
},
|
},
|
||||||
|
setTelemetryModule: (telemetry) => {
|
||||||
|
telemetryModule = telemetry;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
profileMark('commander:program-built');
|
profileMark('commander:program-built');
|
||||||
const context: KtxCliCommandContext = {
|
const context: KtxCliCommandContext = {
|
||||||
|
|
@ -477,17 +519,29 @@ export async function runCommanderKtxCli(
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parseError: unknown;
|
||||||
try {
|
try {
|
||||||
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
|
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
parseError = error;
|
||||||
if (isKtxProjectMissingAbortError(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)) {
|
} finally {
|
||||||
return error.exitCode === 0 ? 0 : 1;
|
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;
|
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:
|
pg:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
posthog-node:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.6
|
version: 19.2.6
|
||||||
|
|
@ -4944,6 +4947,10 @@ packages:
|
||||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
prebuild-install@7.1.3:
|
||||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -11215,6 +11222,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
|
|
||||||
|
posthog-node@5.0.0: {}
|
||||||
|
|
||||||
prebuild-install@7.1.3:
|
prebuild-install@7.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue