feat(opencode): bridge global OTel tracer for AI SDK telemetry

The existing Effect Otlp.layerJson (merged via #21387) instruments
Effect services but does not register a global OTel tracer provider.
The AI SDK's experimental_telemetry reads from the global provider,
so its spans were silently dropped.

This adds a lightweight BasicTracerProvider bridge that registers
globally when OTEL_EXPORTER_OTLP_ENDPOINT is set, using the same
endpoint and headers the Effect layer already reads. AI SDK spans
(ai.streamText, ai.toolCall, prompt/response content, token counts)
now export alongside Effect service spans to any OTLP/HTTP collector.

Also enables recordInputs/recordOutputs on both streamText and
generateObject call sites so full message content is captured as
span attributes.
This commit is contained in:
Kit Langton 2026-04-09 22:30:22 -04:00
commit c5b5d17008
6 changed files with 121 additions and 3 deletions

View file

@ -117,6 +117,11 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.4.2",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1",

View file

@ -13,6 +13,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { Flag } from "@/flag/flag"
import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
@ -347,7 +348,9 @@ export namespace Agent {
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
isEnabled: cfg.experimental?.openTelemetry || !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
recordInputs: true,
recordOutputs: true,
metadata: {
userId: cfg.username ?? "unknown",
},

View file

@ -1,3 +1,4 @@
import "./otel-bridge.js"
import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"

View file

@ -0,0 +1,55 @@
/**
* Registers a global OTel tracer provider so the AI SDK's
* `experimental_telemetry` spans are exported alongside Effect's own spans.
*
* Effect's Otlp.layerJson (in effect/oltp.ts) handles Effect-service tracing
* but does NOT register a global provider the AI SDK only talks to the
* global one. This bridge fills the gap with a lightweight BasicTracerProvider.
*
* Import this module as the FIRST import in the entry point so the provider
* is registered before any AI SDK code runs.
*/
import { Flag } from "@/flag/flag"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
if (endpoint) {
const { trace } = await import("@opentelemetry/api")
const { BasicTracerProvider, BatchSpanProcessor } = await import("@opentelemetry/sdk-trace-base")
const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http")
const { resourceFromAttributes } = await import("@opentelemetry/resources")
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import("@opentelemetry/semantic-conventions")
const { CHANNEL, VERSION } = await import("@/installation/meta")
const provider = new BasicTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: "opencode",
[ATTR_SERVICE_VERSION]: VERSION,
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
}),
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: `${endpoint.replace(/\/$/, "")}/v1/traces`,
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...rest] = x.split("=")
acc[key] = rest.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined,
}),
),
],
})
trace.setGlobalTracerProvider(provider)
const shutdown = () => provider.shutdown().catch(() => {})
process.on("beforeExit", shutdown)
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
}

View file

@ -385,7 +385,9 @@ export namespace LLM {
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
isEnabled: cfg.experimental?.openTelemetry || !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
recordInputs: true,
recordOutputs: true,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,