Skip to content

Commit b2f8e65

Browse files
authored
Merge pull request #164 from sarath-menon/telemetry-memory-red
fix(laminar): bound diagnostic span and export memory
2 parents 4b22bab + 12a406f commit b2f8e65

7 files changed

Lines changed: 469 additions & 36 deletions

File tree

‎bun.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/bcode-laminar/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"@opentelemetry/sdk-node": "0.217.0",
2020
"@opentelemetry/sdk-trace-base": "2.9.0",
2121
"@opentelemetry/core": "2.9.0",
22+
"@opentelemetry/otlp-transformer": "0.217.0",
23+
"@opentelemetry/resources": "2.9.0",
2224
"@opentelemetry/exporter-trace-otlp-grpc": "0.217.0",
2325
"@opentelemetry/exporter-trace-otlp-proto": "0.217.0",
2426
"@grpc/grpc-js": "1.14.4"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { resourceFromAttributes } from "@opentelemetry/resources"
2+
import { ProtobufTraceSerializer } from "@opentelemetry/otlp-transformer"
3+
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
4+
import type { Attributes, AttributeValue } from "@opentelemetry/api"
5+
6+
export const RECORD_BYTES = 64 * 1024
7+
export const QUEUE_BYTES = 4 * 1024 * 1024
8+
export const QUEUE_RECORDS = 512
9+
const FIELD_BYTES = 16 * 1024
10+
const encoder = new TextEncoder()
11+
const decoder = new TextDecoder("utf-8", { fatal: true })
12+
13+
export function encodedBytes(spans: ReadableSpan[]): number {
14+
return ProtobufTraceSerializer.serializeRequest(spans)?.byteLength ?? 0
15+
}
16+
17+
function text(value: string): string {
18+
// Slice before encoding so a huge diagnostic string cannot create another huge buffer.
19+
const bytes = encoder.encode(value.slice(0, FIELD_BYTES))
20+
if (bytes.length <= FIELD_BYTES) return decoder.decode(bytes)
21+
let end = FIELD_BYTES
22+
while ((bytes[end]! & 0xc0) === 0x80) end--
23+
return decoder.decode(bytes.subarray(0, end))
24+
}
25+
26+
function attributes(source: Attributes, mark: () => void): Attributes {
27+
const result: Attributes = Object.create(null)
28+
for (const [key, value] of Object.entries(source)) {
29+
if (text(key) !== key) {
30+
mark()
31+
continue
32+
}
33+
if (typeof value === "string") {
34+
result[key] = text(value)
35+
if (result[key] !== value) mark()
36+
} else if (Array.isArray(value)) {
37+
const values: AttributeValue[] = []
38+
let remaining = FIELD_BYTES
39+
for (const item of value) {
40+
const bounded = typeof item === "string" ? text(item) : item
41+
const size = typeof bounded === "string" ? encoder.encode(bounded).length : 8
42+
if (size > remaining) {
43+
mark()
44+
break
45+
}
46+
if (bounded !== item) mark()
47+
remaining -= size
48+
values.push(bounded as AttributeValue)
49+
}
50+
result[key] = values as AttributeValue
51+
} else result[key] = value
52+
}
53+
return result
54+
}
55+
56+
export function boundedSpan(source: ReadableSpan): ReadableSpan | undefined {
57+
let changed = false
58+
const mark = () => {
59+
changed = true
60+
}
61+
const boundedText = (value: string) => {
62+
const result = text(value)
63+
if (result !== value) mark()
64+
return result
65+
}
66+
const context = { ...source.spanContext() }
67+
// A plain snapshot must not retain the original span and its unbounded strings.
68+
const span: ReadableSpan = {
69+
name: boundedText(source.name),
70+
kind: source.kind,
71+
spanContext: () => context,
72+
parentSpanContext: source.parentSpanContext,
73+
startTime: source.startTime,
74+
endTime: source.endTime,
75+
duration: source.duration,
76+
ended: source.ended,
77+
resource: resourceFromAttributes(attributes(source.resource.attributes, mark), {
78+
schemaUrl: source.resource.schemaUrl,
79+
}),
80+
instrumentationScope: source.instrumentationScope,
81+
droppedAttributesCount: source.droppedAttributesCount,
82+
droppedEventsCount: source.droppedEventsCount,
83+
droppedLinksCount: source.droppedLinksCount,
84+
attributes: attributes(source.attributes, mark),
85+
status: { ...source.status, message: source.status.message && boundedText(source.status.message) },
86+
events: source.events.map((event) => ({
87+
...event,
88+
name: boundedText(event.name),
89+
attributes: attributes(event.attributes ?? {}, mark),
90+
})),
91+
links: source.links.map((link) => ({ ...link, attributes: attributes(link.attributes ?? {}, mark) })),
92+
}
93+
if (changed) span.attributes["bcode.telemetry.truncated"] = true
94+
// Keep identity, timings, numeric usage and error status before diagnostic text.
95+
for (const key of Object.keys(span.attributes).sort(
96+
(a, b) =>
97+
(typeof span.attributes[b] === "string" ? span.attributes[b].length : 0) -
98+
(typeof span.attributes[a] === "string" ? span.attributes[a].length : 0),
99+
)) {
100+
if (encodedBytes([span]) <= RECORD_BYTES) return span
101+
if (typeof span.attributes[key] === "number" || typeof span.attributes[key] === "boolean") continue
102+
delete span.attributes[key]
103+
span.attributes["bcode.telemetry.truncated"] = true
104+
}
105+
while (encodedBytes([span]) > RECORD_BYTES && span.events.length) {
106+
span.events.pop()
107+
span.attributes["bcode.telemetry.truncated"] = true
108+
}
109+
while (encodedBytes([span]) > RECORD_BYTES && span.links.length) {
110+
span.links.pop()
111+
span.attributes["bcode.telemetry.truncated"] = true
112+
}
113+
return encodedBytes([span]) <= RECORD_BYTES ? span : undefined
114+
}

‎packages/bcode-laminar/src/exporter.ts‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,14 @@ import { makeSpanOtelV2Compatible } from "./compat"
1414
export class LaminarSpanExporter implements SpanExporter {
1515
private exporter: SpanExporter
1616

17-
constructor(options: {
18-
apiKey: string
19-
baseUrl: string
20-
port: number
21-
timeoutMillis?: number
22-
}) {
17+
constructor(options: { apiKey: string; baseUrl: string; port: number; timeoutMillis?: number }) {
2318
const url = options.baseUrl.replace(/\/$/, "").replace(/:\d{1,5}$/g, "")
2419
const metadata = new Metadata()
2520
metadata.set("authorization", `Bearer ${options.apiKey}`)
2621
this.exporter = new ExporterGrpc({
2722
url: `${url}:${options.port}`,
2823
metadata,
29-
timeoutMillis: options.timeoutMillis ?? 30000,
24+
timeoutMillis: options.timeoutMillis ?? 10000,
3025
})
3126
}
3227

@@ -54,14 +49,9 @@ export class LaminarSpanExporter implements SpanExporter {
5449
// the runtime never needs LMNR_PROJECT_API_KEY.
5550
//
5651
// Default path is unchanged: gRPC to Laminar with bearer auth.
57-
export const createSpanExporter = (
58-
laminar: { apiKey: string; baseUrl: string; port: number },
59-
): SpanExporter => {
60-
if (
61-
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||
62-
process.env.OTEL_EXPORTER_OTLP_ENDPOINT
63-
) {
64-
return new ExporterHttpProto()
52+
export const createSpanExporter = (laminar: { apiKey: string; baseUrl: string; port: number }): SpanExporter => {
53+
if (process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
54+
return new ExporterHttpProto({ timeoutMillis: 10000 })
6555
}
6656
return new LaminarSpanExporter(laminar)
6757
}

‎packages/bcode-laminar/src/processor.ts‎

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
// - `pino` logger — opencode plugins log via `client.app.log`; the plugin passes
1818
// in a logger callback.
1919

20-
import { type Context, type Span, SpanStatusCode, trace } from "@opentelemetry/api"
20+
import { type Context, type Span, SpanStatusCode, TraceFlags, trace } from "@opentelemetry/api"
21+
import { ExportResultCode } from "@opentelemetry/core"
2122
import {
2223
BatchSpanProcessor,
2324
type ReadableSpan,
@@ -34,6 +35,7 @@ import {
3435
SPAN_SDK_VERSION,
3536
} from "./attributes"
3637
import { getParentSpanId, makeSpanOtelV2Compatible, type OTelSpanCompat } from "./compat"
38+
import { boundedSpan, encodedBytes, QUEUE_BYTES, QUEUE_RECORDS } from "./budget"
3739
import { sessionCurrentTurnSpan } from "./state"
3840
import { otelSpanIdToUUID, type StringUUID } from "./utils"
3941

@@ -44,16 +46,55 @@ type LogFn = (level: "debug" | "info" | "warn" | "error", message: string) => vo
4446

4547
export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
4648
private inner: BatchSpanProcessor
49+
private pendingBytes = 0
50+
private pendingRecords = 0
51+
private sizes = new WeakMap<ReadableSpan, number>()
4752
private readonly spanIdToPath = new Map<string, string[]>()
4853
private readonly spanIdLists = new Map<string, StringUUID[]>()
4954
private readonly spawningSpanIdToToolUseId: Record<string, string> = {}
5055
private readonly log: LogFn
5156

5257
constructor(options: { exporter: SpanExporter; log?: LogFn }) {
53-
this.inner = new BatchSpanProcessor(options.exporter, {
54-
maxExportBatchSize: 512,
55-
exportTimeoutMillis: 30000,
56-
})
58+
this.inner = new BatchSpanProcessor(
59+
{
60+
export: (spans, callback) => {
61+
let completed = false
62+
const finish: typeof callback = (result) => {
63+
if (completed) return
64+
completed = true
65+
clearTimeout(timer)
66+
for (const span of spans) {
67+
this.pendingBytes -= this.sizes.get(span) ?? 0
68+
this.pendingRecords--
69+
this.sizes.delete(span)
70+
}
71+
callback(result)
72+
}
73+
// SDK timeout does not invoke the exporter callback; release our budget too.
74+
const timer = setTimeout(
75+
() =>
76+
finish({
77+
code: ExportResultCode.FAILED,
78+
error: new Error("Diagnostic export timed out"),
79+
}),
80+
10000,
81+
)
82+
try {
83+
options.exporter.export(spans, finish)
84+
} catch {
85+
finish({ code: ExportResultCode.FAILED, error: new Error("Diagnostic export failed") })
86+
}
87+
},
88+
shutdown: () => options.exporter.shutdown(),
89+
forceFlush: () => options.exporter.forceFlush?.() ?? Promise.resolve(),
90+
},
91+
{
92+
// Sixteen 64 KiB records fit in 1 MiB, including each record's OTLP envelope.
93+
maxExportBatchSize: 16,
94+
maxQueueSize: QUEUE_RECORDS,
95+
exportTimeoutMillis: 10000,
96+
},
97+
)
5798
this.log = options.log ?? (() => {})
5899
}
59100

@@ -71,9 +112,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
71112
// 1. Re-parent AI-SDK spans onto the live "turn" span for this opencode
72113
// session, so each turn becomes its own Laminar trace instead of a
73114
// forest of orphan traces.
74-
const sessionId = span.attributes?.["ai.telemetry.metadata.sessionId"] as
75-
| string
76-
| undefined
115+
const sessionId = span.attributes?.["ai.telemetry.metadata.sessionId"] as string | undefined
77116
let ctx = parentContext
78117
if (sessionId && typeof sessionId === "string") {
79118
const parentSpanContext = sessionCurrentTurnSpan[sessionId]?.spanContext()
@@ -96,9 +135,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
96135
const toolCallNameAttr = span.attributes?.["ai.toolCall.name"] as string | undefined
97136
if (
98137
SPAWNING_TOOL_NAMES.includes(span.name) ||
99-
(span.name === "ai.toolCall" &&
100-
toolCallNameAttr &&
101-
SPAWNING_TOOL_NAMES.includes(toolCallNameAttr))
138+
(span.name === "ai.toolCall" && toolCallNameAttr && SPAWNING_TOOL_NAMES.includes(toolCallNameAttr))
102139
) {
103140
this.spawningSpanIdToToolUseId[otelSpanIdToUUID(span.spanContext().spanId)] = toolCallId
104141
}
@@ -107,16 +144,12 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
107144
// 3. Stamp Laminar's path attributes. The UI nests by these, NOT by
108145
// OTel parentSpanId — must run for every span.
109146
const parentPathFromAttribute = span.attributes?.[PARENT_SPAN_PATH] as string[] | undefined
110-
const parentIdsPathFromAttribute = span.attributes?.[PARENT_SPAN_IDS_PATH] as
111-
| StringUUID[]
112-
| undefined
147+
const parentIdsPathFromAttribute = span.attributes?.[PARENT_SPAN_IDS_PATH] as StringUUID[] | undefined
113148
const parentSpanId = getParentSpanId(span)
114149
const parentSpanPath =
115-
parentPathFromAttribute ??
116-
(parentSpanId !== undefined ? this.spanIdToPath.get(parentSpanId) : undefined)
150+
parentPathFromAttribute ?? (parentSpanId !== undefined ? this.spanIdToPath.get(parentSpanId) : undefined)
117151
const parentSpanIdsPath =
118-
parentIdsPathFromAttribute ??
119-
(parentSpanId !== undefined ? this.spanIdLists.get(parentSpanId) : [])
152+
parentIdsPathFromAttribute ?? (parentSpanId !== undefined ? this.spanIdLists.get(parentSpanId) : [])
120153

121154
const spanId = span.spanContext().spanId
122155
const spanPath = parentSpanPath ? [...parentSpanPath, span.name] : [span.name]
@@ -143,8 +176,7 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
143176
if (spawningToolCallSpanId) {
144177
span.setAttributes({
145178
"lmnr.spawning_subagent.span_id": spawningToolCallSpanId,
146-
"lmnr.spawning_subagent.tool_use_id":
147-
this.spawningSpanIdToToolUseId[spawningToolCallSpanId]!,
179+
"lmnr.spawning_subagent.tool_use_id": this.spawningSpanIdToToolUseId[spawningToolCallSpanId]!,
148180
})
149181
}
150182
}
@@ -176,6 +208,17 @@ export class OpenCodeLaminarSpanProcessor implements SpanProcessor {
176208
// entries that the descendant scan iterates), not by the raw hex span id.
177209
delete this.spawningSpanIdToToolUseId[otelSpanIdToUUID(spanId)]
178210
makeSpanOtelV2Compatible(span)
179-
this.inner.onEnd(span)
211+
if (!(span.spanContext().traceFlags & TraceFlags.SAMPLED)) return
212+
const bounded = boundedSpan(span)
213+
const size = bounded ? encodedBytes([bounded]) : 0
214+
if (!bounded || this.pendingBytes + size > QUEUE_BYTES || this.pendingRecords >= QUEUE_RECORDS) {
215+
this.log("warn", "Diagnostic span dropped: telemetry memory budget")
216+
return
217+
}
218+
makeSpanOtelV2Compatible(bounded)
219+
this.sizes.set(bounded, size)
220+
this.pendingBytes += size
221+
this.pendingRecords++
222+
this.inner.onEnd(bounded)
180223
}
181224
}

0 commit comments

Comments
 (0)