@devopsplaybook.io/otel-utils 1.7.0-beta.38.e32e141 → 1.8.0-beta.39.883f25d

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
@@ -10,18 +10,26 @@ npm install @devopsplaybook.io/otel-utils
10
10
 
11
11
  ## Configuration
12
12
 
13
- All classes accept a `ConfigOTelInterface` object:
14
-
15
- | Field | Type | Default | Description |
16
- | --------------------------------------------------------- | ---------- | ------- | ------------------------------- |
17
- | `SERVICE_ID` | `string` | — | Service identifier (required) |
18
- | `VERSION` | `string` | — | Service version (required) |
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 |
23
- | `OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS` | `number?` | `60` | Metrics export interval |
24
- | `OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER` | `string?` | — | Bearer token for collector auth |
13
+ 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
+
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) |
23
+ | `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 |
29
+
30
+ `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
+
32
+ Numeric export intervals below 1 s (including `0` and negative values from an empty environment variable) are clamped to 1 s; `NaN`/`Infinity` fall back to the 60 s default. Non-positive or non-integer batch values fall back to their defaults.
25
33
 
26
34
  When a collector endpoint is not provided, the corresponding signal (traces, logs, or metrics) is initialized with no export: traces and metrics still have a local provider but no data is sent, and `StandardLogger` creates no provider (its `getLogger()` returns `undefined`).
27
35
 
@@ -55,7 +63,7 @@ span.end();
55
63
 
56
64
  ### StandardLogger
57
65
 
58
- Initializes a `LoggerProvider` with an OTLP log exporter and a `BatchLogRecordProcessor` (`maxQueueSize: 2048`). When `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` is set, the provider is also registered as the global OTel logger provider (first instance only), so `logs.getLogger(...)` from `@opentelemetry/api` returns loggers wired to the configured exporter. Each instance always logs through its own provider (`logger.createModuleLogger(...)`), so a second instance never mis-routes its records to the first one. Without that endpoint, `initOTel` still records the service identity but creates no provider and `getLogger()` returns `undefined`.
66
+ Initializes a `LoggerProvider` with an OTLP log exporter and a `BatchLogRecordProcessor` (`maxQueueSize: 2048`, `maxExportBatchSize: 512`, `exportTimeoutMillis: 30000`, `scheduledDelayMillis: 60000` by default). When `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` is set, the provider is also registered as the global OTel logger provider (first instance only), so `logs.getLogger(...)` from `@opentelemetry/api` returns loggers wired to the configured exporter. Each instance always logs through its own provider (`logger.createModuleLogger(...)`), so a second instance never mis-routes its records to the first one. Without that endpoint, `initOTel` still records the service identity but creates no provider and `getLogger()` returns `undefined`.
59
67
 
60
68
  ```typescript
61
69
  import { StandardLogger } from "@devopsplaybook.io/otel-utils";
@@ -74,15 +82,47 @@ moduleLog.info("Hello world");
74
82
  - `createModuleLogger(moduleName)` — Creates a `ModuleLogger` scoped to a module name.
75
83
  - `shutdown()` — Flushes buffered log records and shuts the logger provider down. Idempotent. No-op when no logs endpoint is configured.
76
84
 
85
+ **Batch tunables and loss window**
86
+
87
+ `BatchLogRecordProcessor` buffers records in memory and exports them periodically:
88
+
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 |
95
+
96
+ 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
+
98
+ ```typescript
99
+ await logger.initOTel({
100
+ ...config,
101
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: 5,
102
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE: 4096,
103
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE: 512,
104
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS: 10000,
105
+ });
106
+ ```
107
+
77
108
  ### ModuleLogger
78
109
 
79
- Scoped logger that prefixes all output with a module name. Logs to both `console.log` and (when available) the OTel Logger.
110
+ 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
+
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. |
117
+
118
+ 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.
80
119
 
81
120
  ```typescript
82
121
  const log = logger.createModuleLogger("api");
83
122
  log.info("request received");
84
123
  log.warn("rate limit approaching");
85
124
  log.error("connection failed", new Error("timeout"));
125
+ log.error("connection failed", "timeout"); // any unknown value is normalised
86
126
  log.info("processing complete", activeSpan); // attach trace context
87
127
  ```
88
128
 
@@ -90,7 +130,7 @@ log.info("processing complete", activeSpan); // attach trace context
90
130
 
91
131
  - `info(message, context?)` — Log at INFO level.
92
132
  - `warn(message, context?)` — Log at WARN level.
93
- - `error(message, error?, context?)` — Log at ERROR level with optional `Error` and span context. Attaches `exception.type`, `exception.message`, and `exception.stacktrace` attributes.
133
+ - `error(message, error?, context?)` — Log at ERROR level. `error` accepts any value: `Error` instances are kept as-is, strings become `new Error(string)` and other values are serialised with `JSON.stringify` (falling back to `String`). `exception.type` and `exception.message` are always set; `exception.stacktrace` is set only when the error has a stack. `null`/`undefined` add no exception attributes. A `Span` passed as the second argument is treated as the context, so `error("failed", span)` and `error("failed", err, span)` both work.
94
134
 
95
135
  When a `Span` is passed as `context`, the log record includes `span.id` and `trace.id` attributes for trace correlation.
96
136
 
@@ -1,11 +1,16 @@
1
1
  import type { Span } from "@opentelemetry/api";
2
+ import type { ConsoleLogMode } from "./models/ConfigOTelInterface";
2
3
  import { StandardLoggerInterface } from "./models/StandardLoggerInterface";
4
+ export interface ModuleLoggerOptions {
5
+ consoleMode?: ConsoleLogMode;
6
+ }
3
7
  export declare class ModuleLogger {
4
8
  private module;
5
9
  private standardLogger;
6
- constructor(module: string, standardLogger: StandardLoggerInterface);
10
+ private consoleMode;
11
+ constructor(module: string, standardLogger: StandardLoggerInterface, options?: ModuleLoggerOptions);
7
12
  info(message: string, context?: Span): void;
8
13
  warn(message: string, context?: Span): void;
9
- error(message: string, error?: Error, context?: Span): void;
14
+ error(message: string, error?: unknown, context?: Span): void;
10
15
  private display;
11
16
  }
@@ -3,9 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ModuleLogger = void 0;
4
4
  const api_1 = require("@opentelemetry/api");
5
5
  class ModuleLogger {
6
- constructor(module, standardLogger) {
6
+ constructor(module, standardLogger, options = {}) {
7
+ var _a;
7
8
  this.module = module;
8
9
  this.standardLogger = standardLogger;
10
+ this.consoleMode = (_a = options.consoleMode) !== null && _a !== void 0 ? _a : "always";
9
11
  }
10
12
  info(message, context) {
11
13
  this.display("info", message, api_1.SeverityNumber.INFO, null, context);
@@ -14,23 +16,35 @@ class ModuleLogger {
14
16
  this.display("warn", message, api_1.SeverityNumber.WARN, null, context);
15
17
  }
16
18
  error(message, error, context) {
17
- this.display("error", message, api_1.SeverityNumber.ERROR, error, context);
19
+ if (!context && isSpanLike(error)) {
20
+ context = error;
21
+ error = undefined;
22
+ }
23
+ this.display("error", message, api_1.SeverityNumber.ERROR, normalizeError(error), context);
18
24
  }
19
25
  display(level, message, severityNumber = api_1.SeverityNumber.INFO, error, context) {
20
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
- const attributes = { "log.type": "custom" };
26
+ const attributes = {
27
+ "log.type": "custom",
28
+ "log.module": this.module,
29
+ };
22
30
  if (error) {
23
31
  attributes["exception.type"] = error.name;
24
32
  attributes["exception.message"] = error.message;
25
- attributes["exception.stacktrace"] = error.stack;
33
+ if (error.stack !== undefined) {
34
+ attributes["exception.stacktrace"] = error.stack;
35
+ }
26
36
  }
27
37
  if (context) {
28
38
  const spanCtx = context.spanContext();
29
39
  attributes["span.id"] = spanCtx.spanId;
30
40
  attributes["trace.id"] = spanCtx.traceId;
31
41
  }
32
- console.log(`[${level}] [${this.module}] ${message}`);
33
42
  const logger = this.standardLogger.getLogger();
43
+ const mirrorToConsole = this.consoleMode === "always" ||
44
+ (this.consoleMode === "fallback" && !logger);
45
+ if (mirrorToConsole) {
46
+ writeToConsole(level, `[${level}] [${this.module}] ${message}`);
47
+ }
34
48
  if (!logger) {
35
49
  return;
36
50
  }
@@ -43,3 +57,48 @@ class ModuleLogger {
43
57
  }
44
58
  }
45
59
  exports.ModuleLogger = ModuleLogger;
60
+ function normalizeError(value) {
61
+ if (value === undefined || value === null) {
62
+ return null;
63
+ }
64
+ if (value instanceof Error) {
65
+ return value;
66
+ }
67
+ return new Error(serializeUnknown(value));
68
+ }
69
+ function serializeUnknown(value) {
70
+ if (typeof value === "string") {
71
+ return value;
72
+ }
73
+ try {
74
+ const json = JSON.stringify(value);
75
+ if (json !== undefined) {
76
+ return json;
77
+ }
78
+ }
79
+ catch {
80
+ // Not serialisable; fall back to String below.
81
+ }
82
+ try {
83
+ return String(value);
84
+ }
85
+ catch {
86
+ return "Unknown error";
87
+ }
88
+ }
89
+ function isSpanLike(value) {
90
+ return (typeof value === "object" &&
91
+ value !== null &&
92
+ typeof value.spanContext === "function");
93
+ }
94
+ function writeToConsole(level, line) {
95
+ if (level === "error") {
96
+ console.error(line);
97
+ }
98
+ else if (level === "warn") {
99
+ console.warn(line);
100
+ }
101
+ else {
102
+ console.log(line);
103
+ }
104
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Logger as OTelLogger } from "@opentelemetry/api";
2
- import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
2
+ import type { ConfigOTelInterface } from "./models/ConfigOTelInterface";
3
3
  import { ModuleLogger } from "./ModuleLogger";
4
4
  import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
5
5
  export declare class StandardLogger implements StandardLoggerInterface {
@@ -9,6 +9,7 @@ export declare class StandardLogger implements StandardLoggerInterface {
9
9
  private serviceName?;
10
10
  private ownsGlobalProvider;
11
11
  private shutdownPromise?;
12
+ private consoleMode;
12
13
  initOTel(config: ConfigOTelInterface): Promise<void>;
13
14
  shutdown(): Promise<void>;
14
15
  getLogger(): OTelLogger | undefined;
@@ -5,15 +5,20 @@ const api_1 = require("@opentelemetry/api");
5
5
  const exporter_logs_otlp_http_1 = require("@opentelemetry/exporter-logs-otlp-http");
6
6
  const sdk_logs_1 = require("@opentelemetry/sdk-logs");
7
7
  const createResource_1 = require("./utils/createResource");
8
+ const configValidation_1 = require("./utils/configValidation");
8
9
  const ModuleLogger_1 = require("./ModuleLogger");
9
10
  let globalLoggerProvider;
10
11
  const ALREADY_REGISTERED_WARNING = "[otel-utils] A global logger provider is already registered; StandardLogger will not replace it. This instance still logs through its own provider, but logs.getLogger() keeps using the existing provider.";
11
12
  class StandardLogger {
12
13
  constructor() {
13
14
  this.ownsGlobalProvider = false;
15
+ this.consoleMode = "always";
14
16
  }
15
17
  async initOTel(config) {
16
- var _a;
18
+ // Validate before releasing the previous provider: a bad re-init must keep
19
+ // the working provider in place.
20
+ (0, configValidation_1.validateServiceIdentity)(config);
21
+ this.consoleMode = (0, configValidation_1.normalizeConsoleLogMode)(config.OPENTELEMETRY_CONSOLE_LOG_MODE);
17
22
  // Re-initialisation flushes and unregisters the previous provider first.
18
23
  if (this.loggerProvider) {
19
24
  await this.releaseProvider();
@@ -37,8 +42,10 @@ class StandardLogger {
37
42
  processors: [
38
43
  new sdk_logs_1.BatchLogRecordProcessor({
39
44
  exporter,
40
- maxQueueSize: 2048,
41
- scheduledDelayMillis: ((_a = config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS) !== null && _a !== void 0 ? _a : 60) * 1000,
45
+ maxQueueSize: (0, configValidation_1.normalizePositiveInteger)(config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE, 2048),
46
+ maxExportBatchSize: (0, configValidation_1.normalizePositiveInteger)(config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE, 512),
47
+ exportTimeoutMillis: (0, configValidation_1.normalizePositiveInteger)(config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS, 30000),
48
+ scheduledDelayMillis: (0, configValidation_1.normalizeExportIntervalSeconds)(config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS, 60) * 1000,
42
49
  }),
43
50
  ],
44
51
  resource: (0, createResource_1.createOTelResource)(this.serviceName, this.serviceVersion),
@@ -56,7 +63,7 @@ class StandardLogger {
56
63
  return this.logger;
57
64
  }
58
65
  createModuleLogger(moduleName) {
59
- return new ModuleLogger_1.ModuleLogger(moduleName, this);
66
+ return new ModuleLogger_1.ModuleLogger(moduleName, this, { consoleMode: this.consoleMode });
60
67
  }
61
68
  registerGlobalProvider() {
62
69
  if (globalLoggerProvider) {
@@ -5,12 +5,13 @@ const api_1 = require("@opentelemetry/api");
5
5
  const exporter_metrics_otlp_http_1 = require("@opentelemetry/exporter-metrics-otlp-http");
6
6
  const createResource_1 = require("./utils/createResource");
7
7
  const sdk_metrics_1 = require("@opentelemetry/sdk-metrics");
8
+ const configValidation_1 = require("./utils/configValidation");
8
9
  let globalMeterProvider;
9
10
  const ALREADY_REGISTERED_WARNING = "[otel-utils] A global meter provider is already registered; StandardMeter will not replace it. This instance still records metrics through its own provider, but metrics.getMeter() keeps using the existing provider.";
10
11
  class StandardMeter {
11
12
  constructor(config) {
12
- var _a;
13
13
  this.ownsGlobalProvider = false;
14
+ (0, configValidation_1.validateServiceIdentity)(config);
14
15
  this.serviceName = config.SERVICE_ID;
15
16
  this.serviceVersion = config.VERSION;
16
17
  let hasCollector = false;
@@ -31,7 +32,7 @@ class StandardMeter {
31
32
  readers: [
32
33
  new sdk_metrics_1.PeriodicExportingMetricReader({
33
34
  exporter: metricExporter,
34
- exportIntervalMillis: ((_a = config.OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS) !== null && _a !== void 0 ? _a : 60) * 1000,
35
+ exportIntervalMillis: (0, configValidation_1.normalizeExportIntervalSeconds)(config.OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS, 60) * 1000,
35
36
  }),
36
37
  ],
37
38
  });
@@ -8,6 +8,7 @@ const exporter_trace_otlp_http_1 = require("@opentelemetry/exporter-trace-otlp-h
8
8
  const id_generator_aws_xray_1 = require("@opentelemetry/id-generator-aws-xray");
9
9
  const sdk_trace_1 = require("@opentelemetry/sdk-trace");
10
10
  const semantic_conventions_1 = require("@opentelemetry/semantic-conventions");
11
+ const configValidation_1 = require("./utils/configValidation");
11
12
  const createResource_1 = require("./utils/createResource");
12
13
  let globalTracerProvider;
13
14
  let contextManagerInitialized = false;
@@ -21,6 +22,7 @@ const ALREADY_REGISTERED_WARNING = "[otel-utils] A global tracer provider is alr
21
22
  class StandardTracer {
22
23
  constructor(config) {
23
24
  this.ownsGlobalProvider = false;
25
+ (0, configValidation_1.validateServiceIdentity)(config);
24
26
  this.serviceName = config.SERVICE_ID;
25
27
  this.serviceVersion = config.VERSION;
26
28
  const spanProcessors = [];
@@ -1,3 +1,4 @@
1
+ export type ConsoleLogMode = "always" | "fallback" | "never";
1
2
  export interface ConfigOTelInterface {
2
3
  SERVICE_ID: string;
3
4
  VERSION: string;
@@ -6,5 +7,9 @@ export interface ConfigOTelInterface {
6
7
  OPENTELEMETRY_COLLECTOR_HTTP_LOGS?: string;
7
8
  OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS?: number;
8
9
  OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS?: number;
10
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE?: number;
11
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE?: number;
12
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS?: number;
13
+ OPENTELEMETRY_CONSOLE_LOG_MODE?: ConsoleLogMode;
9
14
  OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER?: string;
10
15
  }
@@ -0,0 +1,5 @@
1
+ import type { ConfigOTelInterface, ConsoleLogMode } from "../models/ConfigOTelInterface";
2
+ export declare function validateServiceIdentity(config: Pick<ConfigOTelInterface, "SERVICE_ID" | "VERSION">): void;
3
+ export declare function normalizeExportIntervalSeconds(value: number | undefined, defaultSeconds: number): number;
4
+ export declare function normalizePositiveInteger(value: number | undefined, defaultValue: number): number;
5
+ export declare function normalizeConsoleLogMode(value: unknown): ConsoleLogMode;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateServiceIdentity = validateServiceIdentity;
4
+ exports.normalizeExportIntervalSeconds = normalizeExportIntervalSeconds;
5
+ exports.normalizePositiveInteger = normalizePositiveInteger;
6
+ exports.normalizeConsoleLogMode = normalizeConsoleLogMode;
7
+ function validateServiceIdentity(config) {
8
+ if (typeof config.SERVICE_ID !== "string" ||
9
+ config.SERVICE_ID.trim() === "") {
10
+ throw new Error("[otel-utils] SERVICE_ID must be a non-empty string");
11
+ }
12
+ if (typeof config.VERSION !== "string" || config.VERSION.trim() === "") {
13
+ throw new Error("[otel-utils] VERSION must be a non-empty string");
14
+ }
15
+ }
16
+ function normalizeExportIntervalSeconds(value, defaultSeconds) {
17
+ if (value === undefined || !Number.isFinite(value)) {
18
+ return defaultSeconds;
19
+ }
20
+ return Math.max(1, value);
21
+ }
22
+ function normalizePositiveInteger(value, defaultValue) {
23
+ if (value === undefined || !Number.isInteger(value) || value <= 0) {
24
+ return defaultValue;
25
+ }
26
+ return value;
27
+ }
28
+ function normalizeConsoleLogMode(value) {
29
+ if (value === "always" || value === "fallback" || value === "never") {
30
+ return value;
31
+ }
32
+ if (value !== undefined) {
33
+ console.warn(`[otel-utils] Invalid OPENTELEMETRY_CONSOLE_LOG_MODE value "${String(value)}"; expected "always", "fallback" or "never". Defaulting to "always".`);
34
+ }
35
+ return "always";
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/otel-utils",
3
- "version": "1.7.0-beta.38.e32e141",
3
+ "version": "1.8.0-beta.39.883f25d",
4
4
  "description": "Utility to simplify integration with Open Telemetry",
5
5
  "keywords": [
6
6
  "Open",
@@ -3,20 +3,26 @@ import {
3
3
  SeverityNumber,
4
4
  type Logger as OTelLogger,
5
5
  } from "@opentelemetry/api";
6
- import { ModuleLogger } from "./ModuleLogger";
6
+ import { ModuleLogger, type ModuleLoggerOptions } from "./ModuleLogger";
7
7
  import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
8
8
 
9
9
  describe("ModuleLogger", () => {
10
- let consoleSpy: jest.SpyInstance;
10
+ let logSpy: jest.SpyInstance;
11
+ let warnSpy: jest.SpyInstance;
12
+ let errorSpy: jest.SpyInstance;
11
13
  let emit: jest.Mock;
12
14
 
13
15
  beforeEach(() => {
14
16
  emit = jest.fn();
15
- consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
17
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
18
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
19
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
16
20
  });
17
21
 
18
22
  afterEach(() => {
19
- consoleSpy.mockRestore();
23
+ logSpy.mockRestore();
24
+ warnSpy.mockRestore();
25
+ errorSpy.mockRestore();
20
26
  });
21
27
 
22
28
  function createLogger(
@@ -25,27 +31,47 @@ describe("ModuleLogger", () => {
25
31
  return { getLogger };
26
32
  }
27
33
 
28
- function loggerWithEmit(): ModuleLogger {
34
+ function loggerWithEmit(options?: ModuleLoggerOptions): ModuleLogger {
29
35
  const fakeLogger = { emit } as unknown as OTelLogger;
30
- return new ModuleLogger("billing", createLogger(() => fakeLogger));
36
+ return new ModuleLogger("billing", createLogger(() => fakeLogger), options);
31
37
  }
32
38
 
33
- it("logs info to console and the OTel logger", () => {
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", () => {
34
56
  loggerWithEmit().info("hello");
35
57
 
36
- expect(consoleSpy).toHaveBeenCalledWith("[info] [billing] hello");
58
+ expect(logSpy).toHaveBeenCalledWith("[info] [billing] hello");
59
+ expect(warnSpy).not.toHaveBeenCalled();
60
+ expect(errorSpy).not.toHaveBeenCalled();
37
61
  expect(emit).toHaveBeenCalledWith({
38
62
  severityNumber: SeverityNumber.INFO,
39
63
  severityText: "info",
40
64
  body: "[billing] hello",
41
- attributes: { "log.type": "custom" },
65
+ attributes: { "log.type": "custom", "log.module": "billing" },
42
66
  });
43
67
  });
44
68
 
45
- it("logs warn at WARN severity", () => {
69
+ it("mirrors warnings through console.warn", () => {
46
70
  loggerWithEmit().warn("careful");
47
71
 
48
- expect(consoleSpy).toHaveBeenCalledWith("[warn] [billing] careful");
72
+ expect(warnSpy).toHaveBeenCalledWith("[warn] [billing] careful");
73
+ expect(logSpy).not.toHaveBeenCalled();
74
+ expect(errorSpy).not.toHaveBeenCalled();
49
75
  expect(emit).toHaveBeenCalledWith(
50
76
  expect.objectContaining({
51
77
  severityNumber: SeverityNumber.WARN,
@@ -55,17 +81,24 @@ describe("ModuleLogger", () => {
55
81
  );
56
82
  });
57
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
+
58
91
  it("logs errors without duplicating the stack in the record body", () => {
59
92
  const error = new Error("boom");
60
93
  loggerWithEmit().error("request failed", error);
61
94
 
62
- expect(consoleSpy).toHaveBeenCalledWith("[error] [billing] request failed");
63
95
  expect(emit).toHaveBeenCalledWith({
64
96
  severityNumber: SeverityNumber.ERROR,
65
97
  severityText: "error",
66
98
  body: "[billing] request failed",
67
99
  attributes: {
68
100
  "log.type": "custom",
101
+ "log.module": "billing",
69
102
  "exception.type": "Error",
70
103
  "exception.message": "boom",
71
104
  "exception.stacktrace": error.stack,
@@ -73,6 +106,69 @@ describe("ModuleLogger", () => {
73
106
  });
74
107
  });
75
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
+
76
172
  it("adds span and trace ids when a span context is provided", () => {
77
173
  const span = {
78
174
  spanContext: () => ({ spanId: "span-1", traceId: "trace-1" }),
@@ -90,12 +186,68 @@ describe("ModuleLogger", () => {
90
186
  );
91
187
  });
92
188
 
93
- it("falls back to console only when no OTel logger is available", () => {
94
- const logger = new ModuleLogger("billing", createLogger(() => undefined));
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;
95
193
 
96
- logger.warn("offline");
194
+ loggerWithEmit().error("failed", new Error("boom"), span);
97
195
 
98
- expect(consoleSpy).toHaveBeenCalledWith("[warn] [billing] offline");
99
- expect(emit).not.toHaveBeenCalled();
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
+ });
100
252
  });
101
253
  });