Skip to content
Open
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
126 changes: 126 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "0.211.0",
"@opentelemetry/resources": "2.5.0",
"@opentelemetry/sdk-node": "0.211.0",
"@opentelemetry/sdk-trace-base": "2.5.0",
"@opentelemetry/sdk-trace-node": "2.5.0",
"@opentelemetry/semantic-conventions": "1.39.0",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@parcel/watcher": "2.5.1",
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
import { TelemetryProvider, TelemetryConfig } from "@/telemetry"

import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
Expand Down Expand Up @@ -283,12 +284,17 @@ export namespace Agent {
system.push(PROMPT_GENERATE)
const existing = await list()

const telemetryCfg = TelemetryConfig.normalize(cfg.experimental?.openTelemetry)
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
isEnabled: telemetryCfg.enabled,
recordInputs: telemetryCfg.recordInputs,
recordOutputs: telemetryCfg.recordOutputs,
functionId: `agent.generate.${defaultModel.providerID}.${defaultModel.modelID}`,
metadata: {
userId: cfg.username ?? "unknown",
},
tracer: TelemetryProvider.getTracer(),
},
temperature: 0.3,
messages: [
Expand Down
19 changes: 17 additions & 2 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,9 +1072,24 @@ export namespace Config {
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
.boolean()
.union([
z.boolean(),
z.object({
enabled: z.boolean().default(false),
serviceName: z.string().default("opencode"),
endpoint: z.string().optional(),
protocol: z.enum(["http", "grpc"]).default("http"),
headers: z.record(z.string(), z.string()).optional(),
exportInterval: z.number().default(5000),
maxQueueSize: z.number().default(2048),
recordInputs: z.boolean().default(true),
recordOutputs: z.boolean().default(true),
sampleRate: z.number().min(0).max(1).default(1.0),
attributes: z.record(z.string(), z.any()).optional(),
}),
])
.optional()
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
.describe("OpenTelemetry configuration for AI SDK telemetry spans"),
primary_tools: z
.array(z.string())
.optional()
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { TelemetryProvider } from "./telemetry"

process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
Expand Down Expand Up @@ -151,6 +152,9 @@ try {
}
process.exitCode = 1
} finally {
// Shutdown telemetry to flush any pending spans
await TelemetryProvider.shutdown()

// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { TelemetryProvider } from "@/telemetry"
import { Config } from "@/config/config"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })

const cfg = await Config.get()
await TelemetryProvider.init(cfg.experimental?.openTelemetry)
await Plugin.init()
Share.init()
ShareNext.init()
Expand Down
18 changes: 17 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
import { Auth } from "@/auth"
import { TelemetryProvider, TelemetryConfig } from "@/telemetry"

export namespace LLM {
const log = Log.create({ service: "llm" })
Expand Down Expand Up @@ -260,7 +261,22 @@ export namespace LLM {
extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
],
}),
experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },
experimental_telemetry: (() => {
const telemetryCfg = TelemetryConfig.normalize(cfg.experimental?.openTelemetry)
return {
isEnabled: telemetryCfg.enabled,
recordInputs: telemetryCfg.recordInputs,
recordOutputs: telemetryCfg.recordOutputs,
functionId: `llm.stream.${input.model.providerID}.${input.model.id}`,
metadata: {
sessionID: input.sessionID,
providerID: input.model.providerID,
modelID: input.model.id,
agentName: input.agent.name,
},
tracer: TelemetryProvider.getTracer(),
}
})(),
})
}

Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/src/telemetry/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { z } from "zod"

export namespace TelemetryConfig {
export const ObjectSchema = z.object({
enabled: z.boolean().default(false),
serviceName: z.string().default("opencode"),
endpoint: z.string().optional(),
protocol: z.enum(["http", "grpc"]).default("http"),
headers: z.record(z.string(), z.string()).optional(),
exportInterval: z.number().default(5000),
maxQueueSize: z.number().default(2048),
recordInputs: z.boolean().default(true),
recordOutputs: z.boolean().default(true),
sampleRate: z.number().min(0).max(1).default(1.0),
attributes: z.record(z.string(), z.any()).optional(),
})

export type ObjectInfo = z.output<typeof ObjectSchema>

export type Info = ObjectInfo | undefined

export function normalize(val: boolean | ObjectInfo | undefined): ObjectInfo {
if (val === undefined) return { enabled: false, serviceName: "opencode", protocol: "http", exportInterval: 5000, maxQueueSize: 2048, recordInputs: true, recordOutputs: true, sampleRate: 1.0 }
if (typeof val === "boolean") return { enabled: val, serviceName: "opencode", protocol: "http", exportInterval: 5000, maxQueueSize: 2048, recordInputs: true, recordOutputs: true, sampleRate: 1.0 }
return val
}

export function fromEnv(): Partial<ObjectInfo> {
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
const serviceName = process.env.OTEL_SERVICE_NAME

const result: Partial<ObjectInfo> = {}
if (endpoint) result.endpoint = endpoint
if (serviceName) result.serviceName = serviceName

return result
}

export function merge(config: ObjectInfo, env: Partial<ObjectInfo>): ObjectInfo {
return {
...config,
...env,
}
}

export const defaults: ObjectInfo = {
enabled: false,
serviceName: "opencode",
protocol: "http",
exportInterval: 5000,
maxQueueSize: 2048,
recordInputs: true,
recordOutputs: true,
sampleRate: 1.0,
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { TelemetryConfig } from "./config"
export { TelemetryProvider } from "./provider"
96 changes: 96 additions & 0 deletions packages/opencode/src/telemetry/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NodeSDK } from "@opentelemetry/sdk-node"
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"
import type { SpanProcessor } from "@opentelemetry/sdk-trace-base"
import { resourceFromAttributes } from "@opentelemetry/resources"
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { trace } from "@opentelemetry/api"
import type { Tracer } from "@opentelemetry/api"
import { TelemetryConfig } from "./config"
import { Log } from "@/util/log"
import { Installation } from "@/installation"

export namespace TelemetryProvider {
let sdk: NodeSDK | undefined
let spanProcessor: SpanProcessor | undefined
let initialized = false
let config: TelemetryConfig.ObjectInfo | undefined

export async function init(cfg: boolean | TelemetryConfig.ObjectInfo | undefined): Promise<void> {
if (initialized) return

const normalized = TelemetryConfig.normalize(cfg)
const envConfig = TelemetryConfig.fromEnv()
config = TelemetryConfig.merge(normalized, envConfig)

if (!config.enabled) {
Log.Default.debug("telemetry disabled")
return
}

const baseEndpoint = config.endpoint ?? "http://localhost:4318"
const endpoint = baseEndpoint.endsWith("/v1/traces") ? baseEndpoint : `${baseEndpoint.replace(/\/$/, "")}/v1/traces`

Log.Default.info("initializing telemetry", {
endpoint,
serviceName: config.serviceName,
sampleRate: config.sampleRate,
})

const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.serviceName,
[ATTR_SERVICE_VERSION]: Installation.VERSION,
...config.attributes,
})

const exporter = new OTLPTraceExporter({
url: endpoint,
headers: config.headers,
})

spanProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: config.maxQueueSize,
scheduledDelayMillis: config.exportInterval,
})

sdk = new NodeSDK({
resource,
traceExporter: exporter,
spanProcessors: [spanProcessor],
})

sdk.start()
initialized = true

Log.Default.info("telemetry initialized", { endpoint })
}

export async function shutdown(): Promise<void> {
if (!initialized || !sdk) return

Log.Default.info("shutting down telemetry")

await spanProcessor?.forceFlush().catch(() => {})
await sdk.shutdown().catch(() => {})

initialized = false
sdk = undefined
spanProcessor = undefined
config = undefined

Log.Default.info("telemetry shutdown complete")
}

export function getTracer(name = "opencode"): Tracer | undefined {
if (!initialized || !config?.enabled) return undefined
return trace.getTracer(name, Installation.VERSION)
}

export function isEnabled(): boolean {
return initialized && (config?.enabled ?? false)
}

export function getConfig(): TelemetryConfig.ObjectInfo | undefined {
return config
}
}