Skip to content
Open
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
23 changes: 14 additions & 9 deletions packages/opencode/src/altimate/plugin/altimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import open from "open"
import { AltimateApi } from "../api/client"
// altimate_change — onboarding telemetry for the gateway sign-in funnel
import * as OnboardingTelemetry from "../telemetry/onboarding"
import { Telemetry } from "../telemetry"
// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped)
import { getOrCreateMachineId } from "../util/machine-id"
import { Config } from "@/config/config"
Expand Down Expand Up @@ -89,17 +90,21 @@ export async function buildCliContext(machineIdPath?: string): Promise<string> {
} catch (err) {
// Config was unreadable — NOT the normal path. Server routes run inside
// Instance.provide() (AsyncLocalStorage-propagated across awaits), so
// Config.get() resolves during an ordinary browser authorize(). The known
// exception is `altimate auth login <url>`, which deliberately skips
// instance bootstrap (ProvidersLoginCommand `instance: (args) => !isAuthProviderUrl(args.target)`);
// on that path this fires. Fail CLOSED — omit the durable machine_id rather
// than transmit it for a user who may have opted out via config; a missed
// correlation beats leaking a stable identifier. Log so a low correlation
// rate is traceable here instead of being mistaken for a lost reply.
log.warn("cli_context: config unreadable, omitting machine_id (fail-closed)", {
// Config.get() resolves during an ordinary browser authorize(). It fires on
// the paths that skip instance bootstrap: `altimate auth login <url>` and the
// create-and-link hand-off of `altimate-code link`. Follow the telemetry
// module's own decision for this process: it evaluates the same gates in
// doInit() and, with the config unreadable, keeps sending the machine id
// itself — so this context carries the id exactly when telemetry does, and
// omits it whenever telemetry is off (opted out, no sink, test runner).
// Failing closed here instead hid the id from every create-and-link
// hand-off, which made the link step unattributable on the funnel.
await Telemetry.init().catch(() => {})
disabled = !Telemetry.isEnabled()
log.info("cli_context: config unreadable, following telemetry state", {
code: (err as NodeJS.ErrnoException)?.code,
enabled: !disabled,
})
disabled = true
}
}
let machineId = ""
Expand Down
66 changes: 65 additions & 1 deletion packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,65 @@ export namespace Telemetry {
recovered: boolean
duration_ms: number
}
// altimate_change start — workspace link lifecycle (shared event contract: one name per fact,
// the backend's workspace id on every row, fired on the API success response, a `client`
// property stamped on every envelope). The extension emits the same names for its own moments.
| {
type: "workspace_linked"
timestamp: number
session_id: string
workspace_id: number
how: "bind_existing" | "create_and_bind" | "rebind_remote" | "rebind_path"
}
| {
type: "workspace_created"
timestamp: number
session_id: string
workspace_id: number
how: "create_and_bind" | "create_unbound"
}
| {
type: "workspace_link_failed"
timestamp: number
session_id: string
how: "bind_existing" | "create_and_bind" | "create_unbound" | "rebind_remote" | "rebind_path"
reason: "auth" | "not_found" | "network" | "conflict" | "other"
workspace_id?: number
}
| {
type: "workspace_unlinked"
timestamp: number
session_id: string
workspace_id?: number
}
| {
type: "workspace_switched"
timestamp: number
session_id: string
to_workspace_id: number
from_workspace_id?: number
how: "rebind_remote" | "rebind_path"
}
| {
type: "workspace_auth_expired"
timestamp: number
session_id: string
/** Method and path with ids replaced by ":id", e.g. "POST /datamate-project-bindings/bind". */
endpoint: string
status: number
workspace_id?: number
}
| {
type: "workspace_api_error"
timestamp: number
session_id: string
endpoint: string
/** `response`: the server answered 2xx with an empty, unparseable or oversized body. */
status_class: "4xx" | "5xx" | "network" | "timeout" | "response"
status?: number
workspace_id?: number
}
// altimate_change end
| {
type: "mcp_server_census"
timestamp: number
Expand Down Expand Up @@ -1935,6 +1994,9 @@ export namespace Telemetry {

const properties: Record<string, string> = {
cli_version: InstallationVersion,
// altimate_change — the emitting client, per the shared event contract; `source` below
// still says how this CLI was launched (cli, tui, serve, datamates).
client: "altimate-code",
source: clientSource,
project_id: fields.project_id ?? projectId,
...(machineId && { machine_id: machineId }),
Expand All @@ -1949,7 +2011,9 @@ export namespace Telemetry {

for (const [k, v] of Object.entries(fields)) {
if (k === "session_id" || k === "project_id" || k === "_retried" || k === "_operation_id") continue
if (typeof v === "number") {
// altimate_change — an id is a dimension, not a quantity: numeric *_id fields (workspace ids)
// go to properties as strings so the dashboards join on customDimensions like every other key.
if (typeof v === "number" && !k.endsWith("_id")) {
measurements[k] = v
} else if (v !== undefined && v !== null) {
properties[k] = typeof v === "object" ? JSON.stringify(v) : String(v)
Expand Down
Loading
Loading