mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat(telemetry): collect PostHog $exception error reports in CLI and daemon (#262)
* feat(telemetry): add node exception reporter * feat(telemetry): report node cli exceptions * feat(telemetry): add daemon exception reporter * feat(telemetry): report daemon exceptions * docs(telemetry): document error reports * fix(telemetry): pass redaction snapshots from node call sites * test(telemetry): verify prepared node exception payload * fix(telemetry): close daemon exception lifecycle gaps * test(telemetry): verify prepared daemon exception payload * test(telemetry): close error collection acceptance gaps * test(telemetry): close posthog exception acceptance gaps
This commit is contained in:
parent
c3d8cedb0b
commit
fb7b94b60e
36 changed files with 2870 additions and 140 deletions
|
|
@ -16,6 +16,16 @@ type PostHogClient = {
|
|||
properties: Record<string, unknown>;
|
||||
groups?: Record<string, string>;
|
||||
}): void;
|
||||
captureException(
|
||||
error: unknown,
|
||||
distinctId?: string,
|
||||
additionalProperties?: Record<string, unknown>,
|
||||
): void;
|
||||
captureExceptionImmediate(
|
||||
error: unknown,
|
||||
distinctId?: string,
|
||||
additionalProperties?: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
shutdown(): Promise<void> | void;
|
||||
};
|
||||
|
||||
|
|
@ -105,6 +115,57 @@ export async function trackTelemetryEvent(input: {
|
|||
}
|
||||
}
|
||||
|
||||
function writeDebugExceptionPayload(input: {
|
||||
error: Error;
|
||||
distinctId: string;
|
||||
properties: Record<string, unknown>;
|
||||
stderr: TelemetrySink;
|
||||
}): void {
|
||||
input.stderr.write(
|
||||
`[telemetry-exception] ${JSON.stringify({
|
||||
distinctId: input.distinctId,
|
||||
message: input.error.message,
|
||||
name: input.error.name,
|
||||
properties: input.properties,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function trackTelemetryException(input: {
|
||||
error: Error;
|
||||
distinctId: string;
|
||||
properties: Record<string, unknown>;
|
||||
env?: TelemetryEmitterEnv;
|
||||
stderr: TelemetrySink;
|
||||
projectApiKey?: string;
|
||||
host?: string;
|
||||
immediate?: boolean;
|
||||
}): Promise<void> {
|
||||
const env = input.env ?? process.env;
|
||||
|
||||
if (debugEnabled(env)) {
|
||||
writeDebugExceptionPayload(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.immediate) {
|
||||
await client.captureExceptionImmediate(input.error, input.distinctId, input.properties);
|
||||
return;
|
||||
}
|
||||
client.captureException(input.error, input.distinctId, input.properties);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownTelemetryEmitter(): Promise<void> {
|
||||
const client = await clientPromise;
|
||||
if (!client) {
|
||||
|
|
|
|||
201
packages/cli/src/telemetry/exception.ts
Normal file
201
packages/cli/src/telemetry/exception.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { inspect } from 'node:util';
|
||||
|
||||
import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
|
||||
import { buildCommonEnvelope } from './events.js';
|
||||
import { trackTelemetryException } from './emitter.js';
|
||||
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
|
||||
|
||||
export interface ExceptionContext {
|
||||
source: string;
|
||||
handled: boolean;
|
||||
fatal: boolean;
|
||||
extra?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
type AnyObject = object;
|
||||
|
||||
const reportedObjects = new WeakSet<AnyObject>();
|
||||
const recentHandledPrimitives: string[] = [];
|
||||
const RECENT_PRIMITIVE_LIMIT = 128;
|
||||
|
||||
function primitiveKey(value: unknown): string {
|
||||
return `${typeof value}:${String(value)}`;
|
||||
}
|
||||
|
||||
function rememberHandledPrimitive(value: unknown): void {
|
||||
recentHandledPrimitives.push(primitiveKey(value));
|
||||
if (recentHandledPrimitives.length > RECENT_PRIMITIVE_LIMIT) {
|
||||
recentHandledPrimitives.splice(0, recentHandledPrimitives.length - RECENT_PRIMITIVE_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
function consumeHandledPrimitive(value: unknown): boolean {
|
||||
const key = primitiveKey(value);
|
||||
const index = recentHandledPrimitives.indexOf(key);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
recentHandledPrimitives.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean {
|
||||
if ((typeof error === 'object' || typeof error === 'function') && error !== null) {
|
||||
if (reportedObjects.has(error)) {
|
||||
return true;
|
||||
}
|
||||
reportedObjects.add(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
rememberHandledPrimitive(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return consumeHandledPrimitive(error);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function redactStaticPatterns(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, '$1[redacted]$3')
|
||||
.replace(/\b(password|pwd)=([^;&\s]+)/gi, '$1=[redacted]')
|
||||
.replace(/\bAuthorization\s*:\s*[^\r\n,;]+/gi, 'Authorization: [redacted]')
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
||||
.replace(/\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
|
||||
.replace(/\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)/g, '$1=[redacted]')
|
||||
.replace(/([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+/gi, '$1[redacted]');
|
||||
}
|
||||
|
||||
function redactText(value: string, secrets: ReadonlyArray<string>): string {
|
||||
let redacted = value;
|
||||
for (const secret of secrets) {
|
||||
if (secret) {
|
||||
redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[redacted]');
|
||||
}
|
||||
}
|
||||
return redactStaticPatterns(redacted);
|
||||
}
|
||||
|
||||
const FORBIDDEN_EXTRA_PROPERTY_KEYS = new Set([
|
||||
'argv',
|
||||
'args',
|
||||
'env',
|
||||
'environment',
|
||||
'sql',
|
||||
'query',
|
||||
'prompt',
|
||||
'mcparguments',
|
||||
'mcpargs',
|
||||
'tablename',
|
||||
'schemaname',
|
||||
'columnname',
|
||||
'databaseurl',
|
||||
'connectionstring',
|
||||
'url',
|
||||
'password',
|
||||
'token',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
]);
|
||||
|
||||
function safeExtraProperties(
|
||||
extra: Record<string, string | number | boolean> | undefined,
|
||||
): Record<string, string | number | boolean> {
|
||||
const safe: Record<string, string | number | boolean> = {};
|
||||
for (const [key, value] of Object.entries(extra ?? {})) {
|
||||
if (!FORBIDDEN_EXTRA_PROPERTY_KEYS.has(key.replace(/[^a-z0-9_]/gi, '').toLowerCase())) {
|
||||
safe[key] = value;
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function toMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return inspect(error, { depth: 4, breakLength: 120 });
|
||||
}
|
||||
|
||||
function sanitizedError(error: unknown, secrets: ReadonlyArray<string>): Error {
|
||||
if (error instanceof Error) {
|
||||
const cause = 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
|
||||
const clone = new Error(redactText(error.message, secrets), {
|
||||
...(cause !== undefined ? { cause: sanitizedError(cause, secrets) } : {}),
|
||||
});
|
||||
clone.name = error.name;
|
||||
if (error.stack) {
|
||||
clone.stack = redactText(error.stack, secrets);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
return new Error(redactText(toMessage(error), secrets));
|
||||
}
|
||||
|
||||
export async function reportException(input: {
|
||||
error: unknown;
|
||||
context: ExceptionContext;
|
||||
io: KtxCliIo;
|
||||
packageInfo?: KtxCliPackageInfo;
|
||||
projectDir?: string;
|
||||
immediate?: boolean;
|
||||
redactionSecrets?: ReadonlyArray<string>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const debug = process.env.KTX_TELEMETRY_DEBUG === '1';
|
||||
const identity = await loadTelemetryIdentity({
|
||||
stderr: input.io.stderr,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if ((!identity.enabled || !identity.installId) && !debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
|
||||
const installId = identity.installId ?? 'debug';
|
||||
const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined;
|
||||
const safeError = sanitizedError(input.error, input.redactionSecrets ?? []);
|
||||
const properties: Record<string, unknown> = {
|
||||
...buildCommonEnvelope({
|
||||
cliVersion: packageInfo.version,
|
||||
isCi: Boolean(process.env.CI),
|
||||
}),
|
||||
source: input.context.source,
|
||||
handled: input.context.handled,
|
||||
fatal: input.context.fatal,
|
||||
...(projectId ? { projectId } : {}),
|
||||
...safeExtraProperties(input.context.extra),
|
||||
};
|
||||
|
||||
delete properties.$groups;
|
||||
await trackTelemetryException({
|
||||
error: safeError,
|
||||
distinctId: installId,
|
||||
properties,
|
||||
env: process.env,
|
||||
stderr: input.io.stderr,
|
||||
immediate: input.immediate,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function __resetTelemetryExceptionStateForTests(): void {
|
||||
recentHandledPrimitives.length = 0;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type CompletedCommandSpan,
|
||||
} from './command-hook.js';
|
||||
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
|
||||
import { reportException, type ExceptionContext } from './exception.js';
|
||||
import {
|
||||
buildCommonEnvelope,
|
||||
buildTelemetryEvent,
|
||||
|
|
@ -17,8 +18,8 @@ import {
|
|||
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
|
||||
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
|
||||
|
||||
export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter };
|
||||
export type { CommandOutcome, CompletedCommandSpan };
|
||||
export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
|
||||
export type { CommandOutcome, CompletedCommandSpan, ExceptionContext };
|
||||
|
||||
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise<void> {
|
||||
const identity = await loadTelemetryIdentity({
|
||||
|
|
|
|||
117
packages/cli/src/telemetry/redaction-secrets.ts
Normal file
117
packages/cli/src/telemetry/redaction-secrets.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { resolveKtxConfigReference } from '../context/core/config-reference.js';
|
||||
import { loadKtxProject, type KtxLocalProject } from '../context/project/project.js';
|
||||
|
||||
const SENSITIVE_KEY =
|
||||
/(password|secret|token|api[_-]?key|auth[_-]?token|auth_token_ref|private[_-]?key|passphrase|credential|authorization|url)$/i;
|
||||
|
||||
type TelemetryRedactionProject = Pick<KtxLocalProject, 'config' | 'projectDir'>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function addSecret(values: string[], value: string | undefined): void {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed && !values.includes(trimmed)) {
|
||||
values.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function tryResolve(value: string, env: NodeJS.ProcessEnv): string | undefined {
|
||||
try {
|
||||
return resolveKtxConfigReference(value, env);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function addUrlCredentials(values: string[], value: string): void {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
addSecret(values, parsed.password ? decodeURIComponent(parsed.password) : undefined);
|
||||
addSecret(values, parsed.username ? decodeURIComponent(parsed.username) : undefined);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromRecord(input: unknown, env: NodeJS.ProcessEnv, values: string[]): void {
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
collectFromRecord(item, env, values);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, raw] of Object.entries(input)) {
|
||||
if (isRecord(raw) || Array.isArray(raw)) {
|
||||
collectFromRecord(raw, env, values);
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string' || !SENSITIVE_KEY.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const resolved = tryResolve(raw, env);
|
||||
addSecret(values, resolved);
|
||||
if (resolved) {
|
||||
addUrlCredentials(values, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectLlmSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
|
||||
collectFromRecord(project.config.llm.provider, env, values);
|
||||
}
|
||||
|
||||
function collectEmbeddingSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
|
||||
collectFromRecord(project.config.ingest.embeddings, env, values);
|
||||
collectFromRecord(project.config.scan.enrichment.embeddings, env, values);
|
||||
}
|
||||
|
||||
function collectConnectionSecrets(
|
||||
project: TelemetryRedactionProject,
|
||||
connectionId: string | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
values: string[],
|
||||
): void {
|
||||
if (!connectionId) {
|
||||
return;
|
||||
}
|
||||
collectFromRecord(project.config.connections[connectionId], env, values);
|
||||
}
|
||||
|
||||
export async function collectTelemetryRedactionSecrets(input: {
|
||||
project?: TelemetryRedactionProject;
|
||||
projectDir?: string;
|
||||
connectionId?: string;
|
||||
includeLlm?: boolean;
|
||||
includeEmbeddings?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<string[]> {
|
||||
const env = input.env ?? process.env;
|
||||
let project = input.project;
|
||||
if (!project && input.projectDir) {
|
||||
try {
|
||||
project = await loadKtxProject({ projectDir: input.projectDir });
|
||||
} catch {
|
||||
project = undefined;
|
||||
}
|
||||
}
|
||||
if (!project) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
if (input.includeLlm) {
|
||||
collectLlmSecrets(project, env, values);
|
||||
}
|
||||
if (input.includeEmbeddings) {
|
||||
collectEmbeddingSecrets(project, env, values);
|
||||
}
|
||||
collectConnectionSecrets(project, input.connectionId, env, values);
|
||||
return values;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue