Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 134
feat: add core_failure telemetry with PII-safe input signatures#245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
afe04ad2e73f2eef1d1a7625f3f4eb55714db1ad96025515794ac4ad2625f42c95f7b3ead7a200a9c433474529739462c0File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2,7 +2,10 @@ import { Account } from "@/account" | ||
| import { Config } from "@/config/config" | ||
| import { Installation } from "@/installation" | ||
| import { Log } from "@/util/log" | ||
| import { createHash } from "crypto" | ||
| import { createHash, randomUUID } from "crypto" | ||
| import fs from "fs" | ||
| import path from "path" | ||
| import os from "os" | ||
| const log = Log.create({ service: "telemetry" }) | ||
| @@ -63,6 +66,7 @@ export namespace Telemetry { | ||
| duration_ms: number | ||
| sequence_index: number | ||
| previous_tool: string | null | ||
| input_signature?: string | ||
| error?: string | ||
| } | ||
| | { | ||
| @@ -331,6 +335,166 @@ export namespace Telemetry { | ||
| has_ssh_tunnel: boolean | ||
| has_keychain: boolean | ||
| } | ||
| | { | ||
| type: "skill_used" | ||
| timestamp: number | ||
| session_id: string | ||
| message_id: string | ||
| skill_name: string | ||
| skill_source: "builtin" | "global" | "project" | ||
| duration_ms: number | ||
| } | ||
| | { | ||
| type: "sql_execute_failure" | ||
| timestamp: number | ||
| session_id: string | ||
| warehouse_type: string | ||
| query_type: string | ||
| error_message: string | ||
| masked_sql: string | ||
| duration_ms: number | ||
| } | ||
| | { | ||
| type: "core_failure" | ||
| timestamp: number | ||
| session_id: string | ||
| tool_name: string | ||
| tool_category: string | ||
| error_class: | ||
| | "parse_error" | ||
| | "connection" | ||
| | "timeout" | ||
| | "validation" | ||
| | "internal" | ||
| | "permission" | ||
| | "unknown" | ||
| error_message: string | ||
| input_signature: string | ||
| masked_args?: string | ||
| duration_ms: number | ||
| } | ||
| const ERROR_PATTERNS: Array<{ | ||
| class: Telemetry.Event & { type: "core_failure" } extends { error_class: infer C } ? C : never | ||
| keywords: string[] | ||
| }> = [ | ||
| { class: "parse_error", keywords: ["parse", "syntax", "binder", "unexpected token", "sqlglot"] }, | ||
| { | ||
| class: "connection", | ||
| keywords: ["econnrefused", "connection", "socket", "enotfound", "econnreset"], | ||
| }, | ||
| { class: "timeout", keywords: ["timeout", "etimedout", "bridge timeout", "timed out"] }, | ||
| { class: "permission", keywords: ["permission", "denied", "unauthorized", "forbidden"] }, | ||
| { class: "validation", keywords: ["invalid params", "invalid", "missing", "required"] }, | ||
| { class: "internal", keywords: ["internal", "assertion"] }, | ||
| ] | ||
Comment on lines
+380
to
+390
This comment was marked as outdated.Sorry, something went wrong. Uh oh!There was an error while loading. Please reload this page. | ||
| export function classifyError( | ||
| message: string, | ||
| ): Telemetry.Event & { type: "core_failure" } extends { error_class: infer C } ? C : never { | ||
| const lower = message.toLowerCase() | ||
| for (const { class: cls, keywords } of ERROR_PATTERNS) { | ||
| if (keywords.some((kw) => lower.includes(kw))) return cls | ||
| } | ||
| return "unknown" | ||
| } | ||
| export function computeInputSignature(args: Record<string, unknown>): string { | ||
| const sig: Record<string, string> = {} | ||
| for (const [k, v] of Object.entries(args)) { | ||
| if (v === null || v === undefined) { | ||
| sig[k] = "null" | ||
| } else if (typeof v === "string") { | ||
| sig[k] = `string:${v.length}` | ||
| } else if (typeof v === "number") { | ||
| sig[k] = "number" | ||
| } else if (typeof v === "boolean") { | ||
| sig[k] = "boolean" | ||
| } else if (Array.isArray(v)) { | ||
| sig[k] = `array:${v.length}` | ||
| } else if (typeof v === "object") { | ||
| sig[k] = `object:${Object.keys(v).length}` | ||
| } else { | ||
| sig[k] = typeof v | ||
| } | ||
| } | ||
| const result = JSON.stringify(sig) | ||
| if (result.length <= 1000) return result | ||
| // Drop keys from the end until the JSON fits, preserving valid JSON structure | ||
| const keys = Object.keys(sig) | ||
| while (keys.length > 0) { | ||
| keys.pop() | ||
| const truncated: Record<string, string> = {} | ||
| for (const k of keys) truncated[k] = sig[k] | ||
| truncated["..."] = `${Object.keys(sig).length - keys.length} more` | ||
| const out = JSON.stringify(truncated) | ||
| if (out.length <= 1000) return out | ||
| } | ||
| return JSON.stringify({ "...": `${Object.keys(sig).length} keys` }) | ||
| } | ||
| // Mirrors altimate-sdk (Rust) SENSITIVE_KEYS — keep in sync. | ||
| const SENSITIVE_KEYS: string[] = [ | ||
| "key", "api_key", "apikey", "apiKey", "token", "access_token", "refresh_token", | ||
| "secret", "secret_key", "password", "passwd", "pwd", | ||
| "credential", "credentials", "authorization", "auth", | ||
| "signature", "sig", "private_key", "connection_string", | ||
| // camelCase variants not caught by prefix/suffix matching | ||
| "authtoken", "accesstoken", "refreshtoken", "bearertoken", "jwttoken", | ||
| "jwtsecret", "clientsecret", "appsecret", | ||
| ] | ||
| function isSensitiveKey(key: string): boolean { | ||
| const lower = key.toLowerCase() | ||
| return SENSITIVE_KEYS.some( | ||
| (k) => lower === k || lower.endsWith(`_${k}`) || lower.startsWith(`${k}_`), | ||
| ) | ||
| } | ||
suryaiyer95 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. suryaiyer95 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| export function maskString(s: string): string { | ||
| return s | ||
| .replace(/'(?:[^'\\]|\\.)*'/g, "?") | ||
| .replace(/"(?:[^"\\]|\\.)*"/g, "?") | ||
| .replace(/\s+/g, " ") | ||
| .trim() | ||
| } | ||
| function maskValue(value: unknown, key?: string): unknown { | ||
| if (key && isSensitiveKey(key)) return "****" | ||
| if (typeof value === "string") return maskString(value) | ||
| if (Array.isArray(value)) return value.map((v) => maskValue(v, key)) | ||
| if (value !== null && typeof value === "object") { | ||
| const masked: Record<string, unknown> = {} | ||
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | ||
| masked[k] = maskValue(v, k) | ||
| } | ||
| return masked | ||
| } | ||
| return value | ||
| } | ||
| /** PII-mask tool arguments for failure telemetry. | ||
| * Mirrors altimate-sdk mask_value: sensitive keys → "****", | ||
| * string literals in SQL → ?, whitespace collapsed. Truncates to 2000 chars. */ | ||
| export function maskArgs(args: Record<string, unknown>): string { | ||
| const masked: Record<string, unknown> = {} | ||
| for (const [k, v] of Object.entries(args)) { | ||
| masked[k] = maskValue(v, k) | ||
| } | ||
| const result = JSON.stringify(masked) | ||
| if (result.length <= 2000) return result | ||
| // Drop keys from the end until valid JSON fits, same approach as computeInputSignature | ||
| const keys = Object.keys(masked) | ||
| while (keys.length > 0) { | ||
| keys.pop() | ||
| const truncated: Record<string, unknown> = {} | ||
| for (const k of keys) truncated[k] = masked[k] | ||
| truncated["..."] = `${Object.keys(masked).length - keys.length} more` | ||
| const out = JSON.stringify(truncated) | ||
| if (out.length <= 2000) return out | ||
| } | ||
| return JSON.stringify({ "...": `${Object.keys(masked).length} keys` }) | ||
| } | ||
| const FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "bash"]) | ||
| @@ -373,6 +537,7 @@ export namespace Telemetry { | ||
| let buffer: Event[] = [] | ||
| let flushTimer: ReturnType<typeof setInterval> | undefined | ||
| let userEmail = "" | ||
| let machineId = "" | ||
| let sessionId = "" | ||
| let projectId = "" | ||
| let appInsights: AppInsightsConfig | undefined | ||
| @@ -402,12 +567,13 @@ export namespace Telemetry { | ||
| const properties: Record<string, string> = { | ||
| cli_version: Installation.VERSION, | ||
| project_id: fields.project_id ?? projectId, | ||
| ...(machineId && { machine_id: machineId }), | ||
| } | ||
| const measurements: Record<string, number> = {} | ||
| // Flatten all fields — nested `tokens` object gets prefixed keys | ||
| for (const [k, v] of Object.entries(fields)) { | ||
| if (k === "session_id" || k === "project_id") continue | ||
| if (k === "session_id" || k === "project_id" || k === "_retried") continue | ||
| if (k === "tokens" && typeof v === "object" && v !== null) { | ||
| for (const [tk, tv] of Object.entries(v as Record<string, unknown>)) { | ||
| if (typeof tv === "number") measurements[`tokens_${tk}`] = tv | ||
| @@ -490,6 +656,18 @@ export namespace Telemetry { | ||
| } catch { | ||
| // Account unavailable — proceed without user ID | ||
| } | ||
| try { | ||
| const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id") | ||
| try { | ||
| machineId = fs.readFileSync(machineIdPath, "utf8").trim() | ||
| } catch { | ||
| machineId = randomUUID() | ||
| fs.mkdirSync(path.dirname(machineIdPath), { recursive: true }) | ||
| fs.writeFileSync(machineIdPath, machineId, "utf8") | ||
| } | ||
| } catch { | ||
| // Machine ID unavailable — proceed without it | ||
| } | ||
| enabled = true | ||
| log.info("telemetry initialized", { mode: "appinsights" }) | ||
| const timer = setInterval(flush, FLUSH_INTERVAL_MS) | ||
| @@ -591,6 +769,7 @@ export namespace Telemetry { | ||
| droppedEvents = 0 | ||
| sessionId = "" | ||
| projectId = "" | ||
| machineId = "" | ||
| initPromise = undefined | ||
| initDone = false | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.