Skip to content

Commit 8a54726

Browse files
saravmajesticclaude
andcommitted
feat(observability): serve session traces to the IDE extension
The TUI's `/traces` is a TUI-only command, so the VS Code chat (which runs `altimate serve` headless) had no way to list or open a trace. - `GET /altimate/trace?offset&limit`: a page of session traces, newest first, with each trace's title, status, duration, tokens, cost and tool calls - `GET /altimate/trace/:sessionID/view`: one trace's self-contained viewer page - Both honor `tracing.dir`, validate the session ID before it names a file, and refuse browser origins on the same terms as the workspace routes - Extract `browserOriginRefusal` from `workspaceRouteRefusal` so both share it; workspace messages are unchanged - `renderTraceViewer({ embedded: true })` leaves out Copy Link, whose URL is not shareable inside an editor tab Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b337f8b commit 8a54726

3 files changed

Lines changed: 248 additions & 10 deletions

File tree

‎packages/opencode/src/altimate/observability/viewer.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414

1515
import { USER_MESSAGE_INPUT_MAX_CHARS, type TraceFile } from "./tracing"
1616

17-
export function renderTraceViewer(trace: TraceFile, options?: { live?: boolean; apiPath?: string }): string {
17+
export function renderTraceViewer(
18+
trace: TraceFile,
19+
// `embedded`: shown inside a host (the IDE extension's editor tab) whose page URL is not
20+
// shareable, so the Copy Link button is left out.
21+
options?: { live?: boolean; apiPath?: string; embedded?: boolean },
22+
): string {
1823
const traceJSON = JSON.stringify(trace).replace(/<\//g, "<\\/")
1924
const apiPath = options?.apiPath ?? "/api/trace"
2025
const live = options?.live ?? false
26+
const embedded = options?.embedded ?? false
2127

2228
return `<!DOCTYPE html>
2329
<html lang="en">
@@ -292,7 +298,7 @@ pre.io { background: var(--bg); border: 1px solid var(--border); border-radius:
292298
<div class="toolbar">
293299
<button class="toolbar-btn primary" id="btn-share" title="Download self-contained HTML trace (session recording)">Share Trace</button>
294300
<button class="toolbar-btn" id="btn-copy-summary" title="Copy markdown summary to clipboard">Copy Summary</button>
295-
<button class="toolbar-btn" id="btn-copy-link" title="Copy current URL to clipboard">Copy Link</button>
301+
${embedded ? "" : `<button class="toolbar-btn" id="btn-copy-link" title="Copy current URL to clipboard">Copy Link</button>`}
296302
<div class="toolbar-spacer"></div>
297303
<span class="toolbar-toast" id="toolbar-toast"></span>
298304
</div>
@@ -1548,7 +1554,8 @@ function showDetail(span) {
15481554
});
15491555
15501556
// Copy Link
1551-
document.getElementById('btn-copy-link').addEventListener('click', function() {
1557+
var copyLinkBtn = document.getElementById('btn-copy-link');
1558+
if (copyLinkBtn) copyLinkBtn.addEventListener('click', function() {
15521559
var url = window.location.href;
15531560
if (navigator.clipboard && navigator.clipboard.writeText) {
15541561
navigator.clipboard.writeText(url).then(function() {

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

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,32 +94,55 @@ export namespace Server {
9494
if (!CoreFlag.ALTIMATE_WORKSPACE) {
9595
return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } }
9696
}
97+
return browserOriginRefusal("Workspace actions", origin, host, password, fetchSite)
98+
}
99+
/** Why a browser-originated call to a local-only `/altimate/*` route must be refused, or undefined
100+
* when it may run. `subject` names the routes in the error ("Workspace actions", "Traces"). */
101+
export function browserOriginRefusal(
102+
subject: string,
103+
origin: string | undefined,
104+
host: string | undefined,
105+
password: string | undefined = Flag.OPENCODE_SERVER_PASSWORD,
106+
fetchSite?: string,
107+
): { status: 403; body: { ok: false; error: string } } | undefined {
97108
// A browser labels every request it sends, including Origin-less ones such as an `<img>` GET
98109
// from another site. Native clients send no such header, so only a browser's cross-site request
99110
// is refused here; the Origin rules below handle the rest.
100111
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
101-
log.warn("refused cross-site workspace action", { fetchSite })
102-
return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another site." } }
112+
log.warn("refused cross-site request", { subject, fetchSite })
113+
return { status: 403, body: { ok: false, error: `${subject} cannot be run from another site.` } }
103114
}
104115
if (!origin) return undefined
105116
if (!password) {
106-
log.warn("refused browser-originated workspace action on an unsecured server", { origin })
117+
log.warn("refused browser-originated request on an unsecured server", { subject, origin })
107118
return {
108119
status: 403,
109120
body: {
110121
ok: false,
111-
error: "Workspace actions cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.",
122+
error: `${subject} cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.`,
112123
},
113124
}
114125
}
115126
// With a password set, basicAuth has vetted the credentials — but a browser replays cached
116-
// Basic credentials on a cross-site form POST too, so only this server's own pages may call.
127+
// Basic credentials on a cross-site request too, so only this server's own pages may call.
117128
if (!sameOrigin(origin, host)) {
118-
log.warn("refused cross-origin workspace action", { origin })
119-
return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another origin." } }
129+
log.warn("refused cross-origin request", { subject, origin })
130+
return { status: 403, body: { ok: false, error: `${subject} cannot be run from another origin.` } }
120131
}
121132
return undefined
122133
}
134+
const TRACE_PAGE_SIZE = 50
135+
const TRACE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
136+
/** The traces directory, honoring `tracing.dir` like the CLI and TUI; a config that fails to load
137+
* falls back to the default rather than hiding every trace. */
138+
async function tracesDir(): Promise<string | undefined> {
139+
try {
140+
const { Config } = await import("../config/config")
141+
return (await Config.get()).tracing?.dir
142+
} catch {
143+
return undefined
144+
}
145+
}
123146
/** The skill registry this instance serves, reloaded so a skill written since it loaded is found
124147
* — also one in a skills directory that did not exist at boot. Same in-context path as
125148
* `refreshSkillRegistry` in session/prompt.ts: the facade's invalidate keeps the stale root list
@@ -1249,6 +1272,77 @@ export namespace Server {
12491272
}
12501273
})
12511274
// altimate_change end
1275+
// altimate_change start — GET /altimate/trace, GET /altimate/trace/:sessionID/view
1276+
// The TUI's `/traces` for the IDE extension, which runs this CLI headless: a page of the
1277+
// session traces and one trace's self-contained viewer page. Trace content carries prompts and
1278+
// tool output, so a browser origin is refused on the same terms as the workspace routes.
1279+
.get("/altimate/trace", async (c) => {
1280+
const refused = browserOriginRefusal(
1281+
"Traces",
1282+
c.req.header("origin"),
1283+
c.req.header("host"),
1284+
undefined,
1285+
c.req.header("sec-fetch-site"),
1286+
)
1287+
if (refused) return c.json(refused.body, refused.status)
1288+
try {
1289+
const { Trace } = await import("../altimate/observability/tracing")
1290+
const page = await Trace.listTracesPaginated(await tracesDir(), {
1291+
offset: Number(c.req.query("offset") ?? 0),
1292+
limit: Number(c.req.query("limit") ?? TRACE_PAGE_SIZE),
1293+
})
1294+
return c.json({
1295+
ok: true as const,
1296+
total: page.total,
1297+
offset: page.offset,
1298+
limit: page.limit,
1299+
traces: page.traces.map(({ sessionId, trace }) => ({
1300+
sessionID: sessionId,
1301+
title: trace.metadata.title || trace.metadata.prompt || sessionId,
1302+
startedAt: trace.startedAt,
1303+
status: trace.summary.status,
1304+
duration: trace.summary.duration,
1305+
totalTokens: trace.summary.totalTokens,
1306+
totalCost: trace.summary.totalCost,
1307+
totalToolCalls: trace.summary.totalToolCalls,
1308+
})),
1309+
})
1310+
} catch (err) {
1311+
const error = err instanceof Error ? err.message : String(err)
1312+
log.error("trace list: failed", { error })
1313+
return c.json({ ok: false, error }, 500)
1314+
}
1315+
})
1316+
.get("/altimate/trace/:sessionID/view", async (c) => {
1317+
const refused = browserOriginRefusal(
1318+
"Traces",
1319+
c.req.header("origin"),
1320+
c.req.header("host"),
1321+
undefined,
1322+
c.req.header("sec-fetch-site"),
1323+
)
1324+
if (refused) return c.json(refused.body, refused.status)
1325+
const sessionID = c.req.param("sessionID")
1326+
// Trace files are `<sessionID>.json` in the traces dir; anything outside this alphabet
1327+
// could name a path elsewhere.
1328+
if (!TRACE_SESSION_ID.test(sessionID)) {
1329+
return c.json({ ok: false, error: `Invalid sessionID: ${sessionID}` }, 400)
1330+
}
1331+
try {
1332+
const [{ Trace }, { renderTraceViewer }] = await Promise.all([
1333+
import("../altimate/observability/tracing"),
1334+
import("../altimate/observability/viewer"),
1335+
])
1336+
const trace = await Trace.loadTrace(sessionID, await tracesDir())
1337+
if (!trace) return c.json({ ok: false, error: `Trace not found: ${sessionID}` }, 404)
1338+
return c.html(renderTraceViewer(trace, { embedded: true }))
1339+
} catch (err) {
1340+
const error = err instanceof Error ? err.message : String(err)
1341+
log.error("trace view: failed", { error })
1342+
return c.json({ ok: false, error }, 500)
1343+
}
1344+
})
1345+
// altimate_change end
12521346
.all("/*", async (c) => {
12531347
const path = c.req.path
12541348

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// altimate_change - new file
2+
//
3+
// `GET /altimate/trace` and `GET /altimate/trace/:sessionID/view`: the TUI's `/traces` for the IDE
4+
// extension. Traces are real files in a temp `tracing.dir`, so these also cover that the routes
5+
// honor the configured directory.
6+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
7+
import fs from "fs/promises"
8+
import os from "os"
9+
import path from "path"
10+
import { Server } from "../../src/server/server"
11+
import { Config } from "../../src/config/config"
12+
import { resetDatabase } from "./db"
13+
import { disposeAllInstances } from "../fixture/fixture"
14+
15+
let dir: string
16+
let spies: Array<{ mockRestore: () => void }> = []
17+
18+
function get(urlPath: string, headers: Record<string, string> = {}) {
19+
return Server.Default().request(urlPath, { method: "GET", headers })
20+
}
21+
22+
async function writeTrace(sessionId: string, startedAt: string, metadata: Record<string, unknown> = {}) {
23+
const trace = {
24+
version: 2,
25+
traceId: `trace-${sessionId}`,
26+
sessionId,
27+
startedAt,
28+
metadata,
29+
spans: [],
30+
summary: {
31+
totalTokens: 1200,
32+
totalCost: 0.0123,
33+
totalToolCalls: 3,
34+
totalGenerations: 2,
35+
duration: 4500,
36+
status: "completed",
37+
tokens: { input: 1000, output: 200, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
38+
},
39+
}
40+
await fs.writeFile(path.join(dir, `${sessionId}.json`), JSON.stringify(trace))
41+
}
42+
43+
beforeEach(async () => {
44+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-trace-routes-"))
45+
spies.push(spyOn(Config, "get").mockResolvedValue({ tracing: { dir } } as never))
46+
})
47+
48+
afterEach(async () => {
49+
for (const spy of spies) spy.mockRestore()
50+
spies = []
51+
await fs.rm(dir, { recursive: true, force: true })
52+
await disposeAllInstances()
53+
await resetDatabase()
54+
})
55+
56+
describe("GET /altimate/trace", () => {
57+
test("lists traces newest first with their summary", async () => {
58+
await writeTrace("ses_old", "2026-10-01T10:00:00.000Z", { title: "Old session" })
59+
await writeTrace("ses_new", "2026-10-02T10:00:00.000Z", { prompt: "Explain the orders model" })
60+
61+
const response = await get("/altimate/trace")
62+
expect(response.status).toBe(200)
63+
const body = (await response.json()) as Record<string, any>
64+
expect(body.ok).toBe(true)
65+
expect(body.total).toBe(2)
66+
expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_new", "ses_old"])
67+
// Title falls back to the prompt when the session has no title.
68+
expect(body.traces[0]).toEqual({
69+
sessionID: "ses_new",
70+
title: "Explain the orders model",
71+
startedAt: "2026-10-02T10:00:00.000Z",
72+
status: "completed",
73+
duration: 4500,
74+
totalTokens: 1200,
75+
totalCost: 0.0123,
76+
totalToolCalls: 3,
77+
})
78+
expect(body.traces[1].title).toBe("Old session")
79+
})
80+
81+
test("pages with offset and limit", async () => {
82+
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
83+
await writeTrace("ses_b", "2026-10-02T10:00:00.000Z")
84+
await writeTrace("ses_c", "2026-10-03T10:00:00.000Z")
85+
86+
const body = (await (await get("/altimate/trace?offset=1&limit=1")).json()) as Record<string, any>
87+
expect(body.total).toBe(3)
88+
expect(body.offset).toBe(1)
89+
expect(body.limit).toBe(1)
90+
expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_b"])
91+
})
92+
93+
test("returns an empty page when there are no traces", async () => {
94+
const body = (await (await get("/altimate/trace")).json()) as Record<string, any>
95+
expect(body).toEqual({ ok: true, total: 0, offset: 0, limit: 50, traces: [] })
96+
})
97+
98+
test("refuses a cross-site browser request", async () => {
99+
await writeTrace("ses_a", "2026-10-01T10:00:00.000Z")
100+
const response = await get("/altimate/trace", { "sec-fetch-site": "cross-site" })
101+
expect(response.status).toBe(403)
102+
expect(await response.json()).toEqual({ ok: false, error: "Traces cannot be run from another site." })
103+
})
104+
})
105+
106+
describe("GET /altimate/trace/:sessionID/view", () => {
107+
test("serves the viewer page for the trace", async () => {
108+
await writeTrace("ses_view", "2026-10-01T10:00:00.000Z", { title: "Viewer session" })
109+
110+
const response = await get("/altimate/trace/ses_view/view")
111+
expect(response.status).toBe(200)
112+
expect(response.headers.get("content-type")).toContain("text/html")
113+
const html = await response.text()
114+
expect(html).toContain("<title>Altimate Trace</title>")
115+
expect(html).toContain("Viewer session")
116+
// The IDE's editor tab has no shareable URL, so the viewer leaves Copy Link out.
117+
expect(html).not.toContain('id="btn-copy-link"')
118+
expect(html).toContain('id="btn-share"')
119+
})
120+
121+
test("returns 404 for an unknown trace", async () => {
122+
const response = await get("/altimate/trace/ses_missing/view")
123+
expect(response.status).toBe(404)
124+
expect(await response.json()).toEqual({ ok: false, error: "Trace not found: ses_missing" })
125+
})
126+
127+
test("rejects a session ID that could name a path outside the traces dir", async () => {
128+
const response = await get("/altimate/trace/..%2Fsecrets/view")
129+
expect(response.status).toBe(400)
130+
})
131+
132+
test("refuses a browser origin on an unsecured server", async () => {
133+
await writeTrace("ses_view", "2026-10-01T10:00:00.000Z")
134+
const response = await get("/altimate/trace/ses_view/view", { origin: "https://evil.example" })
135+
expect(response.status).toBe(403)
136+
})
137+
})

0 commit comments

Comments
 (0)