argsbarg 6.1.8 → 6.1.9
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 +16 -1
- package/docs/README.md +1 -0
- package/docs/cli-program.md +2 -0
- package/docs/decisions.md +20 -1
- 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 +10 -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 +10 -0
- 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/index.d.ts
CHANGED
|
@@ -113,6 +113,59 @@ export declare class CliContext {
|
|
|
113
113
|
private _posMap;
|
|
114
114
|
private _positionalMap;
|
|
115
115
|
}
|
|
116
|
+
/** ECS version string written to every JSON log line. */
|
|
117
|
+
export declare const ECS_VERSION = "8.11.0";
|
|
118
|
+
/** Severity label for ECS `log.level`. */
|
|
119
|
+
export type EcsLogLevel = "debug" | "info" | "warn" | "error";
|
|
120
|
+
/** Fields merged into every ECS log line. */
|
|
121
|
+
export interface EcsServiceFields {
|
|
122
|
+
name: string;
|
|
123
|
+
version: string;
|
|
124
|
+
}
|
|
125
|
+
/** Context for {@link CliLogConfig.enrich} and {@link CliLogConfig.serialize}. */
|
|
126
|
+
export interface LogEnrichContext {
|
|
127
|
+
level: EcsLogLevel;
|
|
128
|
+
message: string;
|
|
129
|
+
action?: string;
|
|
130
|
+
requestId?: string;
|
|
131
|
+
traceId?: string;
|
|
132
|
+
spanId?: string;
|
|
133
|
+
labels?: Record<string, string | number | boolean>;
|
|
134
|
+
error?: unknown;
|
|
135
|
+
service: EcsServiceFields;
|
|
136
|
+
http?: {
|
|
137
|
+
method: string;
|
|
138
|
+
path: string;
|
|
139
|
+
status: number;
|
|
140
|
+
durationMs: number;
|
|
141
|
+
clientIp?: string;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Input for one ECS log event. */
|
|
145
|
+
export interface EcsLogEvent {
|
|
146
|
+
level: EcsLogLevel;
|
|
147
|
+
message: string;
|
|
148
|
+
action?: string;
|
|
149
|
+
labels?: Record<string, string | number | boolean>;
|
|
150
|
+
error?: unknown;
|
|
151
|
+
fields?: Record<string, unknown>;
|
|
152
|
+
requestId?: string;
|
|
153
|
+
traceId?: string;
|
|
154
|
+
spanId?: string;
|
|
155
|
+
/** Populated on HTTP/MCP access log events for {@link CliLogConfig.enrich}. */
|
|
156
|
+
http?: LogEnrichContext["http"];
|
|
157
|
+
}
|
|
158
|
+
/** Options for {@link formatEcsLine}. */
|
|
159
|
+
export interface FormatEcsLineOpts {
|
|
160
|
+
service: EcsServiceFields;
|
|
161
|
+
event: EcsLogEvent;
|
|
162
|
+
/** Additive fields merged after the ECS baseline (cannot override protected keys). */
|
|
163
|
+
enrich?: (ctx: LogEnrichContext) => Record<string, unknown>;
|
|
164
|
+
}
|
|
165
|
+
/** Formats one ECS Logging–compatible JSON log line (newline omitted). */
|
|
166
|
+
export declare function formatEcsLine(opts: FormatEcsLineOpts): string;
|
|
167
|
+
/** @deprecated Pass {@link FormatEcsLineOpts} instead. */
|
|
168
|
+
export declare function formatEcsLine(service: EcsServiceFields, event: EcsLogEvent): string;
|
|
116
169
|
/**
|
|
117
170
|
* How a leaf handler was dispatched.
|
|
118
171
|
*/
|
|
@@ -324,6 +377,10 @@ export interface CliHttpWireContext {
|
|
|
324
377
|
clientIp: string;
|
|
325
378
|
path: string;
|
|
326
379
|
method: string;
|
|
380
|
+
/** W3C trace id when `traceparent` is present on the request. */
|
|
381
|
+
traceId?: string;
|
|
382
|
+
/** Span id for this server hop when `traceparent` is present. */
|
|
383
|
+
spanId?: string;
|
|
327
384
|
}
|
|
328
385
|
/** Wire-level MCP hooks on JSON-RPC messages (observe-only). */
|
|
329
386
|
export interface CliMcpWireHooks {
|
|
@@ -672,6 +729,8 @@ export interface InvokeHookContext {
|
|
|
672
729
|
request: Request;
|
|
673
730
|
clientIp: string;
|
|
674
731
|
requestId: string;
|
|
732
|
+
traceId?: string;
|
|
733
|
+
spanId?: string;
|
|
675
734
|
};
|
|
676
735
|
mcp?: {
|
|
677
736
|
rpcMethod: string;
|
|
@@ -717,9 +776,9 @@ export interface ReadinessContext {
|
|
|
717
776
|
appConfig: AnyAppConfigSnapshot;
|
|
718
777
|
runtime: ServerRuntime;
|
|
719
778
|
}
|
|
720
|
-
/** Framework logging defaults (ECS json or human text on stderr). */
|
|
779
|
+
/** Framework logging defaults (ECS Logging json or human text on stderr). */
|
|
721
780
|
export interface CliLogConfig {
|
|
722
|
-
/** `json` = ECS lines; `text` = human stderr lines. Default: `json`. */
|
|
781
|
+
/** `json` = ECS Logging lines; `text` = human stderr lines. Default: `json`. */
|
|
723
782
|
format?: "json" | "text";
|
|
724
783
|
/** Tee stderr + append; relative paths resolve under the app config dir. */
|
|
725
784
|
file?: string;
|
|
@@ -727,6 +786,16 @@ export interface CliLogConfig {
|
|
|
727
786
|
access?: boolean;
|
|
728
787
|
/** Emit error events after the hook pipeline. Default: true. */
|
|
729
788
|
errors?: boolean;
|
|
789
|
+
/**
|
|
790
|
+
* Add non-standard fields to each JSON log line after the ECS baseline.
|
|
791
|
+
* Cannot override `@timestamp`, `log.level`, `message`, `ecs.version`, or service fields.
|
|
792
|
+
*/
|
|
793
|
+
enrich?: (ctx: LogEnrichContext) => Record<string, unknown>;
|
|
794
|
+
/**
|
|
795
|
+
* Full control over JSON log line serialization. When set, bypasses the built-in ECS formatter.
|
|
796
|
+
* The consumer owns the entire line (including newline omission).
|
|
797
|
+
*/
|
|
798
|
+
serialize?: (ctx: LogEnrichContext) => string;
|
|
730
799
|
}
|
|
731
800
|
/**
|
|
732
801
|
* Program root passed to {@link Cli}.
|
|
@@ -870,17 +939,6 @@ export interface CliSchemaRootExport extends CliSchemaExport {
|
|
|
870
939
|
/** Program-level error JSON Schema when configured on `httpServer.errors` or `mcpServer.errors`. */
|
|
871
940
|
errorSchema?: Record<string, unknown>;
|
|
872
941
|
}
|
|
873
|
-
/** Severity label for ECS `log.level`. */
|
|
874
|
-
export type EcsLogLevel = "debug" | "info" | "warn" | "error";
|
|
875
|
-
/** Input for one ECS log event. */
|
|
876
|
-
export interface EcsLogEvent {
|
|
877
|
-
level: EcsLogLevel;
|
|
878
|
-
message: string;
|
|
879
|
-
action?: string;
|
|
880
|
-
labels?: Record<string, string | number | boolean>;
|
|
881
|
-
error?: unknown;
|
|
882
|
-
fields?: Record<string, unknown>;
|
|
883
|
-
}
|
|
884
942
|
/** Resolved logging options for a server or invoke session. */
|
|
885
943
|
export interface ResolvedLogConfig {
|
|
886
944
|
format: "json" | "text";
|
|
@@ -888,6 +946,8 @@ export interface ResolvedLogConfig {
|
|
|
888
946
|
access: boolean;
|
|
889
947
|
errors: boolean;
|
|
890
948
|
dev: boolean;
|
|
949
|
+
enrich?: CliLogConfig["enrich"];
|
|
950
|
+
serialize?: CliLogConfig["serialize"];
|
|
891
951
|
}
|
|
892
952
|
/** Options for {@link LogEmitter}. */
|
|
893
953
|
export interface LogEmitterOpts {
|
|
@@ -911,10 +971,18 @@ declare class LogEmitter {
|
|
|
911
971
|
durationMs: number;
|
|
912
972
|
requestId?: string;
|
|
913
973
|
clientIp?: string;
|
|
974
|
+
traceId?: string;
|
|
975
|
+
spanId?: string;
|
|
914
976
|
}): void;
|
|
915
977
|
/** Error log after the hook pipeline (real stack always included). */
|
|
916
|
-
emitInvokeError(failureKind: string, error: unknown, clientMessage: string,
|
|
978
|
+
emitInvokeError(failureKind: string, error: unknown, clientMessage: string, meta?: {
|
|
979
|
+
labels?: Record<string, string | number | boolean>;
|
|
980
|
+
requestId?: string;
|
|
981
|
+
traceId?: string;
|
|
982
|
+
spanId?: string;
|
|
983
|
+
}): void;
|
|
917
984
|
private formatLine;
|
|
985
|
+
private buildEnrichContext;
|
|
918
986
|
private formatTextLine;
|
|
919
987
|
private appendFile;
|
|
920
988
|
}
|
|
@@ -996,6 +1064,8 @@ export declare class Cli {
|
|
|
996
1064
|
request: Request;
|
|
997
1065
|
clientIp: string;
|
|
998
1066
|
requestId: string;
|
|
1067
|
+
traceId?: string;
|
|
1068
|
+
spanId?: string;
|
|
999
1069
|
};
|
|
1000
1070
|
mcp?: {
|
|
1001
1071
|
rpcMethod: string;
|
package/package.json
CHANGED
package/src/builtins/http.ts
CHANGED
|
@@ -18,7 +18,7 @@ const HTTP_SERVE_OPTIONS: CliOption[] = [
|
|
|
18
18
|
{ name: "obscure-errors", description: "Hide unexpected errors from clients.", kind: CliOptionKind.Presence },
|
|
19
19
|
{
|
|
20
20
|
name: "log-format",
|
|
21
|
-
description: "Log format: json (ECS) or text.",
|
|
21
|
+
description: "Log format: json (ECS Logging) or text.",
|
|
22
22
|
kind: CliOptionKind.Enum,
|
|
23
23
|
choices: ["json", "text"],
|
|
24
24
|
},
|
package/src/builtins/mcp.ts
CHANGED
|
@@ -13,7 +13,7 @@ const MCP_SERVE_OPTIONS: CliOption[] = [
|
|
|
13
13
|
{ name: "obscure-errors", description: "Hide unexpected errors from clients.", kind: CliOptionKind.Presence },
|
|
14
14
|
{
|
|
15
15
|
name: "log-format",
|
|
16
|
-
description: "Log format: json (ECS) or text.",
|
|
16
|
+
description: "Log format: json (ECS Logging) or text.",
|
|
17
17
|
kind: CliOptionKind.Enum,
|
|
18
18
|
choices: ["json", "text"],
|
|
19
19
|
},
|
package/src/core/types.ts
CHANGED
|
@@ -219,6 +219,10 @@ export interface CliHttpWireContext {
|
|
|
219
219
|
clientIp: string;
|
|
220
220
|
path: string;
|
|
221
221
|
method: string;
|
|
222
|
+
/** W3C trace id when `traceparent` is present on the request. */
|
|
223
|
+
traceId?: string;
|
|
224
|
+
/** Span id for this server hop when `traceparent` is present. */
|
|
225
|
+
spanId?: string;
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
/** Wire-level MCP hooks on JSON-RPC messages (observe-only). */
|
|
@@ -596,7 +600,7 @@ export interface InvokeHookContext {
|
|
|
596
600
|
locals: CliLocals;
|
|
597
601
|
runtime?: ServerRuntime;
|
|
598
602
|
appConfig: AnyAppConfigSnapshot;
|
|
599
|
-
http?: { request: Request; clientIp: string; requestId: string };
|
|
603
|
+
http?: { request: Request; clientIp: string; requestId: string; traceId?: string; spanId?: string };
|
|
600
604
|
mcp?: { rpcMethod: string; toolName?: string; requestId: string };
|
|
601
605
|
}
|
|
602
606
|
|
|
@@ -641,9 +645,9 @@ export interface ReadinessContext {
|
|
|
641
645
|
runtime: ServerRuntime;
|
|
642
646
|
}
|
|
643
647
|
|
|
644
|
-
/** Framework logging defaults (ECS json or human text on stderr). */
|
|
648
|
+
/** Framework logging defaults (ECS Logging json or human text on stderr). */
|
|
645
649
|
export interface CliLogConfig {
|
|
646
|
-
/** `json` = ECS lines; `text` = human stderr lines. Default: `json`. */
|
|
650
|
+
/** `json` = ECS Logging lines; `text` = human stderr lines. Default: `json`. */
|
|
647
651
|
format?: "json" | "text";
|
|
648
652
|
/** Tee stderr + append; relative paths resolve under the app config dir. */
|
|
649
653
|
file?: string;
|
|
@@ -651,6 +655,16 @@ export interface CliLogConfig {
|
|
|
651
655
|
access?: boolean;
|
|
652
656
|
/** Emit error events after the hook pipeline. Default: true. */
|
|
653
657
|
errors?: boolean;
|
|
658
|
+
/**
|
|
659
|
+
* Add non-standard fields to each JSON log line after the ECS baseline.
|
|
660
|
+
* Cannot override `@timestamp`, `log.level`, `message`, `ecs.version`, or service fields.
|
|
661
|
+
*/
|
|
662
|
+
enrich?: (ctx: import("../log/ecs.ts").LogEnrichContext) => Record<string, unknown>;
|
|
663
|
+
/**
|
|
664
|
+
* Full control over JSON log line serialization. When set, bypasses the built-in ECS formatter.
|
|
665
|
+
* The consumer owns the entire line (including newline omission).
|
|
666
|
+
*/
|
|
667
|
+
serialize?: (ctx: import("../log/ecs.ts").LogEnrichContext) => string;
|
|
654
668
|
}
|
|
655
669
|
|
|
656
670
|
/**
|
package/src/docs/http-guide.ts
CHANGED
|
@@ -80,6 +80,16 @@ export function generateHttpGuide(root: CliProgram): string {
|
|
|
80
80
|
"",
|
|
81
81
|
'Errors use `{ "error": "..." }` with `400`, `404`, `503`, or `500`.',
|
|
82
82
|
"",
|
|
83
|
+
"## Logging",
|
|
84
|
+
"",
|
|
85
|
+
"Server logs go to **stderr** (one JSON object per line by default).",
|
|
86
|
+
"",
|
|
87
|
+
"- Configure with `program.log` on the program root",
|
|
88
|
+
"- **`enrich`** — add custom JSON fields on top of the default line",
|
|
89
|
+
"- **`serialize`** — replace the formatter and emit your own line shape",
|
|
90
|
+
"",
|
|
91
|
+
"See the argsbarg [logging guide](https://github.com/bdombro/bun-argsbarg/blob/main/docs/logging.md) for examples and the full `LogEnrichContext` shape.",
|
|
92
|
+
"",
|
|
83
93
|
];
|
|
84
94
|
|
|
85
95
|
if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
|
|
@@ -139,7 +139,7 @@ export async function executeHttpRouteCall(
|
|
|
139
139
|
pathParams: Record<string, string>,
|
|
140
140
|
query: Record<string, string>,
|
|
141
141
|
body: Record<string, unknown>,
|
|
142
|
-
http?: { request: Request; clientIp: string; requestId: string },
|
|
142
|
+
http?: { request: Request; clientIp: string; requestId: string; traceId?: string; spanId?: string },
|
|
143
143
|
): Promise<HeadlessToolCallResult> {
|
|
144
144
|
const argvResult = httpRequestToArgv(cli.program, route, pathParams, query, body);
|
|
145
145
|
if ("error" in argvResult) {
|
package/src/hooks/run.ts
CHANGED
|
@@ -134,8 +134,13 @@ export async function runErrorPipeline(
|
|
|
134
134
|
failureKind === "unexpected" && obscureUnexpected ? obscureUnexpectedClientMessage() : clientError.message;
|
|
135
135
|
|
|
136
136
|
emitter?.emitInvokeError(failureKind, err, displayMessage, {
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
labels: {
|
|
138
|
+
invocation: hookCtx.invocation,
|
|
139
|
+
path: hookCtx.path.join(" "),
|
|
140
|
+
},
|
|
141
|
+
requestId: hookCtx.locals.requestId,
|
|
142
|
+
traceId: hookCtx.http?.traceId,
|
|
143
|
+
spanId: hookCtx.http?.spanId,
|
|
139
144
|
});
|
|
140
145
|
|
|
141
146
|
return { failureKind, clientError, errorMsg: displayMessage };
|
package/src/http/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
headlessFailureToHttpResponse,
|
|
10
10
|
headlessSuccessToHttpResponse,
|
|
11
11
|
} from "../headless/tool-call.ts";
|
|
12
|
+
import { extractTraceContext, formatTraceparent } from "../log/trace.ts";
|
|
12
13
|
import type { Cli } from "../runtime/cli.ts";
|
|
13
14
|
import { leafHttpResponseDefaults } from "../runtime/exposure.ts";
|
|
14
15
|
import type { ResolvedHttpServeConfig } from "../server/overrides.ts";
|
|
@@ -70,12 +71,14 @@ export async function handleApiRequest(
|
|
|
70
71
|
const requestId = randomUUID();
|
|
71
72
|
const url = new URL(request.url);
|
|
72
73
|
const clientIp = resolveClientIp(request, trustProxy);
|
|
74
|
+
const trace = extractTraceContext(request);
|
|
73
75
|
const wireCtx: CliHttpWireContext = {
|
|
74
76
|
request,
|
|
75
77
|
requestId,
|
|
76
78
|
clientIp,
|
|
77
79
|
path: url.pathname,
|
|
78
80
|
method: request.method,
|
|
81
|
+
...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}),
|
|
79
82
|
};
|
|
80
83
|
const hooks = cli.server?.httpHooks ?? cli.program.httpServer?.hooks;
|
|
81
84
|
const emitter = cli.server?.emitter;
|
|
@@ -99,8 +102,22 @@ export async function handleApiRequest(
|
|
|
99
102
|
durationMs,
|
|
100
103
|
requestId,
|
|
101
104
|
clientIp,
|
|
105
|
+
traceId: trace?.traceId,
|
|
106
|
+
spanId: trace?.spanId,
|
|
107
|
+
});
|
|
108
|
+
if (!trace) {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
const headers = new Headers(response.headers);
|
|
112
|
+
headers.set(
|
|
113
|
+
"traceparent",
|
|
114
|
+
formatTraceparent({ traceId: trace.traceId, spanId: trace.spanId, sampled: trace.sampled }),
|
|
115
|
+
);
|
|
116
|
+
return new Response(response.body, {
|
|
117
|
+
status: response.status,
|
|
118
|
+
statusText: response.statusText,
|
|
119
|
+
headers,
|
|
102
120
|
});
|
|
103
|
-
return response;
|
|
104
121
|
};
|
|
105
122
|
|
|
106
123
|
await hooks?.onRequest?.(wireCtx);
|
|
@@ -168,6 +185,7 @@ export async function handleApiRequest(
|
|
|
168
185
|
request,
|
|
169
186
|
clientIp,
|
|
170
187
|
requestId,
|
|
188
|
+
...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}),
|
|
171
189
|
});
|
|
172
190
|
if (result.ok) {
|
|
173
191
|
const leafHttp = leafHttpResponseDefaults(match.route.leaf);
|
package/src/index.ts
CHANGED
|
@@ -79,6 +79,8 @@ export {
|
|
|
79
79
|
wantsExplicitJson,
|
|
80
80
|
} from "./headless/routing.ts";
|
|
81
81
|
export { generateOpenApi, openApiJson } from "./http/openapi.ts";
|
|
82
|
+
export type { EcsLogEvent, LogEnrichContext } from "./log/ecs.ts";
|
|
83
|
+
export { ECS_VERSION, formatEcsLine } from "./log/ecs.ts";
|
|
82
84
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
83
85
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
84
86
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "./runtime/cli.ts";
|
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
|
+
}
|