Skip to content

Commit f33c5e7

Browse files
saravmajesticclaude
andcommitted
feat(observability): carry the client's trace through a turn
A `traceparent` sent with a prompt is bound to the session's turn and forwarded to the Altimate gateways (`traceparent`, `x-request-id`), stamped on log lines (`trace=`) and on App Insights events (`ai.operation.id`). Subagents inherit it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent fcca705 commit f33c5e7

7 files changed

Lines changed: 144 additions & 1 deletion

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* W3C trace context for a session's current turn.
3+
*
4+
* A client (the VS Code extension) sends `traceparent` with a prompt. The trace is bound to the
5+
* session for that turn and:
6+
* - forwarded to the Altimate gateways (`traceparent` + `x-request-id`), whose Azure Monitor
7+
* instrumentation adopts it as the request's `operation_Id`;
8+
* - stamped on this process's log lines (`trace=<id>`) and App Insights events
9+
* (`ai.operation.id`).
10+
* One id then joins the client's, altimate-code's and the backend's records of a failure.
11+
*
12+
* The prompt is handled asynchronously (`prompt_async` returns before the turn runs), so the
13+
* binding is per session rather than per async context.
14+
*/
15+
export namespace TraceContext {
16+
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/
17+
/** Bound on remembered sessions; the oldest binding is dropped first. */
18+
const MAX_SESSIONS = 500
19+
/** Providers served by Altimate's backend — the only ones that record the trace. */
20+
const ALTIMATE_PROVIDERS = new Set(["altimate-backend", "altimate-free"])
21+
22+
const bySession = new Map<string, { traceparent: string; traceId: string }>()
23+
24+
/**
25+
* Binds the trace a client sent with a prompt to the session's turn. A prompt without a valid
26+
* `traceparent` clears the binding, so a later untraced turn is not filed under an old trace.
27+
*/
28+
export function bind(sessionID: string, traceparent: string | undefined) {
29+
bySession.delete(sessionID)
30+
const match = traceparent ? TRACEPARENT.exec(traceparent.trim().toLowerCase()) : null
31+
if (!match) return
32+
bySession.set(sessionID, { traceparent: match[0], traceId: match[1] })
33+
if (bySession.size > MAX_SESSIONS) bySession.delete(bySession.keys().next().value!)
34+
}
35+
36+
/** A subagent session runs as part of its parent's turn. */
37+
export function inherit(childSessionID: string, parentSessionID: string) {
38+
const parent = bySession.get(parentSessionID)
39+
if (parent) bySession.set(childSessionID, parent)
40+
}
41+
42+
export function traceId(sessionID: string): string | undefined {
43+
return bySession.get(sessionID)?.traceId
44+
}
45+
46+
/** Outgoing request headers for the session's trace; empty for non-Altimate providers. */
47+
export function headers(sessionID: string, providerID: string): Record<string, string> {
48+
const trace = bySession.get(sessionID)
49+
if (!trace || !ALTIMATE_PROVIDERS.has(providerID)) return {}
50+
return { traceparent: trace.traceparent, "x-request-id": trace.traceId }
51+
}
52+
}

‎packages/opencode/src/altimate/telemetry/index.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Config } from "@/config/config"
33
import { Flag } from "@/flag/flag"
44
import { InstallationVersion } from "@opencode-ai/core/installation/version"
55
import { Log } from "@/altimate/util/log"
6+
import { TraceContext } from "@/altimate/observability/trace-context"
67
// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped)
78
import { getOrCreateMachineId } from "@/altimate/util/machine-id"
89
import { createHash, randomUUID } from "crypto"
@@ -1938,7 +1939,7 @@ export namespace Telemetry {
19381939
const measurements: Record<string, number> = {}
19391940

19401941
for (const [k, v] of Object.entries(fields)) {
1941-
if (k === "session_id" || k === "project_id" || k === "_retried") continue
1942+
if (k === "session_id" || k === "project_id" || k === "_retried" || k === "_operation_id") continue
19421943
if (typeof v === "number") {
19431944
measurements[k] = v
19441945
} else if (v !== undefined && v !== null) {
@@ -1961,6 +1962,7 @@ export namespace Telemetry {
19611962
// altimate_change end
19621963
"ai.cloud.role": "altimate",
19631964
"ai.application.ver": InstallationVersion,
1965+
...(fields._operation_id && { "ai.operation.id": fields._operation_id }),
19641966
},
19651967
data: {
19661968
baseType: "EventData",
@@ -2228,6 +2230,13 @@ export namespace Telemetry {
22282230
// Before init completes: buffer (flushed once init enables, or cleared if disabled).
22292231
// After init completed and disabled telemetry: drop silently.
22302232
if (initDone && !enabled) return
2233+
// altimate_change start — stamp the client's trace for this session's turn now: the event is
2234+
// serialised at flush time, when the session may already be on its next turn. Becomes the
2235+
// envelope's `ai.operation.id`, joining it to the extension's and backend's records.
2236+
const traceSession = (event as any).session_id ?? sessionId
2237+
const operationId = traceSession ? TraceContext.traceId(traceSession) : undefined
2238+
if (operationId) (event as any)._operation_id = operationId
2239+
// altimate_change end
22312240
buffer.push(event)
22322241
if (buffer.length > MAX_BUFFER_SIZE) {
22332242
buffer.shift()

‎packages/opencode/src/server/routes/session.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { Todo } from "../../session/todo"
1717
import { Agent } from "../../agent/agent"
1818
import { Snapshot } from "@/snapshot"
1919
import { Log } from "../../util/log"
20+
// altimate_change start — client trace propagation
21+
import { TraceContext } from "@/altimate/observability/trace-context"
22+
// altimate_change end
2023
// altimate_change start — upstream_fix: bridge merge wired routes to Effect-TS Permission, but the runtime
2124
// asks come from PermissionNext. Use PermissionNext here so this deprecated reply route hits the same
2225
// pending map. See packages/opencode/src/server/routes/permission.ts for the full explanation.
@@ -922,6 +925,9 @@ export const SessionRoutes = lazy(() =>
922925
return stream(c, async (stream) => {
923926
const sessionID = c.req.valid("param").sessionID
924927
const body = c.req.valid("json")
928+
// altimate_change start — bind the client's trace to this turn (see TraceContext)
929+
TraceContext.bind(sessionID, c.req.header("traceparent"))
930+
// altimate_change end
925931
const msg = await SessionPrompt.prompt({ ...body, sessionID })
926932
stream.write(JSON.stringify(msg))
927933
})
@@ -954,6 +960,9 @@ export const SessionRoutes = lazy(() =>
954960
return stream(c, async () => {
955961
const sessionID = c.req.valid("param").sessionID
956962
const body = c.req.valid("json")
963+
// altimate_change start — bind the client's trace to this turn (see TraceContext)
964+
TraceContext.bind(sessionID, c.req.header("traceparent"))
965+
// altimate_change end
957966
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
958967
log.error("prompt_async failed", { sessionID, error: err })
959968
Bus.publish(Session.Event.Error, {
@@ -997,6 +1006,9 @@ export const SessionRoutes = lazy(() =>
9971006
async (c) => {
9981007
const sessionID = c.req.valid("param").sessionID
9991008
const body = c.req.valid("json")
1009+
// altimate_change start — bind the client's trace to this turn (see TraceContext)
1010+
TraceContext.bind(sessionID, c.req.header("traceparent"))
1011+
// altimate_change end
10001012
const msg = await SessionPrompt.command({ ...body, sessionID })
10011013
return c.json(msg)
10021014
},

‎packages/opencode/src/session/llm.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Context, Effect, Layer, Stream } from "effect"
3939
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
4040
import type { LLMEvent } from "@opencode-ai/llm"
4141
import { LLMAISDK } from "./llm/ai-sdk"
42+
import { TraceContext } from "@/altimate/observability/trace-context"
4243
// altimate_change end
4344

4445
export namespace LLM {
@@ -84,6 +85,10 @@ export namespace LLM {
8485
.tag("small", (input.small ?? false).toString())
8586
.tag("agent", input.agent.name)
8687
.tag("mode", input.agent.mode)
88+
// altimate_change start — stamp the turn's client trace on this request's log lines
89+
const traceId = TraceContext.traceId(input.sessionID)
90+
if (traceId) l.tag("trace", traceId)
91+
// altimate_change end
8792
l.info("stream", {
8893
modelID: input.model.id,
8994
providerID: input.model.providerID,
@@ -203,6 +208,9 @@ export namespace LLM {
203208
"User-Agent": `altimate-code/${Installation.VERSION}`,
204209
}
205210
: undefined,
211+
// altimate_change start — forward the turn's trace to the Altimate gateways
212+
TraceContext.headers(input.sessionID, input.model.providerID),
213+
// altimate_change end
206214
input.model.headers,
207215
headers,
208216
)

‎packages/opencode/src/session/processor.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { MessageV2 } from "./message-v2"
22
import { Log } from "@/util/log"
3+
// altimate_change start — client trace propagation
4+
import { TraceContext } from "@/altimate/observability/trace-context"
5+
// altimate_change end
36
import { Session } from "."
47
import { Agent } from "@/agent/agent"
58
import { Snapshot } from "@/snapshot"
@@ -1163,6 +1166,9 @@ export namespace SessionProcessor {
11631166
log.error("process", {
11641167
error: e,
11651168
stack: JSON.stringify(e.stack),
1169+
// altimate_change start — join this failure to the client's trace
1170+
...(TraceContext.traceId(input.sessionID) && { trace: TraceContext.traceId(input.sessionID) }),
1171+
// altimate_change end
11661172
})
11671173
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
11681174
if (MessageV2.ContextOverflowError.isInstance(error)) {

‎packages/opencode/src/tool/task.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { Config } from "../config/config"
1717
import { PermissionNext } from "@/permission/next"
1818
// altimate_change start — log unhandled cancel rejections
1919
import { Log } from "@/util/log"
20+
// altimate_change start — client trace propagation
21+
import { TraceContext } from "@/altimate/observability/trace-context"
22+
// altimate_change end
2023
// re-brand core (ModelV2/ProviderV2) IDs to the provider/schema brands SessionPrompt expects
2124
import { ModelID, ProviderID } from "@/provider/schema"
2225
import { Effect } from "effect"
@@ -182,6 +185,9 @@ export const TaskTool = Tool.define("task", async (ctx) => {
182185
})
183186

184187
const messageID = MessageID.ascending()
188+
// altimate_change start — the subagent's LLM calls belong to the parent turn's trace
189+
TraceContext.inherit(session.id, ctx.sessionID)
190+
// altimate_change end
185191
const promptOps = ctx.extra?.promptOps as TaskPromptOps | undefined
186192

187193
function cancel() {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* TraceContext binds the trace a client sends with a prompt to the session's turn, so the turn's
3+
* gateway calls, log lines and telemetry share one id with the client and the backend.
4+
*/
5+
import { describe, expect, test } from "bun:test"
6+
import { TraceContext } from "../../src/altimate/observability/trace-context"
7+
import { LLM } from "../../src/session/llm"
8+
9+
const TRACE_ID = "0af7651916cd43dd8448eb211c80319c"
10+
const TRACEPARENT = `00-${TRACE_ID}-b7ad6b7169203331-01`
11+
12+
describe("TraceContext", () => {
13+
test("binds a valid traceparent to the session", () => {
14+
TraceContext.bind("ses_bind", TRACEPARENT)
15+
expect(TraceContext.traceId("ses_bind")).toBe(TRACE_ID)
16+
})
17+
18+
test("ignores a malformed traceparent and clears the previous turn's trace", () => {
19+
TraceContext.bind("ses_clear", TRACEPARENT)
20+
TraceContext.bind("ses_clear", "not-a-traceparent")
21+
expect(TraceContext.traceId("ses_clear")).toBeUndefined()
22+
TraceContext.bind("ses_clear", TRACEPARENT)
23+
TraceContext.bind("ses_clear", undefined)
24+
expect(TraceContext.traceId("ses_clear")).toBeUndefined()
25+
})
26+
27+
test("sends trace headers only to Altimate providers", () => {
28+
TraceContext.bind("ses_headers", TRACEPARENT)
29+
const expected = { traceparent: TRACEPARENT, "x-request-id": TRACE_ID }
30+
expect(TraceContext.headers("ses_headers", "altimate-backend")).toEqual(expected)
31+
expect(TraceContext.headers("ses_headers", "altimate-free")).toEqual(expected)
32+
expect(TraceContext.headers("ses_headers", "anthropic")).toEqual({})
33+
expect(TraceContext.headers("ses_untraced", "altimate-backend")).toEqual({})
34+
})
35+
36+
test("a subagent session inherits its parent's trace", () => {
37+
TraceContext.bind("ses_parent", TRACEPARENT)
38+
TraceContext.inherit("ses_child", "ses_parent")
39+
expect(TraceContext.traceId("ses_child")).toBe(TRACE_ID)
40+
})
41+
42+
test("the Altimate Base session header does not displace the trace headers", () => {
43+
TraceContext.bind("ses_base", TRACEPARENT)
44+
const headers = LLM.withManagedSessionHeaders("altimate-free", "ses_base", {
45+
...TraceContext.headers("ses_base", "altimate-free"),
46+
})
47+
expect(headers.traceparent).toBe(TRACEPARENT)
48+
expect(headers["X-Session-Id"]).toBe("ses_base")
49+
})
50+
})

0 commit comments

Comments
 (0)