Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ We collect the following categories of events:
| `session_start` | A new CLI session begins |
| `session_end` | A CLI session ends (includes duration) |
| `session_forked` | A session is forked from an existing one |
| `generation` | An AI model generation completes (model ID, token counts, duration, but no prompt content) |
| `tool_call` | A tool is invoked (tool name and category, but no arguments or output) |
| `bridge_call` | A native tool call completes (method name and duration, but no arguments) |
| `generation` | An AI model generation completes (model ID, token counts, duration no prompt content) |
| `tool_call` | A tool is invoked (tool name and category no arguments or output) |
| `native_call` | A native engine call completes (method name and duration no arguments) |
| `command` | A CLI command is executed (command name only) |
| `error` | An unhandled error occurs (error type and truncated message, but no stack traces) |
| `auth_login` | Authentication succeeds or fails (provider and method, but no credentials) |
Expand All @@ -33,8 +33,11 @@ We collect the following categories of events:
| `error_recovered` | Successful recovery from a transient error (error type, strategy, attempt count) |
| `mcp_server_census` | MCP server capabilities after connect (tool and resource counts, but no tool names) |
| `context_overflow_recovered` | Context overflow is handled (strategy) |
| `skill_used` | A skill is loaded (skill name and source — `builtin`, `global`, or `project` — no skill content) |
| `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) |
| `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) |

Each event includes a timestamp, anonymous session ID, and the CLI version.
Each event includes a timestamp, anonymous session ID, CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information).

## Delivery & Reliability

Expand Down Expand Up @@ -113,9 +116,9 @@ Event type names use **snake_case** with a `domain_action` pattern:

### Adding a New Event

1. **Define the type.** Add a new variant to the `Telemetry.Event` union in `packages/altimate-code/src/telemetry/index.ts`
2. **Emit the event.** Call `Telemetry.track()` at the appropriate location
3. **Update docs.** Add a row to the event table above
1. **Define the type** Add a new variant to the `Telemetry.Event` union in `packages/opencode/src/altimate/telemetry/index.ts`
2. **Emit the event** — Call `Telemetry.track()` at the appropriate location
3. **Update docs** — Add a row to the event table above

### Privacy Checklist

Expand Down
1 change: 0 additions & 1 deletion packages/drivers/src/sqlserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { ConnectionConfig, Connector, ConnectorResult, SchemaColumn } from
export async function connect(config: ConnectionConfig): Promise<Connector> {
let mssql: any
try {
// @ts-expect-error — optional dependency, loaded at runtime
mssql = await import("mssql")
mssql = mssql.default || mssql
} catch {
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/altimate/native/connections/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ register("sql.execute", async (params: SqlExecuteParams): Promise<SqlExecuteResu
} catch {}
return result
} catch (e) {
const errorMsg = String(e)
const maskedErrorMsg = Telemetry.maskString(errorMsg).slice(0, 500)
try {
Telemetry.track({
type: "warehouse_query",
Expand All @@ -239,11 +241,21 @@ register("sql.execute", async (params: SqlExecuteParams): Promise<SqlExecuteResu
duration_ms: Date.now() - startTime,
row_count: 0,
truncated: false,
error: String(e).slice(0, 500),
error: maskedErrorMsg,
error_category: categorizeQueryError(e),
})
Telemetry.track({
type: "sql_execute_failure",
timestamp: Date.now(),
session_id: Telemetry.getContext().sessionId,
warehouse_type: warehouseType,
query_type: detectQueryType(params.sql),
error_message: maskedErrorMsg,
masked_sql: Telemetry.maskString(params.sql).slice(0, 2000),
duration_ms: Date.now() - startTime,
})
} catch {}
return { columns: [], rows: [], row_count: 0, truncated: false, error: String(e) } as SqlExecuteResult & { error: string }
return { columns: [], rows: [], row_count: 0, truncated: false, error: errorMsg } as SqlExecuteResult & { error: string }
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export async function get(name: string): Promise<Connector> {
auth_method: detectAuthMethod(config),
success: false,
duration_ms: Date.now() - startTime,
error: String(e).slice(0, 500),
error: Telemetry.maskString(String(e)).slice(0, 500),
error_category: categorizeConnectionError(e),
})
} catch {}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/native/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function call<M extends BridgeMethod>(
method: method as string,
status: "error",
duration_ms: Date.now() - startTime,
error: String(e).slice(0, 500),
error: Telemetry.maskString(String(e)).slice(0, 500),
})
} catch {
// Telemetry must never prevent error propagation
Expand Down
183 changes: 181 additions & 2 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -63,6 +66,7 @@ export namespace Telemetry {
duration_ms: number
sequence_index: number
previous_tool: string | null
input_signature?: string
error?: string
}
| {
Expand Down Expand Up @@ -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.


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}_`),
)
}

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"])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -591,6 +769,7 @@ export namespace Telemetry {
droppedEvents = 0
sessionId = ""
projectId = ""
machineId = ""
initPromise = undefined
initDone = false
}
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/src/tool/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ import { iife } from "@/util/iife"
import { Fingerprint } from "../altimate/fingerprint"
import { Config } from "../config/config"
import { selectSkillsWithLLM } from "../altimate/skill-selector"
import { Telemetry } from "../altimate/telemetry"
import os from "os"

const MAX_DISPLAY_SKILLS = 50

// altimate_change start — classifySkillSource helper for skill telemetry
function classifySkillSource(location: string): "builtin" | "global" | "project" {
if (location.includes("node_modules") || location.includes(".altimate/builtin")) return "builtin"
if (location.startsWith(os.homedir())) return "global"
return "project"
}
// altimate_change end
// altimate_change end

export const SkillTool = Tool.define("skill", async (ctx) => {
Expand Down Expand Up @@ -83,6 +93,9 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
// altimate_change start — telemetry: startTime for skill_used duration
const startTime = Date.now()
// altimate_change end
// altimate_change start - use upstream Skill.get() for exact name lookup
const skill = await Skill.get(params.name)

Expand Down Expand Up @@ -122,6 +135,22 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))

// altimate_change start — telemetry instrumentation for skill loading
try {
Telemetry.track({
type: "skill_used",
timestamp: Date.now(),
session_id: ctx.sessionID,
message_id: ctx.messageID,
skill_name: skill.name,
skill_source: classifySkillSource(skill.location),
duration_ms: Date.now() - startTime,
})
} catch {
// Telemetry must never break skill loading
}
// altimate_change end

return {
title: `Loaded skill: ${skill.name}`,
output: [
Expand Down
Loading
Loading