diff --git a/bun.lock b/bun.lock index 8db8852a0bfe..1c04c0b79a0a 100644 --- a/bun.lock +++ b/bun.lock @@ -338,6 +338,9 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", "@opentui/core": "0.1.90", "@opentui/solid": "0.1.90", "@parcel/watcher": "2.5.1", @@ -1448,6 +1451,12 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 39b4e6232b4f..942d77b891e4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -101,6 +101,9 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", "@opentui/core": "0.1.90", "@opentui/solid": "0.1.90", "@parcel/watcher": "2.5.1", diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..69081e97e699 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -36,6 +36,7 @@ import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" +import { ROOT_CONTEXT, context as otelContext, propagation } from "@opentelemetry/api" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" @@ -43,6 +44,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +import { TelemetryBootstrap } from "@/telemetry/bootstrap" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -53,8 +55,21 @@ export namespace Server { export const Default = lazy(() => createApp({})) export const createApp = (opts: { cors?: string[] }): Hono => { + TelemetryBootstrap.init() const app = new Hono() return app + .use(async (_c, next) => { + // Continue any incoming distributed trace context. + const extractedContext = propagation.extract(ROOT_CONTEXT, _c.req.raw.headers, { + get(carrier, key) { + return carrier.get(key) ?? undefined + }, + keys(carrier) { + return [...carrier.keys()] + }, + }) + return otelContext.with(extractedContext, next) + }) .onError((err, c) => { log.error("failed", { error: err, diff --git a/packages/opencode/src/telemetry/bootstrap.ts b/packages/opencode/src/telemetry/bootstrap.ts new file mode 100644 index 000000000000..1fcb020cbcf1 --- /dev/null +++ b/packages/opencode/src/telemetry/bootstrap.ts @@ -0,0 +1,14 @@ +import { context, propagation } from "@opentelemetry/api" +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks" +import { W3CTraceContextPropagator } from "@opentelemetry/core" + +let initialized = false + +export namespace TelemetryBootstrap { + export function init() { + if (initialized) return + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()) + propagation.setGlobalPropagator(new W3CTraceContextPropagator()) + initialized = true + } +} diff --git a/packages/opencode/test/server/trace-context.test.ts b/packages/opencode/test/server/trace-context.test.ts new file mode 100644 index 000000000000..452c5211b278 --- /dev/null +++ b/packages/opencode/test/server/trace-context.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { context, trace } from "@opentelemetry/api" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("server trace context", () => { + test("continues incoming trace context for request handlers", async () => { + await using tmp = await tmpdir({ git: true }) + const app = Server.createApp({}) + const originalProvide = Instance.provide + let seen: + | { + traceId: string | null + spanId: string | null + isRemote: boolean + } + | undefined + const provideSpy = spyOn(Instance, "provide").mockImplementation(async (input) => { + const spanContext = trace.getSpanContext(context.active()) + seen = { + traceId: spanContext?.traceId ?? null, + spanId: spanContext?.spanId ?? null, + isRemote: spanContext?.isRemote ?? false, + } + return originalProvide(input) + }) + + try { + const response = await app.request("/path", { + headers: { + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(seen).toEqual({ + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + isRemote: true, + }) + } finally { + provideSpy.mockRestore() + } + }) + + test("leaves request handlers on root context when no trace headers are present", async () => { + await using tmp = await tmpdir({ git: true }) + const app = Server.createApp({}) + const originalProvide = Instance.provide + let seen: + | { + traceId: string | null + spanId: string | null + isRemote: boolean + } + | undefined + const provideSpy = spyOn(Instance, "provide").mockImplementation(async (input) => { + const spanContext = trace.getSpanContext(context.active()) + seen = { + traceId: spanContext?.traceId ?? null, + spanId: spanContext?.spanId ?? null, + isRemote: spanContext?.isRemote ?? false, + } + return originalProvide(input) + }) + + try { + const response = await app.request("/path", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(seen).toEqual({ + traceId: null, + spanId: null, + isRemote: false, + }) + } finally { + provideSpy.mockRestore() + } + }) +})