argsbarg 6.1.8 → 6.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,10 @@ Unit tests for ECS log line formatting.
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { formatEcsLine } from "./ecs.ts";
6
+ import { ECS_VERSION, formatEcsLine, mergeEnrichFields, PROTECTED_ECS_KEYS } from "./ecs.ts";
7
7
 
8
8
  describe("formatEcsLine", () => {
9
- test("includes service fields and event action", () => {
9
+ test("includes ECS Logging baseline fields", () => {
10
10
  const line = formatEcsLine(
11
11
  { name: "myapp", version: "7.0.0" },
12
12
  {
@@ -21,9 +21,49 @@ describe("formatEcsLine", () => {
21
21
  expect(parsed["event.action"]).toBe("http.server.start");
22
22
  expect(parsed.message).toBe("HTTP API listening");
23
23
  expect(parsed["log.level"]).toBe("info");
24
+ expect(parsed["ecs.version"]).toBe(ECS_VERSION);
24
25
  expect(typeof parsed["@timestamp"]).toBe("string");
25
26
  });
26
27
 
28
+ test("nests labels object instead of flattening", () => {
29
+ const line = formatEcsLine(
30
+ { name: "app", version: "1.0.0" },
31
+ {
32
+ level: "info",
33
+ message: "ok",
34
+ labels: { request_id: "abc", team: "demo" },
35
+ },
36
+ );
37
+ const parsed = JSON.parse(line) as Record<string, unknown>;
38
+ expect(parsed.labels).toEqual({ request_id: "abc", team: "demo" });
39
+ expect(parsed["labels.request_id"]).toBeUndefined();
40
+ });
41
+
42
+ test("includes trace and canonical http fields", () => {
43
+ const line = formatEcsLine(
44
+ { name: "app", version: "1.0.0" },
45
+ {
46
+ level: "info",
47
+ message: "GET /workspaces",
48
+ action: "http.access",
49
+ traceId: "0af7651916cd43dd8448eb211c80319c",
50
+ spanId: "b7ad6b7169203331",
51
+ fields: {
52
+ "http.request.method": "GET",
53
+ "url.path": "/workspaces",
54
+ "http.response.status_code": 200,
55
+ "event.duration": 45_000_000,
56
+ },
57
+ },
58
+ );
59
+ const parsed = JSON.parse(line) as Record<string, unknown>;
60
+ expect(parsed["trace.id"]).toBe("0af7651916cd43dd8448eb211c80319c");
61
+ expect(parsed["span.id"]).toBe("b7ad6b7169203331");
62
+ expect(parsed["http.request.method"]).toBe("GET");
63
+ expect(parsed["url.path"]).toBe("/workspaces");
64
+ expect(parsed["event.duration"]).toBe(45_000_000);
65
+ });
66
+
27
67
  test("includes error stack fields", () => {
28
68
  const err = new Error("boom");
29
69
  const line = formatEcsLine(
@@ -40,4 +80,30 @@ describe("formatEcsLine", () => {
40
80
  expect(parsed["error.type"]).toBe("Error");
41
81
  expect(String(parsed["error.stack_trace"])).toContain("boom");
42
82
  });
83
+
84
+ test("enrich merges additive fields but not protected keys", () => {
85
+ const line = formatEcsLine({
86
+ service: { name: "app", version: "1.0.0" },
87
+ event: { level: "info", message: "hello" },
88
+ enrich: () => ({
89
+ "custom.field": "yes",
90
+ message: "overridden",
91
+ }),
92
+ });
93
+ const parsed = JSON.parse(line) as Record<string, unknown>;
94
+ expect(parsed.message).toBe("hello");
95
+ expect(parsed["custom.field"]).toBe("yes");
96
+ });
97
+ });
98
+
99
+ describe("mergeEnrichFields", () => {
100
+ test("skips protected and existing keys", () => {
101
+ const line: Record<string, unknown> = { message: "keep", "trace.id": "set" };
102
+ mergeEnrichFields(line, { message: "nope", "ecs.version": "0.0.0", "trace.id": "bad", extra: 1 });
103
+ expect(line.message).toBe("keep");
104
+ expect(line["ecs.version"]).toBeUndefined();
105
+ expect(line["trace.id"]).toBe("set");
106
+ expect(line.extra).toBe(1);
107
+ expect(PROTECTED_ECS_KEYS.has("message")).toBe(true);
108
+ });
43
109
  });
package/src/log/ecs.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  Elastic Common Schema (ECS) JSON log line formatting for framework observability.
3
3
  */
4
4
 
5
+ /** ECS version string written to every JSON log line. */
6
+ export const ECS_VERSION = "8.11.0";
7
+
5
8
  /** Severity label for ECS `log.level`. */
6
9
  export type EcsLogLevel = "debug" | "info" | "warn" | "error";
7
10
 
@@ -11,6 +14,26 @@ export interface EcsServiceFields {
11
14
  version: string;
12
15
  }
13
16
 
17
+ /** Context for {@link CliLogConfig.enrich} and {@link CliLogConfig.serialize}. */
18
+ export interface LogEnrichContext {
19
+ level: EcsLogLevel;
20
+ message: string;
21
+ action?: string;
22
+ requestId?: string;
23
+ traceId?: string;
24
+ spanId?: string;
25
+ labels?: Record<string, string | number | boolean>;
26
+ error?: unknown;
27
+ service: EcsServiceFields;
28
+ http?: {
29
+ method: string;
30
+ path: string;
31
+ status: number;
32
+ durationMs: number;
33
+ clientIp?: string;
34
+ };
35
+ }
36
+
14
37
  /** Input for one ECS log event. */
15
38
  export interface EcsLogEvent {
16
39
  level: EcsLogLevel;
@@ -19,8 +42,31 @@ export interface EcsLogEvent {
19
42
  labels?: Record<string, string | number | boolean>;
20
43
  error?: unknown;
21
44
  fields?: Record<string, unknown>;
45
+ requestId?: string;
46
+ traceId?: string;
47
+ spanId?: string;
48
+ /** Populated on HTTP/MCP access log events for {@link CliLogConfig.enrich}. */
49
+ http?: LogEnrichContext["http"];
50
+ }
51
+
52
+ /** Options for {@link formatEcsLine}. */
53
+ export interface FormatEcsLineOpts {
54
+ service: EcsServiceFields;
55
+ event: EcsLogEvent;
56
+ /** Additive fields merged after the ECS baseline (cannot override protected keys). */
57
+ enrich?: (ctx: LogEnrichContext) => Record<string, unknown>;
22
58
  }
23
59
 
60
+ /** ECS baseline keys that {@link FormatEcsLineOpts.enrich} must not override. */
61
+ export const PROTECTED_ECS_KEYS = new Set([
62
+ "@timestamp",
63
+ "log.level",
64
+ "message",
65
+ "ecs.version",
66
+ "service.name",
67
+ "service.version",
68
+ ]);
69
+
24
70
  function errorFields(error: unknown): Record<string, unknown> {
25
71
  if (error instanceof Error) {
26
72
  return {
@@ -32,28 +78,77 @@ function errorFields(error: unknown): Record<string, unknown> {
32
78
  return { "error.message": String(error) };
33
79
  }
34
80
 
35
- /** Formats one ECS-compatible JSON log line (newline omitted). */
36
- export function formatEcsLine(service: EcsServiceFields, event: EcsLogEvent): string {
81
+ function buildEnrichContext(service: EcsServiceFields, event: EcsLogEvent): LogEnrichContext {
82
+ return {
83
+ level: event.level,
84
+ message: event.message,
85
+ action: event.action,
86
+ requestId: event.requestId,
87
+ traceId: event.traceId,
88
+ spanId: event.spanId,
89
+ labels: event.labels,
90
+ error: event.error,
91
+ service,
92
+ http: event.http,
93
+ };
94
+ }
95
+
96
+ /** Merges enrich output without overriding protected or existing ECS baseline keys. */
97
+ export function mergeEnrichFields(line: Record<string, unknown>, enrich: Record<string, unknown> | undefined): void {
98
+ if (!enrich) {
99
+ return;
100
+ }
101
+ for (const [key, value] of Object.entries(enrich)) {
102
+ if (PROTECTED_ECS_KEYS.has(key) || key in line) {
103
+ continue;
104
+ }
105
+ line[key] = value;
106
+ }
107
+ }
108
+
109
+ /** Formats one ECS Logging–compatible JSON log line (newline omitted). */
110
+ export function formatEcsLine(opts: FormatEcsLineOpts): string;
111
+ /** @deprecated Pass {@link FormatEcsLineOpts} instead. */
112
+ export function formatEcsLine(service: EcsServiceFields, event: EcsLogEvent): string;
113
+ export function formatEcsLine(serviceOrOpts: EcsServiceFields | FormatEcsLineOpts, event?: EcsLogEvent): string {
114
+ const opts: FormatEcsLineOpts =
115
+ event !== undefined ? { service: serviceOrOpts as EcsServiceFields, event } : (serviceOrOpts as FormatEcsLineOpts);
116
+ const { service, event: ev } = opts;
117
+
37
118
  const line: Record<string, unknown> = {
38
119
  "@timestamp": new Date().toISOString(),
39
- "log.level": event.level,
40
- message: event.message,
120
+ "log.level": ev.level,
121
+ message: ev.message,
122
+ "ecs.version": ECS_VERSION,
41
123
  "service.name": service.name,
42
124
  "service.version": service.version,
43
125
  };
44
- if (event.action) {
45
- line["event.action"] = event.action;
126
+
127
+ if (ev.action) {
128
+ line["event.action"] = ev.action;
46
129
  }
47
- if (event.labels) {
48
- for (const [key, value] of Object.entries(event.labels)) {
49
- line[`labels.${key}`] = value;
50
- }
130
+ if (ev.traceId) {
131
+ line["trace.id"] = ev.traceId;
132
+ }
133
+ if (ev.spanId) {
134
+ line["span.id"] = ev.spanId;
51
135
  }
52
- if (event.fields) {
53
- Object.assign(line, event.fields);
136
+ if (ev.labels && Object.keys(ev.labels).length > 0) {
137
+ line.labels = { ...ev.labels };
54
138
  }
55
- if (event.error !== undefined) {
56
- Object.assign(line, errorFields(event.error));
139
+ if (ev.fields) {
140
+ Object.assign(line, ev.fields);
57
141
  }
142
+ if (ev.error !== undefined) {
143
+ Object.assign(line, errorFields(ev.error));
144
+ }
145
+
146
+ mergeEnrichFields(line, opts.enrich?.(buildEnrichContext(service, ev)));
147
+
58
148
  return JSON.stringify(line);
59
149
  }
150
+
151
+ /** Converts HTTP access duration from milliseconds to ECS `event.duration` nanoseconds. */
152
+ export function durationMsToEcsNanos(durationMs: number): number {
153
+ return durationMs * 1_000_000;
154
+ }
@@ -0,0 +1,95 @@
1
+ /*
2
+ Unit tests for LogEmitter enrich and serialize hooks.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { CliProgram } from "../core/types.ts";
7
+ import type { LogEnrichContext } from "./ecs.ts";
8
+ import { LogEmitter } from "./emitter.ts";
9
+
10
+ const program = {
11
+ key: "testapp",
12
+ version: "1.0.0",
13
+ description: "test",
14
+ handler: () => {},
15
+ } satisfies CliProgram;
16
+
17
+ function captureStderr(run: () => void): string {
18
+ const chunks: string[] = [];
19
+ const original = process.stderr.write.bind(process.stderr);
20
+ process.stderr.write = ((chunk: string | Uint8Array) => {
21
+ chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
22
+ return true;
23
+ }) as typeof process.stderr.write;
24
+ try {
25
+ run();
26
+ } finally {
27
+ process.stderr.write = original;
28
+ }
29
+ return chunks.join("");
30
+ }
31
+
32
+ describe("LogEmitter hooks", () => {
33
+ test("serialize bypasses built-in ECS formatter", () => {
34
+ const serialize = (ctx: LogEnrichContext) => JSON.stringify({ custom: true, msg: ctx.message });
35
+ const emitter = new LogEmitter({
36
+ program,
37
+ resolved: { format: "json", access: true, errors: true, dev: false, serialize },
38
+ });
39
+ const out = captureStderr(() => emitter.emit({ level: "info", message: "hello" }));
40
+ const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
41
+ expect(parsed.custom).toBe(true);
42
+ expect(parsed.msg).toBe("hello");
43
+ expect(parsed["ecs.version"]).toBeUndefined();
44
+ });
45
+
46
+ test("enrich adds fields to access logs", () => {
47
+ const enrich = () => ({ "labels.team": "payments" });
48
+ const emitter = new LogEmitter({
49
+ program,
50
+ resolved: { format: "json", access: true, errors: true, dev: false, enrich },
51
+ });
52
+ const out = captureStderr(() =>
53
+ emitter.emitAccess({
54
+ method: "GET",
55
+ path: "/workspaces",
56
+ status: 200,
57
+ durationMs: 12,
58
+ requestId: "req-1",
59
+ clientIp: "127.0.0.1",
60
+ }),
61
+ );
62
+ const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
63
+ expect(parsed["http.request.method"]).toBe("GET");
64
+ expect(parsed["url.path"]).toBe("/workspaces");
65
+ expect(parsed["labels.team"]).toBe("payments");
66
+ expect(parsed["ecs.version"]).toBeDefined();
67
+ });
68
+
69
+ test("enrich receives http on access logs", () => {
70
+ let seen: LogEnrichContext | undefined;
71
+ const enrich = (ctx: LogEnrichContext) => {
72
+ seen = ctx;
73
+ return {};
74
+ };
75
+ const emitter = new LogEmitter({
76
+ program,
77
+ resolved: { format: "json", access: true, errors: true, dev: false, enrich },
78
+ });
79
+ captureStderr(() =>
80
+ emitter.emitAccess({
81
+ method: "GET",
82
+ path: "/workspaces",
83
+ status: 200,
84
+ durationMs: 12,
85
+ }),
86
+ );
87
+ expect(seen?.http).toEqual({
88
+ method: "GET",
89
+ path: "/workspaces",
90
+ status: 200,
91
+ durationMs: 12,
92
+ clientIp: undefined,
93
+ });
94
+ });
95
+ });
@@ -4,8 +4,9 @@ Framework log emitter: ECS json or human text to stderr with optional file tee.
4
4
 
5
5
  import { appendFileSync, mkdirSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
- import type { CliProgram } from "../core/types.ts";
8
- import { type EcsLogEvent, type EcsLogLevel, formatEcsLine } from "./ecs.ts";
7
+ import type { CliLogConfig, CliProgram } from "../core/types.ts";
8
+ import type { LogEnrichContext } from "./ecs.ts";
9
+ import { durationMsToEcsNanos, type EcsLogEvent, type EcsLogLevel, formatEcsLine } from "./ecs.ts";
9
10
 
10
11
  /** Resolved logging options for a server or invoke session. */
11
12
  export interface ResolvedLogConfig {
@@ -14,6 +15,8 @@ export interface ResolvedLogConfig {
14
15
  access: boolean;
15
16
  errors: boolean;
16
17
  dev: boolean;
18
+ enrich?: CliLogConfig["enrich"];
19
+ serialize?: CliLogConfig["serialize"];
17
20
  }
18
21
 
19
22
  /** Options for {@link LogEmitter}. */
@@ -38,6 +41,8 @@ export function resolveLogConfig(program: CliProgram, overrides: Partial<Resolve
38
41
  access: overrides.access ?? log?.access ?? true,
39
42
  errors: overrides.errors ?? log?.errors ?? true,
40
43
  dev: overrides.dev ?? false,
44
+ enrich: log?.enrich,
45
+ serialize: log?.serialize,
41
46
  };
42
47
  }
43
48
 
@@ -80,6 +85,8 @@ export class LogEmitter {
80
85
  durationMs: number;
81
86
  requestId?: string;
82
87
  clientIp?: string;
88
+ traceId?: string;
89
+ spanId?: string;
83
90
  }): void {
84
91
  if (!this.resolved.access) {
85
92
  return;
@@ -91,17 +98,42 @@ export class LogEmitter {
91
98
  this.appendFile(line);
92
99
  return;
93
100
  }
101
+
102
+ const isHttp = fields.method !== "MCP";
103
+ const action = isHttp ? "http.access" : "mcp.access";
104
+ const httpFields: Record<string, unknown> = isHttp
105
+ ? {
106
+ "http.request.method": fields.method,
107
+ "url.path": fields.path,
108
+ "http.response.status_code": fields.status,
109
+ "event.duration": durationMsToEcsNanos(fields.durationMs),
110
+ }
111
+ : {
112
+ "event.duration": durationMsToEcsNanos(fields.durationMs),
113
+ };
114
+
115
+ if (fields.clientIp && fields.clientIp !== "unknown") {
116
+ httpFields["client.ip"] = fields.clientIp;
117
+ }
118
+
94
119
  this.emit({
95
120
  level: "info",
96
121
  message: `${fields.method} ${fields.path}`,
97
- action: "http.access",
122
+ action,
123
+ requestId: fields.requestId,
124
+ traceId: fields.traceId,
125
+ spanId: fields.spanId,
126
+ fields: httpFields,
98
127
  labels: {
99
128
  ...(fields.requestId ? { request_id: fields.requestId } : {}),
100
- ...(fields.clientIp ? { client_ip: fields.clientIp } : {}),
101
- http_method: fields.method,
102
- http_path: fields.path,
103
- http_status: fields.status,
104
- duration_ms: fields.durationMs,
129
+ ...(isHttp ? {} : { rpc_method: fields.path }),
130
+ },
131
+ http: {
132
+ method: fields.method,
133
+ path: fields.path,
134
+ status: fields.status,
135
+ durationMs: fields.durationMs,
136
+ clientIp: fields.clientIp,
105
137
  },
106
138
  });
107
139
  }
@@ -111,7 +143,12 @@ export class LogEmitter {
111
143
  failureKind: string,
112
144
  error: unknown,
113
145
  clientMessage: string,
114
- labels?: Record<string, string | number | boolean>,
146
+ meta?: {
147
+ labels?: Record<string, string | number | boolean>;
148
+ requestId?: string;
149
+ traceId?: string;
150
+ spanId?: string;
151
+ },
115
152
  ): void {
116
153
  if (!this.resolved.errors) {
117
154
  return;
@@ -120,7 +157,10 @@ export class LogEmitter {
120
157
  level: failureKind === "unexpected" ? "error" : "warn",
121
158
  message: clientMessage,
122
159
  action: "invoke.error",
123
- labels: { failure_kind: failureKind, ...labels },
160
+ labels: { failure_kind: failureKind, ...meta?.labels },
161
+ requestId: meta?.requestId,
162
+ traceId: meta?.traceId,
163
+ spanId: meta?.spanId,
124
164
  error,
125
165
  });
126
166
  if (this.resolved.dev && error instanceof Error && error.stack) {
@@ -133,7 +173,30 @@ export class LogEmitter {
133
173
  if (this.resolved.format === "text") {
134
174
  return this.formatTextLine(event);
135
175
  }
136
- return formatEcsLine(this.service, event);
176
+ const enrichCtx = this.buildEnrichContext(event);
177
+ if (this.resolved.serialize) {
178
+ return this.resolved.serialize(enrichCtx);
179
+ }
180
+ return formatEcsLine({
181
+ service: this.service,
182
+ event,
183
+ enrich: this.resolved.enrich,
184
+ });
185
+ }
186
+
187
+ private buildEnrichContext(event: EcsLogEvent): LogEnrichContext {
188
+ return {
189
+ level: event.level,
190
+ message: event.message,
191
+ action: event.action,
192
+ requestId: event.requestId,
193
+ traceId: event.traceId,
194
+ spanId: event.spanId,
195
+ labels: event.labels,
196
+ error: event.error,
197
+ service: this.service,
198
+ http: event.http,
199
+ };
137
200
  }
138
201
 
139
202
  private formatTextLine(event: EcsLogEvent): string {
@@ -0,0 +1,60 @@
1
+ /*
2
+ Unit tests for W3C Trace Context parsing and formatting.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { extractTraceContext, formatTraceparent, parseTraceparent, randomSpanId } from "./trace.ts";
7
+
8
+ describe("parseTraceparent", () => {
9
+ test("parses valid traceparent", () => {
10
+ const parsed = parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
11
+ expect(parsed).toEqual({
12
+ traceId: "0af7651916cd43dd8448eb211c80319c",
13
+ spanId: "b7ad6b7169203331",
14
+ sampled: true,
15
+ });
16
+ });
17
+
18
+ test("returns undefined for invalid header", () => {
19
+ expect(parseTraceparent("not-a-traceparent")).toBeUndefined();
20
+ expect(parseTraceparent("")).toBeUndefined();
21
+ });
22
+ });
23
+
24
+ describe("formatTraceparent", () => {
25
+ test("formats traceparent with sampled flag", () => {
26
+ expect(
27
+ formatTraceparent({
28
+ traceId: "0af7651916cd43dd8448eb211c80319c",
29
+ spanId: "b7ad6b7169203331",
30
+ sampled: true,
31
+ }),
32
+ ).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
33
+ });
34
+ });
35
+
36
+ describe("extractTraceContext", () => {
37
+ test("creates a new span id for the server hop", () => {
38
+ const request = new Request("http://localhost/workspaces", {
39
+ headers: {
40
+ traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
41
+ },
42
+ });
43
+ const ctx = extractTraceContext(request);
44
+ expect(ctx?.traceId).toBe("0af7651916cd43dd8448eb211c80319c");
45
+ expect(ctx?.parentSpanId).toBe("b7ad6b7169203331");
46
+ expect(ctx?.spanId).toHaveLength(16);
47
+ expect(ctx?.spanId).not.toBe("b7ad6b7169203331");
48
+ });
49
+
50
+ test("returns undefined when header is missing", () => {
51
+ const request = new Request("http://localhost/workspaces");
52
+ expect(extractTraceContext(request)).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("randomSpanId", () => {
57
+ test("returns 16 hex chars", () => {
58
+ expect(randomSpanId()).toMatch(/^[0-9a-f]{16}$/);
59
+ });
60
+ });
@@ -0,0 +1,62 @@
1
+ /*
2
+ W3C Trace Context (traceparent) parsing and formatting for HTTP request correlation.
3
+ */
4
+
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ /** Parsed trace context for one HTTP request hop. */
8
+ export interface IncomingTraceContext {
9
+ /** 128-bit trace id (32 lowercase hex chars). */
10
+ traceId: string;
11
+ /** Parent span id from the incoming traceparent (caller's span). */
12
+ parentSpanId: string;
13
+ /** New span id for this server hop (16 lowercase hex chars). */
14
+ spanId: string;
15
+ /** Whether the trace is marked sampled in trace-flags. */
16
+ sampled: boolean;
17
+ }
18
+
19
+ const TRACEPARENT_RE = /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})$/i;
20
+
21
+ /** Generates a new 64-bit span id (16 hex chars). */
22
+ export function randomSpanId(): string {
23
+ return randomBytes(8).toString("hex");
24
+ }
25
+
26
+ /** Parses a W3C `traceparent` header value, or returns undefined when invalid. */
27
+ export function parseTraceparent(header: string): { traceId: string; spanId: string; sampled: boolean } | undefined {
28
+ const match = header.trim().match(TRACEPARENT_RE);
29
+ if (!match) {
30
+ return undefined;
31
+ }
32
+ const [, traceId, spanId, flags] = match;
33
+ if (!traceId || !spanId || !flags) {
34
+ return undefined;
35
+ }
36
+ const flagByte = Number.parseInt(flags, 16);
37
+ return {
38
+ traceId: traceId.toLowerCase(),
39
+ spanId: spanId.toLowerCase(),
40
+ sampled: (flagByte & 0x01) === 0x01,
41
+ };
42
+ }
43
+
44
+ /** Extracts trace context from an HTTP request, or undefined when no valid traceparent is present. */
45
+ export function extractTraceContext(request: Request): IncomingTraceContext | undefined {
46
+ const parsed = parseTraceparent(request.headers.get("traceparent") ?? "");
47
+ if (!parsed) {
48
+ return undefined;
49
+ }
50
+ return {
51
+ traceId: parsed.traceId,
52
+ parentSpanId: parsed.spanId,
53
+ spanId: randomSpanId(),
54
+ sampled: parsed.sampled,
55
+ };
56
+ }
57
+
58
+ /** Formats a W3C `traceparent` header value for the current hop. */
59
+ export function formatTraceparent(ctx: { traceId: string; spanId: string; sampled?: boolean }): string {
60
+ const flags = ctx.sampled === false ? "00" : "01";
61
+ return `00-${ctx.traceId}-${ctx.spanId}-${flags}`;
62
+ }
@@ -195,7 +195,7 @@ export class Cli {
195
195
  invocation?: CliInvocation;
196
196
  toolArgs?: Record<string, unknown>;
197
197
  requestId?: string;
198
- http?: { request: Request; clientIp: string; requestId: string };
198
+ http?: { request: Request; clientIp: string; requestId: string; traceId?: string; spanId?: string };
199
199
  mcp?: { rpcMethod: string; toolName?: string; requestId: string };
200
200
  },
201
201
  ): Promise<CliInvokeResult> {