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
35 changes: 24 additions & 11 deletions packages/opencode/src/server/routes/instance/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,31 @@ import { AppRuntime } from "@/effect/app-runtime"

type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never

// Build the base span attributes for an HTTP handler: method, path, and every
// matched route param (sessionID, messageID, partID, providerID, ptyID, …)
// prefixed with `opencode.`. This makes each request's root span searchable
// by ID in motel without having to parse the path string.
export interface RequestLike {
readonly req: {
readonly method: string
readonly url: string
param(): Record<string, string>
}
}

export function requestAttributes(c: RequestLike): Record<string, string> {
const attributes: Record<string, string> = {
"http.method": c.req.method,
"http.path": new URL(c.req.url).pathname,
}
for (const [key, value] of Object.entries(c.req.param())) {
attributes[`opencode.${key}`] = value
}
return attributes
}

export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
const url = new URL(c.req.url)
return AppRuntime.runPromise(
effect.pipe(
Effect.withSpan(name, {
attributes: {
"http.method": c.req.method,
"http.path": url.pathname,
},
}),
),
)
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
}

export async function jsonRequest<C extends Context, A, E>(
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/server/trace-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import { requestAttributes } from "../../src/server/routes/instance/trace"

function fakeContext(method: string, url: string, params: Record<string, string>) {
return {
req: {
method,
url,
param: () => params,
},
}
}

describe("requestAttributes", () => {
test("includes http method and path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
expect(attrs["http.method"]).toBe("GET")
expect(attrs["http.path"]).toBe("/session")
})

test("strips query string from path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
expect(attrs["http.path"]).toBe("/file/search")
})

test("tags route params with opencode.<param> prefix", () => {
const attrs = requestAttributes(
fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
sessionID: "ses_abc",
messageID: "msg_def",
partID: "prt_ghi",
}),
)
expect(attrs["opencode.sessionID"]).toBe("ses_abc")
expect(attrs["opencode.messageID"]).toBe("msg_def")
expect(attrs["opencode.partID"]).toBe("prt_ghi")
})

test("produces no param attributes when no params are matched", () => {
const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
expect(Object.keys(attrs).filter((k) => k.startsWith("opencode."))).toEqual([])
})

test("handles non-ID params (e.g. mcp :name) without mangling", () => {
const attrs = requestAttributes(
fakeContext("POST", "http://localhost/mcp/exa/connect", {
name: "exa",
}),
)
expect(attrs["opencode.name"]).toBe("exa")
})
})
Loading