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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Laminar observability plugin for Claude Code. Stop/SessionEnd hooks parse the se

- Emission goes through the OTel SDK: a `BasicTracerProvider` + collecting span processor mint spans, and `@opentelemetry/exporter-trace-otlp-http` serializes OTLP/JSON and POSTs to `{base}/v1/traces` with `Authorization: Bearer <project api key>`. Trace ids (32 hex) / span ids (16 hex) / nanosecond times / attribute-value envelopes are produced by the SDK.
- The OTel JS OTLP/JSON serializer emits integer attribute values as JSON **numbers** (e.g. `{"intValue": 10}`), not decimal strings. app-server's JSON decoder accepts `intValue` as either a number or a string, so both wire forms ingest fine (pinned by the wire-format test).
- `LMNR_SPAN_CONTEXT` may carry a serialized Laminar span context from a caller (`Laminar.serialize_span_context()` / SDK equivalent). When present and valid, Claude Code turn root spans use it as their remote parent, joining the caller's trace; when absent/invalid, standalone trace behavior is preserved.
- Spans are one Laminar trace per Claude Code turn, grouped into a session via `lmnr.association.properties.session_id` on the root span; `user_id` also goes on the root. Trace-constant context (`source`, `os`, `cwd`, `git_branch`, `claude_code_version`, `turn_number`, `transcript`, `skills`) goes in `lmnr.association.properties.metadata.*`. No `tags` are emitted. NEVER put per-span details in metadata — the app-server flattens those keys into TRACE-level metadata across ALL spans (last write wins). Use plain span attributes (e.g. `claude_code.tool.name`) for span-scoped data.
- LLM spans: `lmnr.span.type=LLM`, `gen_ai.input.messages` / `gen_ai.output.messages` as JSON strings, usage via `gen_ai.usage.{input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens}`.
- Spans are backdated to transcript timestamps (`startSpan(..., startTime)` / `span.end(endTime)` take `Date`s). When verifying in ClickHouse, filter `start_time` by the session's era, not the export time; the `spans` table has no `created_at`. Trace rows live in `traces_replacing` (query with `FINAL`).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ claude-code`):
| `~/.config/lmnr/claude-code-plugin.json` `projectApiKey` | — (required) | Laminar project API key |
| `~/.config/lmnr/claude-code-plugin.json` `baseUrl` | `https://api.lmnr.ai` | Laminar API base URL; for self-hosted use e.g. `http://localhost:8000` |
| `LMNR_USER_ID` | — | Optional user id attached to traces. If unset, the identity from `lmnr-cli login` (`~/.config/lmnr/credentials.json`) is used when present. |
| `LMNR_SPAN_CONTEXT` | — | Optional serialized Laminar span context (from `Laminar.serialize_span_context()` / SDK equivalent). When set, Claude Code turn root spans are emitted as children of that span so they join the caller's trace. |

The environment variables `LMNR_PROJECT_API_KEY` / `LMNR_BASE_URL` override the
file when set (handy for CI or a shell you already have configured).
Expand Down
55 changes: 54 additions & 1 deletion dist/hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25558,6 +25558,57 @@ var SPAN_TYPE_ATTR = "lmnr.span.type";
var SPAN_INPUT_ATTR = "lmnr.span.input";
var SPAN_OUTPUT_ATTR = "lmnr.span.output";
var ASSOC_PREFIX = "lmnr.association.properties";
function normalizeTraceId(value) {
if (typeof value !== "string") {
return null;
}
const id = value.trim().replace(/-/g, "").toLowerCase();
return /^[0-9a-f]{32}$/.test(id) && !/^0+$/.test(id) ? id : null;
}
function normalizeSpanId(value) {
if (typeof value !== "string") {
return null;
}
const id = value.trim().replace(/-/g, "").toLowerCase();
const spanId = id.length === 32 ? id.slice(16) : id;
return /^[0-9a-f]{16}$/.test(spanId) && !/^0+$/.test(spanId) ? spanId : null;
}
function parseParentSpanContext(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const record = parsed;
const traceId = normalizeTraceId(record.traceId ?? record.trace_id);
const spanId = normalizeSpanId(record.spanId ?? record.span_id);
if (traceId === null || spanId === null) {
return null;
}
return {
traceId,
spanId,
isRemote: true,
traceFlags: typeof record.traceFlags === "number" ? record.traceFlags : TraceFlags.SAMPLED
};
}
function parentContextFromEnv() {
const raw = process.env.LMNR_SPAN_CONTEXT?.trim();
if (!raw) {
return ROOT_CONTEXT;
}
const spanContext = parseParentSpanContext(raw);
if (spanContext === null) {
debug("Ignoring invalid LMNR_SPAN_CONTEXT");
return ROOT_CONTEXT;
}
debug("Using LMNR_SPAN_CONTEXT as Claude Code trace parent");
return trace.setSpanContext(ROOT_CONTEXT, spanContext);
}
function toOtelAttributes(attrs) {
const out = {};
for (const [key, value] of Object.entries(attrs)) {
Expand Down Expand Up @@ -25594,9 +25645,11 @@ var TraceEmitter = class {
config;
processor;
tracer;
rootParentContext;
constructor(config) {
this.config = config;
this.processor = new CollectingSpanProcessor();
this.rootParentContext = parentContextFromEnv();
const provider = new import_sdk_trace_base.BasicTracerProvider({
resource: new import_resources.Resource({
"service.name": "claude-code",
Expand All @@ -25611,7 +25664,7 @@ var TraceEmitter = class {
return this.processor.spans;
}
startSpan(name, startTime, attributes, parent) {
const parentCtx = parent ? parent.context : ROOT_CONTEXT;
const parentCtx = parent ? parent.context : this.rootParentContext;
const span = this.tracer.startSpan(
name,
{ kind: SpanKind.INTERNAL, startTime, attributes: toOtelAttributes(attributes) },
Expand Down
61 changes: 60 additions & 1 deletion src/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import {
ROOT_CONTEXT,
SpanKind,
SpanStatusCode,
TraceFlags,
trace,
type Attributes,
type AttributeValue,
type Context,
type Span,
type SpanContext,
} from "@opentelemetry/api";
import { ExportResultCode, type ExportResult } from "@opentelemetry/core";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
Expand All @@ -24,6 +26,61 @@ export const SPAN_OUTPUT_ATTR = "lmnr.span.output";
export const SPAN_PATH_ATTR = "lmnr.span.path";
export const ASSOC_PREFIX = "lmnr.association.properties";

function normalizeTraceId(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const id = value.trim().replace(/-/g, "").toLowerCase();
return /^[0-9a-f]{32}$/.test(id) && !/^0+$/.test(id) ? id : null;
}

function normalizeSpanId(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const id = value.trim().replace(/-/g, "").toLowerCase();
const spanId = id.length === 32 ? id.slice(16) : id;
return /^[0-9a-f]{16}$/.test(spanId) && !/^0+$/.test(spanId) ? spanId : null;
}

function parseParentSpanContext(raw: string): SpanContext | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
const traceId = normalizeTraceId(record.traceId ?? record.trace_id);
const spanId = normalizeSpanId(record.spanId ?? record.span_id);
if (traceId === null || spanId === null) {
return null;
}
return {
traceId,
spanId,
isRemote: true,
traceFlags: typeof record.traceFlags === "number" ? record.traceFlags : TraceFlags.SAMPLED,
};
}

function parentContextFromEnv(): Context {
const raw = process.env.LMNR_SPAN_CONTEXT?.trim();
if (!raw) {
return ROOT_CONTEXT;
}
const spanContext = parseParentSpanContext(raw);
if (spanContext === null) {
debug("Ignoring invalid LMNR_SPAN_CONTEXT");
return ROOT_CONTEXT;
}
debug("Using LMNR_SPAN_CONTEXT as Claude Code trace parent");
return trace.setSpanContext(ROOT_CONTEXT, spanContext);
}

/** Convert loosely-typed attributes to OTel attributes, dropping unsupported values. */
function toOtelAttributes(attrs: Record<string, Json>): Attributes {
const out: Attributes = {};
Expand Down Expand Up @@ -70,10 +127,12 @@ export class TraceEmitter {
readonly config: LaminarConfig;
private readonly processor: CollectingSpanProcessor;
private readonly tracer;
private readonly rootParentContext: Context;

constructor(config: LaminarConfig) {
this.config = config;
this.processor = new CollectingSpanProcessor();
this.rootParentContext = parentContextFromEnv();
const provider = new BasicTracerProvider({
resource: new Resource({
"service.name": "claude-code",
Expand All @@ -90,7 +149,7 @@ export class TraceEmitter {
}

startSpan(name: string, startTime: Date, attributes: Record<string, Json>, parent: SpanHandle | null): SpanHandle {
const parentCtx = parent ? parent.context : ROOT_CONTEXT;
const parentCtx = parent ? parent.context : this.rootParentContext;
const span = this.tracer.startSpan(
name,
{ kind: SpanKind.INTERNAL, startTime, attributes: toOtelAttributes(attributes) },
Expand Down
28 changes: 28 additions & 0 deletions tests/otlp-emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ describe("OTLP format", () => {
assert.match(span.spanContext().spanId, /^[0-9a-f]{16}$/);
});

it("uses LMNR_SPAN_CONTEXT as the parent for Claude Code turn roots", () => {
const parentTraceId = "12345678-1234-5678-9abc-def012345678";
const parentSpanId = "00000000-0000-0000-89ab-cdef01234567";
process.env.LMNR_SPAN_CONTEXT = JSON.stringify({ traceId: parentTraceId, spanId: parentSpanId });
try {
const emitter = makeEmitter();
const turns = buildTurns([userRow("hello"), assistantRow([{ type: "text", text: "hi" }])]);
emitTurn(emitter, emitter.config, "0123abcd-0000-4000-8000-000000000000", 1, turns[0]!, "/tmp/session.jsonl");

const byName = spansByName(emitter.spans);
const root = byName["Claude Code - Turn 1 (0123abcd)"]!;
const llm = byName["LLM Call 1"]!;
assert.equal(root.spanContext().traceId, parentTraceId.replace(/-/g, ""));
assert.equal(root.parentSpanId, "89abcdef01234567");
assert.equal(llm.spanContext().traceId, root.spanContext().traceId);
assert.equal(llm.parentSpanId, root.spanContext().spanId);

const bytes = JsonTraceSerializer.serializeRequest(emitter.spans);
const payload = JSON.parse(Buffer.from(bytes!).toString("utf-8"));
const wireSpans = payload.resourceSpans[0].scopeSpans[0].spans;
const wireRoot = wireSpans.find((s: any) => s.name === "Claude Code - Turn 1 (0123abcd)");
assert.equal(wireRoot.traceId, parentTraceId.replace(/-/g, ""));
assert.equal(wireRoot.parentSpanId, "89abcdef01234567");
} finally {
delete process.env.LMNR_SPAN_CONTEXT;
}
});

it("wire-format envelope (camelCase, intValue string, arrayValue)", () => {
const emitter = makeEmitter();
const turns = buildTurns([
Expand Down
Loading