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.
- package/CHANGELOG.md +23 -1
- package/README.md +160 -68
- package/docs/README.md +1 -0
- package/docs/cli-program.md +2 -0
- package/docs/decisions.md +81 -25
- package/docs/developing.md +1 -1
- package/docs/distribution-homebrew.md +117 -104
- package/docs/http-server.md +3 -1
- package/docs/logging.md +149 -0
- package/examples/full-example/docs/cli-schema.json +18 -18
- package/examples/full-example/docs/cli.md +18 -18
- package/examples/full-example/docs/http.md +13 -1
- package/examples/full-example/justfile +20 -0
- package/index.d.ts +84 -14
- package/package.json +1 -1
- package/src/builtins/http.ts +1 -1
- package/src/builtins/mcp.ts +1 -1
- package/src/core/types.ts +17 -3
- package/src/docs/http-guide.ts +11 -1
- package/src/docs/save.ts +1 -1
- package/src/headless/tool-call.ts +1 -1
- package/src/hooks/run.ts +7 -2
- package/src/http/server.ts +19 -1
- package/src/index.ts +2 -0
- package/src/log/ecs.test.ts +68 -2
- package/src/log/ecs.ts +109 -14
- package/src/log/emitter.test.ts +95 -0
- package/src/log/emitter.ts +74 -11
- package/src/log/trace.test.ts +60 -0
- package/src/log/trace.ts +62 -0
- package/src/runtime/cli.ts +1 -1
package/src/log/ecs.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
36
|
-
|
|
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":
|
|
40
|
-
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
|
-
|
|
45
|
-
|
|
126
|
+
|
|
127
|
+
if (ev.action) {
|
|
128
|
+
line["event.action"] = ev.action;
|
|
46
129
|
}
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
53
|
-
|
|
136
|
+
if (ev.labels && Object.keys(ev.labels).length > 0) {
|
|
137
|
+
line.labels = { ...ev.labels };
|
|
54
138
|
}
|
|
55
|
-
if (
|
|
56
|
-
Object.assign(line,
|
|
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
|
+
});
|
package/src/log/emitter.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
...(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/log/trace.ts
ADDED
|
@@ -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
|
+
}
|
package/src/runtime/cli.ts
CHANGED
|
@@ -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> {
|