@devopsplaybook.io/otel-utils 1.8.0 → 1.9.0

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/README.md CHANGED
@@ -8,24 +8,26 @@ Utility library that simplifies OpenTelemetry integration for Node.js services.
8
8
  npm install @devopsplaybook.io/otel-utils
9
9
  ```
10
10
 
11
+ Requires Node.js 22 or newer (`engines` in `package.json`).
12
+
11
13
  ## Configuration
12
14
 
13
15
  All classes accept a `ConfigOTelInterface` object. The fields mirror environment variables in the consuming application (for example `common-utils`' `ConfigBase`); the library itself does not read `process.env`.
14
16
 
15
- | Field | Type | Default | Description |
16
- | --------------------------------------------------------- | ----------------------------------- | ---------- | -------------------------------------------------- |
17
- | `SERVICE_ID` | `string` | — | Service identifier (required, non-empty) |
18
- | `VERSION` | `string` | — | Service version (required, non-empty) |
19
- | `OPENTELEMETRY_COLLECTOR_HTTP_TRACES` | `string?` | — | OTLP HTTP endpoint for traces |
20
- | `OPENTELEMETRY_COLLECTOR_HTTP_METRICS` | `string?` | — | OTLP HTTP endpoint for metrics |
21
- | `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` | `string?` | — | OTLP HTTP endpoint for logs |
22
- | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS` | `number?` | `60` | Log export interval in seconds (clamped to ≥ 1) |
17
+ | Field | Type | Default | Description |
18
+ | --------------------------------------------------------- | ----------------------------------- | ---------- | --------------------------------------------------- |
19
+ | `SERVICE_ID` | `string` | — | Service identifier (required, non-empty) |
20
+ | `VERSION` | `string` | — | Service version (required, non-empty) |
21
+ | `OPENTELEMETRY_COLLECTOR_HTTP_TRACES` | `string?` | — | OTLP HTTP endpoint for traces |
22
+ | `OPENTELEMETRY_COLLECTOR_HTTP_METRICS` | `string?` | — | OTLP HTTP endpoint for metrics |
23
+ | `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` | `string?` | — | OTLP HTTP endpoint for logs |
24
+ | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS` | `number?` | `60` | Log export interval in seconds (clamped to ≥ 1) |
23
25
  | `OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS` | `number?` | `60` | Metrics export interval in seconds (clamped to ≥ 1) |
24
- | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE` | `number?` | `2048` | Max log records buffered before export |
25
- | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE` | `number?` | `512` | Max log records per export batch |
26
- | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS` | `number?` | `30000` | Log export timeout in milliseconds |
27
- | `OPENTELEMETRY_CONSOLE_LOG_MODE` | `"always" \| "fallback" \| "never"` | `"always"` | Console mirroring mode for `ModuleLogger` |
28
- | `OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER` | `string?` | — | Bearer token for collector auth |
26
+ | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE` | `number?` | `2048` | Max log records buffered before export |
27
+ | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE` | `number?` | `512` | Max log records per export batch |
28
+ | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS` | `number?` | `30000` | Log export timeout in milliseconds |
29
+ | `OPENTELEMETRY_CONSOLE_LOG_MODE` | `"always" \| "fallback" \| "never"` | `"always"` | Console mirroring mode for `ModuleLogger` |
30
+ | `OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER` | `string?` | — | Bearer token for collector auth |
29
31
 
30
32
  `SERVICE_ID` and `VERSION` must be non-empty strings. `StandardTracer`/`StandardMeter` constructors and `StandardLogger.initOTel` fail fast with `[otel-utils] SERVICE_ID must be a non-empty string` / `[otel-utils] VERSION must be a non-empty string` otherwise. A failed re-initialisation of `StandardLogger` keeps the previous provider in place.
31
33
 
@@ -57,8 +59,8 @@ span.end();
57
59
 
58
60
  **Methods:**
59
61
 
60
- - `startSpan(name, parentSpan?, options?)` — Creates a span. Span names are sanitized to `[a-zA-Z0-9-_/]`. When neither `parentSpan` nor `options` is given, the span is `SpanKind.INTERNAL` and carries `http.request_method=BACKEND` and a synthetic `http.route` attribute. Passing `options` (e.g. `{ kind: SpanKind.SERVER }`) forwards them to the OpenTelemetry SDK instead and skips those synthetic attributes, because the caller then describes the span itself.
61
- - `static updateHttpHeader(context, headers?)` — Injects W3C trace context into an HTTP headers object for propagation to downstream services.
62
+ - `startSpan(name, parentSpan?, options?)` — Creates a span and returns the SDK `Span` from `@opentelemetry/sdk-trace`. Span names are sanitized to `[a-zA-Z0-9-_/]`. `parentSpan` accepts any API `Span` (for example from `trace.getSpan(context)` or `trace.wrapSpanContext(...)`), so callers need no cast. When neither `parentSpan` nor `options` is given, the span is `SpanKind.INTERNAL` and carries `http.request.method=BACKEND` and a synthetic `http.route` attribute with the value `${serviceName}-${version}-${sanitizedSpanName}`. Passing a `parentSpan`, an `options` object (e.g. `{ kind: SpanKind.SERVER }`), or both forwards them to the OpenTelemetry SDK instead and skips those synthetic attributes, because the caller then describes the span itself. Generic HTTP dashboards that group by `http.request.method`/`http.route` count these spans as `BACKEND` requests with a synthetic route; this is intentional and consumed by otel-light.
63
+ - `static updateHttpHeader<T extends object = Record<string, string>>(context, headers?)` — Injects the W3C trace context of `context` into an HTTP headers object for propagation to downstream services, and returns that same object (a new one when omitted). Only `traceparent` and `tracestate` are injected: the propagation context is built from the span alone, so baggage and other active-context values are **not** propagated and an existing `baggage` header is left untouched.
62
64
  - `shutdown()` — Flushes buffered spans and shuts the tracer provider down. Idempotent.
63
65
 
64
66
  ### StandardLogger
@@ -86,12 +88,12 @@ moduleLog.info("Hello world");
86
88
 
87
89
  `BatchLogRecordProcessor` buffers records in memory and exports them periodically:
88
90
 
89
- | Option | Config field | Default |
90
- | -------------------- | ------------------------------------------------------ | ---------- |
91
- | `scheduledDelayMillis` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS` | 60000 ms |
92
- | `maxQueueSize` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE` | 2048 |
93
- | `maxExportBatchSize` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE` | 512 |
94
- | `exportTimeoutMillis` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS` | 30000 ms |
91
+ | Option | Config field | Default |
92
+ | ---------------------- | ------------------------------------------------------ | -------- |
93
+ | `scheduledDelayMillis` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS` | 60000 ms |
94
+ | `maxQueueSize` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE` | 2048 |
95
+ | `maxExportBatchSize` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE` | 512 |
96
+ | `exportTimeoutMillis` | `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS` | 30000 ms |
95
97
 
96
98
  A record waits up to `scheduledDelayMillis` (60 s by default) before it is exported; each export sends at most `maxExportBatchSize` (512) records with a 30 s timeout, and at most `maxQueueSize` (2048) records are buffered. Records that arrive while the queue is full are **dropped silently** — no warning or fallback is emitted. A crash or restart loses every record buffered since the last export; call `shutdown()` or use `registerShutdownHooks` (see [Graceful shutdown](#graceful-shutdown)) to flush them. Keep `maxExportBatchSize` ≤ `maxQueueSize` (the SDK clamps it otherwise).
97
99
 
@@ -109,11 +111,11 @@ await logger.initOTel({
109
111
 
110
112
  Scoped logger that prefixes all output with a module name. Every record carries a `log.module` attribute in addition to the `[module] message` body. Records are emitted to the OTel logger when one is available, and can additionally be mirrored to the console, controlled by `OPENTELEMETRY_CONSOLE_LOG_MODE`:
111
113
 
112
- | Mode | Behaviour |
113
- | ------------------- | --------------------------------------------------------------------------------------------- |
114
- | `always` (default) | Mirror every record to the console, whether or not OTel logging is configured. |
115
- | `fallback` | Mirror only when no OTel logger is configured (`OPENTELEMETRY_COLLECTOR_HTTP_LOGS` unset). |
116
- | `never` | Never write to the console; records only go to OTel. |
114
+ | Mode | Behaviour |
115
+ | ------------------ | ------------------------------------------------------------------------------------------ |
116
+ | `always` (default) | Mirror every record to the console, whether or not OTel logging is configured. |
117
+ | `fallback` | Mirror only when no OTel logger is configured (`OPENTELEMETRY_COLLECTOR_HTTP_LOGS` unset). |
118
+ | `never` | Never write to the console; records only go to OTel. |
117
119
 
118
120
  Mirroring is level-aware: `info` uses `console.log`, `warn` uses `console.warn` and `error` uses `console.error` (stderr), so stderr-based alerting sees warnings and errors. An invalid mode value logs one `console.warn` and falls back to `always`. With `never`, a `ModuleLogger` writes nothing to the console even when no OTel logger is configured.
119
121
 
@@ -272,8 +274,8 @@ All three signals share the same service identity (name + version) and resource
272
274
 
273
275
  ## Dependencies
274
276
 
275
- - `@opentelemetry/api` / `@opentelemetry/api-logs` — OpenTelemetry API interfaces
276
- - `@opentelemetry/sdk-trace-node` / `@opentelemetry/sdk-trace-base` — Tracing SDK
277
+ - `@opentelemetry/api` — OpenTelemetry API interfaces
278
+ - `@opentelemetry/sdk-trace` — Tracing SDK
277
279
  - `@opentelemetry/sdk-metrics` — Metrics SDK
278
280
  - `@opentelemetry/sdk-logs` — Logging SDK
279
281
  - `@opentelemetry/exporter-trace-otlp-http` / `@opentelemetry/exporter-metrics-otlp-http` / `@opentelemetry/exporter-logs-otlp-http` — OTLP HTTP exporters
@@ -282,8 +284,13 @@ All three signals share the same service identity (name + version) and resource
282
284
  - `@opentelemetry/core` — W3C trace context propagation
283
285
  - `@opentelemetry/resources` / `@opentelemetry/semantic-conventions` — Resource attributes
284
286
 
287
+ Dev-only `npm audit` advisories (such as those previously reported in the Jest toolchain via `js-yaml`/`sprintf-js`) are tracked by the regular dependency-update task, not addressed here. The current lockfile reports no vulnerabilities, with or without dev dependencies.
288
+
285
289
  ## Build
286
290
 
287
291
  ```bash
288
- npm run build # tsc → dist/
292
+ npm ci # install the locked dependencies
293
+ npm run build # tsc → dist/, then type-check the specs
294
+ npm run lint # oxlint + Prettier check
295
+ npm test # Jest unit tests with coverage
289
296
  ```
@@ -63,7 +63,9 @@ class StandardLogger {
63
63
  return this.logger;
64
64
  }
65
65
  createModuleLogger(moduleName) {
66
- return new ModuleLogger_1.ModuleLogger(moduleName, this, { consoleMode: this.consoleMode });
66
+ return new ModuleLogger_1.ModuleLogger(moduleName, this, {
67
+ consoleMode: this.consoleMode,
68
+ });
67
69
  }
68
70
  registerGlobalProvider() {
69
71
  if (globalLoggerProvider) {
@@ -26,8 +26,14 @@ export declare class StandardTracer {
26
26
  private shutdownPromise?;
27
27
  constructor(config: ConfigOTelInterface);
28
28
  shutdown(): Promise<void>;
29
- startSpan(name: string, parentSpan?: Span, options?: StandardTracerStartSpanOptions): Span;
30
- static updateHttpHeader(context: ApiSpan, headers?: {}): any;
29
+ startSpan(name: string, parentSpan?: ApiSpan, options?: StandardTracerStartSpanOptions): Span;
30
+ /**
31
+ * Injects the W3C trace context of `context` into `headers` and returns it.
32
+ *
33
+ * The propagation context is built from the span alone (ROOT_CONTEXT), so
34
+ * only traceparent/tracestate are injected; baggage is not propagated.
35
+ */
36
+ static updateHttpHeader<T extends object = Record<string, string>>(context: ApiSpan, headers?: T): T;
31
37
  private registerGlobalProvider;
32
38
  private doShutdown;
33
39
  }
@@ -68,11 +68,14 @@ class StandardTracer {
68
68
  span.setAttribute(semantic_conventions_1.ATTR_HTTP_ROUTE, `${this.serviceName}-${this.serviceVersion}-${sanitizedName}`);
69
69
  return span;
70
70
  }
71
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ /**
72
+ * Injects the W3C trace context of `context` into `headers` and returns it.
73
+ *
74
+ * The propagation context is built from the span alone (ROOT_CONTEXT), so
75
+ * only traceparent/tracestate are injected; baggage is not propagated.
76
+ */
72
77
  static updateHttpHeader(context, headers = {}) {
73
- StandardTracer.PROPAGATOR.inject(api_1.trace.setSpanContext(api_1.ROOT_CONTEXT, context.spanContext()),
74
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
- headers, api_1.defaultTextMapSetter);
78
+ StandardTracer.PROPAGATOR.inject(api_1.trace.setSpanContext(api_1.ROOT_CONTEXT, context.spanContext()), headers, api_1.defaultTextMapSetter);
76
79
  return headers;
77
80
  }
78
81
  registerGlobalProvider() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/otel-utils",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Utility to simplify integration with Open Telemetry",
5
5
  "keywords": [
6
6
  "Open",
@@ -14,11 +14,24 @@
14
14
  "type": "commonjs",
15
15
  "main": "dist/index.js",
16
16
  "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
17
30
  "scripts": {
18
31
  "build": "tsc && tsc -p tsconfig.spec.json --noEmit",
19
- "lint": "oxlint src",
20
- "test": "jest --coverage",
21
- "dependencies": ""
32
+ "format": "prettier --write .",
33
+ "lint": "oxlint index.ts src && prettier --check .",
34
+ "test": "jest --coverage"
22
35
  },
23
36
  "dependencies": {
24
37
  "@opentelemetry/api": "^1.10.0",
@@ -39,9 +52,9 @@
39
52
  "@swc/jest": "^0.2.39",
40
53
  "@types/jest": "^30.0.0",
41
54
  "@types/node": "^26.6.5",
42
- "@types/sqlite3": "^5.1.0",
43
55
  "jest": "^30.5.2",
44
56
  "oxlint": "^1.89.0",
57
+ "prettier": "^3.9.10",
45
58
  "typescript": "^7.0.2"
46
59
  },
47
60
  "publishConfig": {
@@ -1,16 +0,0 @@
1
- name: Main Build
2
-
3
- on:
4
- push:
5
- branches: ["main"]
6
-
7
- workflow_dispatch:
8
-
9
- jobs:
10
- npm-merge:
11
- uses: devopsplaybook-io/common-utils/.github/workflows/reusable-npm-merge.yml@main
12
- with:
13
- npm_package_name: "@devopsplaybook.io/otel-utils"
14
- node_version: "22"
15
- secrets:
16
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,25 +0,0 @@
1
- name: PR Check
2
-
3
- on:
4
- pull_request:
5
- branches: ["main"]
6
-
7
- workflow_dispatch:
8
-
9
- concurrency:
10
- group: pr-${{ github.event.pull_request.number || github.ref }}
11
- cancel-in-progress: true
12
-
13
- permissions:
14
- contents: read
15
- pull-requests: write
16
- issues: write
17
-
18
- jobs:
19
- npm-pr:
20
- uses: devopsplaybook-io/common-utils/.github/workflows/reusable-npm-pr.yml@main
21
- with:
22
- npm_package_name: "@devopsplaybook.io/otel-utils"
23
- node_version: "22"
24
- secrets:
25
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
package/index.ts DELETED
@@ -1,7 +0,0 @@
1
- export * from "./src/models/ConfigOTelInterface";
2
- export * from "./src/models/StandardLoggerInterface";
3
- export * from "./src/ModuleLogger";
4
- export * from "./src/ShutdownHooks";
5
- export * from "./src/StandardLogger";
6
- export * from "./src/StandardMeter";
7
- export * from "./src/StandardTracer";
package/jest.config.js DELETED
@@ -1,17 +0,0 @@
1
- module.exports = {
2
- moduleFileExtensions: ["ts", "js"],
3
- transform: {
4
- "^.+\\.(ts|tsx)$": [
5
- "@swc/jest",
6
- {
7
- jsc: {
8
- target: "es2019",
9
- },
10
- },
11
- ],
12
- },
13
- coverageProvider: "v8",
14
- testMatch: ["/**/src/**/*.spec.(ts|js)"],
15
- testPathIgnorePatterns: ["/node_modules/", "/dist/"],
16
- testEnvironment: "node",
17
- };
package/prettierrc.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "tabWidth": 2,
3
- "semi": true,
4
- "singleQuote": false
5
- }
@@ -1,253 +0,0 @@
1
- import type { Span } from "@opentelemetry/api";
2
- import {
3
- SeverityNumber,
4
- type Logger as OTelLogger,
5
- } from "@opentelemetry/api";
6
- import { ModuleLogger, type ModuleLoggerOptions } from "./ModuleLogger";
7
- import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
8
-
9
- describe("ModuleLogger", () => {
10
- let logSpy: jest.SpyInstance;
11
- let warnSpy: jest.SpyInstance;
12
- let errorSpy: jest.SpyInstance;
13
- let emit: jest.Mock;
14
-
15
- beforeEach(() => {
16
- emit = jest.fn();
17
- logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
18
- warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
19
- errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
20
- });
21
-
22
- afterEach(() => {
23
- logSpy.mockRestore();
24
- warnSpy.mockRestore();
25
- errorSpy.mockRestore();
26
- });
27
-
28
- function createLogger(
29
- getLogger: () => OTelLogger | undefined,
30
- ): StandardLoggerInterface {
31
- return { getLogger };
32
- }
33
-
34
- function loggerWithEmit(options?: ModuleLoggerOptions): ModuleLogger {
35
- const fakeLogger = { emit } as unknown as OTelLogger;
36
- return new ModuleLogger("billing", createLogger(() => fakeLogger), options);
37
- }
38
-
39
- function lastAttributes(): Record<string, unknown> {
40
- const record = emit.mock.calls[emit.mock.calls.length - 1][0] as {
41
- attributes: Record<string, unknown>;
42
- };
43
- return record.attributes;
44
- }
45
-
46
- function expectOnlyDefinedAttributes(): void {
47
- for (const [key, value] of Object.entries(lastAttributes())) {
48
- expect({ key, defined: value !== undefined }).toEqual({
49
- key,
50
- defined: true,
51
- });
52
- }
53
- }
54
-
55
- it("logs info to console.log and the OTel logger with the module attribute", () => {
56
- loggerWithEmit().info("hello");
57
-
58
- expect(logSpy).toHaveBeenCalledWith("[info] [billing] hello");
59
- expect(warnSpy).not.toHaveBeenCalled();
60
- expect(errorSpy).not.toHaveBeenCalled();
61
- expect(emit).toHaveBeenCalledWith({
62
- severityNumber: SeverityNumber.INFO,
63
- severityText: "info",
64
- body: "[billing] hello",
65
- attributes: { "log.type": "custom", "log.module": "billing" },
66
- });
67
- });
68
-
69
- it("mirrors warnings through console.warn", () => {
70
- loggerWithEmit().warn("careful");
71
-
72
- expect(warnSpy).toHaveBeenCalledWith("[warn] [billing] careful");
73
- expect(logSpy).not.toHaveBeenCalled();
74
- expect(errorSpy).not.toHaveBeenCalled();
75
- expect(emit).toHaveBeenCalledWith(
76
- expect.objectContaining({
77
- severityNumber: SeverityNumber.WARN,
78
- severityText: "warn",
79
- body: "[billing] careful",
80
- }),
81
- );
82
- });
83
-
84
- it("mirrors errors through console.error", () => {
85
- loggerWithEmit().error("failed");
86
-
87
- expect(errorSpy).toHaveBeenCalledWith("[error] [billing] failed");
88
- expect(logSpy).not.toHaveBeenCalled();
89
- });
90
-
91
- it("logs errors without duplicating the stack in the record body", () => {
92
- const error = new Error("boom");
93
- loggerWithEmit().error("request failed", error);
94
-
95
- expect(emit).toHaveBeenCalledWith({
96
- severityNumber: SeverityNumber.ERROR,
97
- severityText: "error",
98
- body: "[billing] request failed",
99
- attributes: {
100
- "log.type": "custom",
101
- "log.module": "billing",
102
- "exception.type": "Error",
103
- "exception.message": "boom",
104
- "exception.stacktrace": error.stack,
105
- },
106
- });
107
- });
108
-
109
- it("normalises a string error instead of losing its message", () => {
110
- loggerWithEmit().error("failed", "string-error");
111
-
112
- const attributes = lastAttributes();
113
- expect(attributes["exception.type"]).toBe("Error");
114
- expect(attributes["exception.message"]).toBe("string-error");
115
- expect(typeof attributes["exception.stacktrace"]).toBe("string");
116
- expectOnlyDefinedAttributes();
117
- });
118
-
119
- it("serialises a plain object error", () => {
120
- loggerWithEmit().error("failed", { code: 42 });
121
-
122
- const attributes = lastAttributes();
123
- expect(attributes["exception.type"]).toBe("Error");
124
- expect(attributes["exception.message"]).toBe('{"code":42}');
125
- expect(typeof attributes["exception.stacktrace"]).toBe("string");
126
- expectOnlyDefinedAttributes();
127
- });
128
-
129
- it("adds no exception attributes without an error", () => {
130
- loggerWithEmit().error("failed");
131
- expect(lastAttributes()).toEqual({
132
- "log.type": "custom",
133
- "log.module": "billing",
134
- });
135
-
136
- loggerWithEmit().error("failed", null);
137
- expect(lastAttributes()).toEqual({
138
- "log.type": "custom",
139
- "log.module": "billing",
140
- });
141
- });
142
-
143
- it("omits exception.stacktrace when the error has no stack", () => {
144
- const error = new Error("no stack");
145
- delete error.stack;
146
-
147
- loggerWithEmit().error("failed", error);
148
-
149
- const attributes = lastAttributes();
150
- expect(attributes["exception.type"]).toBe("Error");
151
- expect(attributes["exception.message"]).toBe("no stack");
152
- expect(attributes).not.toHaveProperty("exception.stacktrace");
153
- expectOnlyDefinedAttributes();
154
- });
155
-
156
- it("treats a span passed as the second argument as context", () => {
157
- const span = {
158
- spanContext: () => ({ spanId: "span-1", traceId: "trace-1" }),
159
- } as unknown as Span;
160
-
161
- loggerWithEmit().error("failed", span);
162
-
163
- expect(errorSpy).toHaveBeenCalledWith("[error] [billing] failed");
164
- expect(lastAttributes()).toEqual({
165
- "log.type": "custom",
166
- "log.module": "billing",
167
- "span.id": "span-1",
168
- "trace.id": "trace-1",
169
- });
170
- });
171
-
172
- it("adds span and trace ids when a span context is provided", () => {
173
- const span = {
174
- spanContext: () => ({ spanId: "span-1", traceId: "trace-1" }),
175
- } as unknown as Span;
176
-
177
- loggerWithEmit().info("inside a span", span);
178
-
179
- expect(emit).toHaveBeenCalledWith(
180
- expect.objectContaining({
181
- attributes: expect.objectContaining({
182
- "span.id": "span-1",
183
- "trace.id": "trace-1",
184
- }),
185
- }),
186
- );
187
- });
188
-
189
- it("combines exception attributes with a span context", () => {
190
- const span = {
191
- spanContext: () => ({ spanId: "span-2", traceId: "trace-2" }),
192
- } as unknown as Span;
193
-
194
- loggerWithEmit().error("failed", new Error("boom"), span);
195
-
196
- const attributes = lastAttributes();
197
- expect(attributes["exception.message"]).toBe("boom");
198
- expect(attributes["span.id"]).toBe("span-2");
199
- expect(attributes["trace.id"]).toBe("trace-2");
200
- expectOnlyDefinedAttributes();
201
- });
202
-
203
- describe("console modes", () => {
204
- it("always mirrors with and without an OTel logger", () => {
205
- loggerWithEmit({ consoleMode: "always" }).info("with logger");
206
- expect(logSpy).toHaveBeenCalledWith("[info] [billing] with logger");
207
- expect(emit).toHaveBeenCalledTimes(1);
208
-
209
- const withoutLogger = new ModuleLogger(
210
- "billing",
211
- createLogger(() => undefined),
212
- { consoleMode: "always" },
213
- );
214
- withoutLogger.info("without logger");
215
- expect(logSpy).toHaveBeenCalledWith(
216
- "[info] [billing] without logger",
217
- );
218
- expect(emit).toHaveBeenCalledTimes(1);
219
- });
220
-
221
- it("fallback mirrors only when no OTel logger is available", () => {
222
- loggerWithEmit({ consoleMode: "fallback" }).info("with logger");
223
- expect(logSpy).not.toHaveBeenCalled();
224
- expect(emit).toHaveBeenCalledTimes(1);
225
-
226
- const withoutLogger = new ModuleLogger(
227
- "billing",
228
- createLogger(() => undefined),
229
- { consoleMode: "fallback" },
230
- );
231
- withoutLogger.warn("without logger");
232
- expect(warnSpy).toHaveBeenCalledWith(
233
- "[warn] [billing] without logger",
234
- );
235
- });
236
-
237
- it("never disables console output while records are still emitted", () => {
238
- loggerWithEmit({ consoleMode: "never" }).error("silent");
239
-
240
- expect(logSpy).not.toHaveBeenCalled();
241
- expect(warnSpy).not.toHaveBeenCalled();
242
- expect(errorSpy).not.toHaveBeenCalled();
243
- expect(emit).toHaveBeenCalledWith(
244
- expect.objectContaining({ severityText: "error" }),
245
- );
246
- });
247
-
248
- it("defaults to always when no options are given", () => {
249
- new ModuleLogger("billing", createLogger(() => undefined)).info("legacy");
250
- expect(logSpy).toHaveBeenCalledWith("[info] [billing] legacy");
251
- });
252
- });
253
- });