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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/bcode-laminar/VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ worth chasing.
## Behavior trims (vs upstream)

- **No `LaminarClient` "rollout sessions"** — `LMNR_ROLLOUT_SESSION_ID` branch removed. We don't use that feature.
- **No HTTP/protobuf exporter fallback** — gRPC only. Drops `@opentelemetry/exporter-trace-otlp-proto`.
- **No `parseOtelHeaders` / `OTEL_HEADERS` resolution** — we always have a Laminar API key when emitting; OTel-env paths are dead.
- **No `pino` logger** — log via `client.app.log` (opencode-managed).
- **No `loadEnv()`** — opencode loads `.env` already; second pass would surprise users.
- **No caller-side context injection (`sessionExternalContexts`)** — bcode runs the agent locally, not driven by an external TS host.
- **`Laminar.startSpan` reduced** — only the path needed for the per-turn span (sessionId + optional parentSpanContext); no `tracingLevel`, masked-input, or process-global activation stack.

## Behavior additions (vs upstream)

- **OTLP/HTTP+protobuf transport selectable via standard OTel env vars.** When `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` or `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `createSpanExporter` (in `exporter.ts`) returns the standard `@opentelemetry/exporter-trace-otlp-proto` exporter instead of the Laminar gRPC one. Headers come from `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_EXPORTER_OTLP_TRACES_HEADERS` (read by the proto exporter itself). Enables two flows:
- OSS users routing bcode telemetry to any OTel collector (Honeycomb, Tempo, Jaeger) without a Laminar account.
- V4 cloud relaying spans through a backend that holds the real Laminar ingest key — the agent runtime never needs `LMNR_PROJECT_API_KEY`.
- Default (neither OTel env var set) is unchanged: gRPC to Laminar.

## Behavior preserved

- `lmnr.span.path` / `lmnr.span.ids_path` ancestor stamping (Laminar UI nests by these, not by OTel parentSpanId).
Expand Down
1 change: 1 addition & 0 deletions packages/bcode-laminar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/core": "2.6.1",
"@opentelemetry/exporter-trace-otlp-grpc": "0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "0.214.0",
"@grpc/grpc-js": "1.14.3"
},
"devDependencies": {
Expand Down
27 changes: 26 additions & 1 deletion packages/bcode-laminar/src/exporter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Vendored from lmnr-ts/packages/lmnr/src/opentelemetry-lib/tracing/exporter.ts.
// Trimmed to gRPC + bearer-token only (no HTTP fallback, no OTEL_HEADERS env path).
// Trimmed to gRPC + bearer-token + an OTLP/HTTP fallback selected by standard
// OTel env vars (no OTEL_HEADERS-only env path — endpoint must be set to
// switch transports).

import { Metadata } from "@grpc/grpc-js"
import type { ExportResult } from "@opentelemetry/core"
import { OTLPTraceExporter as ExporterGrpc } from "@opentelemetry/exporter-trace-otlp-grpc"
import { OTLPTraceExporter as ExporterHttpProto } from "@opentelemetry/exporter-trace-otlp-proto"
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"

import { makeSpanOtelV2Compatible } from "./compat"
Expand Down Expand Up @@ -40,3 +43,25 @@ export class LaminarSpanExporter implements SpanExporter {
return this.exporter.forceFlush?.()
}
}

// Pick the right exporter based on env. When `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
// or `OTEL_EXPORTER_OTLP_ENDPOINT` is set, route spans through OTLP/HTTP+protobuf
// (vendor-neutral; standard OTel env-var contract — headers come from
// `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_EXPORTER_OTLP_TRACES_HEADERS` which the
// proto exporter reads itself). Lets users point bcode at any OTel collector
// (Honeycomb, Tempo, Jaeger, etc.) without a Laminar account, and lets the
// V4 cloud worker relay through a backend that holds the real Laminar key —
// the runtime never needs LMNR_PROJECT_API_KEY.
//
// Default path is unchanged: gRPC to Laminar with bearer auth.
export const createSpanExporter = (
laminar: { apiKey: string; baseUrl: string; port: number },
): SpanExporter => {
if (
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT
) {
return new ExporterHttpProto()
}
return new LaminarSpanExporter(laminar)
}
18 changes: 13 additions & 5 deletions packages/bcode-laminar/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import type { Plugin } from "@opencode-ai/plugin"
import { NodeSDK } from "@opentelemetry/sdk-node"

import { createSpanExporter } from "./exporter"
import { OpenCodeLaminarSpanProcessor } from "./processor"
import { startTurnSpan } from "./span"
import { sessionCurrentTurnSpan, subagentSessionIds } from "./state"
Expand All @@ -29,6 +30,15 @@ const parsePort = (raw: string | undefined, fallback: number): number => {

export const LaminarPlugin: Plugin = ({ client }) => {
const projectApiKey = process.env.LMNR_PROJECT_API_KEY
// OTel-standard endpoint env vars opt into OTLP/HTTP+protobuf — used by
// OSS users routing to non-Laminar collectors (Honeycomb, Tempo, Jaeger),
// and by the V4 cloud worker which relays through a backend that holds
// the real Laminar key. Either env var alone (without LMNR_PROJECT_API_KEY)
// is sufficient to enable tracing.
// `||` (not `??`) so an empty-string signal-specific override falls back
// to the generic endpoint, matching OTel SDK convention (empty == unset).
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT
const baseUrl = process.env.LMNR_BASE_URL ?? "https://api.lmnr.ai"
const port = parsePort(
process.env.LMNR_GRPC_PORT,
Expand All @@ -48,18 +58,16 @@ export const LaminarPlugin: Plugin = ({ client }) => {
.catch(() => {})
}

if (!projectApiKey) return Promise.resolve({})
if (!projectApiKey && !otlpEndpoint) return Promise.resolve({})

const processor = new OpenCodeLaminarSpanProcessor({
apiKey: projectApiKey,
baseUrl,
port,
exporter: createSpanExporter({ apiKey: projectApiKey ?? "", baseUrl, port }),
log,
})

const sdk = new NodeSDK({ spanProcessors: [processor] })
sdk.start()
log("info", `Laminar tracing initialized → ${baseUrl}`)
log("info", `Laminar tracing initialized → ${otlpEndpoint ?? baseUrl}`)

return Promise.resolve({
config: async (config) => {
Expand Down
21 changes: 6 additions & 15 deletions packages/bcode-laminar/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { type Context, type Span, trace } from "@opentelemetry/api"
import {
BatchSpanProcessor,
type ReadableSpan,
type SpanExporter,
type SpanProcessor,
} from "@opentelemetry/sdk-trace-base"

Expand All @@ -33,7 +34,6 @@ import {
SPAN_SDK_VERSION,
} from "./attributes"
import { getParentSpanId, makeSpanOtelV2Compatible, type OTelSpanCompat } from "./compat"
import { LaminarSpanExporter } from "./exporter"
import { sessionCurrentTurnSpan } from "./state"
import { otelSpanIdToUUID, type StringUUID } from "./utils"

Expand All @@ -48,20 +48,11 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
private readonly spawningSpanIdToToolUseId: Record<string, string> = {}
private readonly log: LogFn

constructor(options: {
apiKey: string
baseUrl: string
port: number
log?: LogFn
}) {
this.inner = new BatchSpanProcessor(
new LaminarSpanExporter({
apiKey: options.apiKey,
baseUrl: options.baseUrl,
port: options.port,
}),
{ maxExportBatchSize: 512, exportTimeoutMillis: 30000 },
)
constructor(options: { exporter: SpanExporter; log?: LogFn }) {
this.inner = new BatchSpanProcessor(options.exporter, {
maxExportBatchSize: 512,
exportTimeoutMillis: 30000,
})
this.log = options.log ?? (() => {})
}

Expand Down
Loading