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/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, labels?: Record<string, string | number | boolean>): void;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.1.8",
3
+ "version": "6.1.9",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
@@ -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
  },
@@ -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
  /**
@@ -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
- invocation: hookCtx.invocation,
138
- path: hookCtx.path.join(" "),
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 };
@@ -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";
@@ -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
+ }