Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
7 changes: 5 additions & 2 deletions docs/docs/configure/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ We collect the following categories of events:
| `session_forked` | A session is forked from an existing one |
| `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) |
| `bridge_call` | A Python engine RPC call completes (method name and duration — no arguments) |
| `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 — no stack traces) |
| `auth_login` | Authentication succeeds or fails (provider and method — no credentials) |
Expand All @@ -33,6 +33,9 @@ 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 — 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 library error occurs — function name, error category, and error message and arguments with PII and sensitive fields masked |

Each event includes a timestamp, anonymous session ID, and the CLI version.

Expand Down Expand Up @@ -113,7 +116,7 @@ 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`
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

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),
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
175 changes: 174 additions & 1 deletion 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,160 @@ 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", "token", "access_token", "refresh_token",
"secret", "secret_key", "password", "passwd", "pwd",
"credential", "credentials", "authorization", "auth",
"signature", "sig", "private_key", "connection_string",
]

function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase()
return SENSITIVE_KEYS.some(
(k) => lower === k || lower.endsWith(`_${k}`) || lower.startsWith(`${k}_`),
)
}

// Mirrors altimate-sdk mask_string: replace '...' → ?, collapse whitespace.
export function maskString(s: string): string {
return s.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 +531,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,6 +561,7 @@ 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> = {}

Expand Down Expand Up @@ -490,6 +650,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 +763,7 @@ export namespace Telemetry {
droppedEvents = 0
sessionId = ""
projectId = ""
machineId = ""
initPromise = undefined
initDone = false
}
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/tool/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ 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

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

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

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

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
}

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