@devopsplaybook.io/otel-utils 1.7.0 → 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.
@@ -1,14 +1,25 @@
1
1
  import { SeverityNumber } from "@opentelemetry/api";
2
2
  import type { Span } from "@opentelemetry/api";
3
+ import type { ConsoleLogMode } from "./models/ConfigOTelInterface";
3
4
  import { StandardLoggerInterface } from "./models/StandardLoggerInterface";
4
5
 
6
+ export interface ModuleLoggerOptions {
7
+ consoleMode?: ConsoleLogMode;
8
+ }
9
+
5
10
  export class ModuleLogger {
6
11
  private module: string;
7
12
  private standardLogger: StandardLoggerInterface;
13
+ private consoleMode: ConsoleLogMode;
8
14
 
9
- constructor(module: string, standardLogger: StandardLoggerInterface) {
15
+ constructor(
16
+ module: string,
17
+ standardLogger: StandardLoggerInterface,
18
+ options: ModuleLoggerOptions = {},
19
+ ) {
10
20
  this.module = module;
11
21
  this.standardLogger = standardLogger;
22
+ this.consoleMode = options.consoleMode ?? "always";
12
23
  }
13
24
 
14
25
  public info(message: string, context?: Span): void {
@@ -19,8 +30,18 @@ export class ModuleLogger {
19
30
  this.display("warn", message, SeverityNumber.WARN, null, context);
20
31
  }
21
32
 
22
- public error(message: string, error?: Error, context?: Span): void {
23
- this.display("error", message, SeverityNumber.ERROR, error, context);
33
+ public error(message: string, error?: unknown, context?: Span): void {
34
+ if (!context && isSpanLike(error)) {
35
+ context = error;
36
+ error = undefined;
37
+ }
38
+ this.display(
39
+ "error",
40
+ message,
41
+ SeverityNumber.ERROR,
42
+ normalizeError(error),
43
+ context,
44
+ );
24
45
  }
25
46
 
26
47
  private display(
@@ -30,13 +51,17 @@ export class ModuleLogger {
30
51
  error?: Error | null,
31
52
  context?: Span,
32
53
  ): void {
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- const attributes: Record<string, any> = { "log.type": "custom" };
54
+ const attributes: Record<string, string> = {
55
+ "log.type": "custom",
56
+ "log.module": this.module,
57
+ };
35
58
 
36
59
  if (error) {
37
60
  attributes["exception.type"] = error.name;
38
61
  attributes["exception.message"] = error.message;
39
- attributes["exception.stacktrace"] = error.stack;
62
+ if (error.stack !== undefined) {
63
+ attributes["exception.stacktrace"] = error.stack;
64
+ }
40
65
  }
41
66
 
42
67
  if (context) {
@@ -45,8 +70,13 @@ export class ModuleLogger {
45
70
  attributes["trace.id"] = spanCtx.traceId;
46
71
  }
47
72
 
48
- console.log(`[${level}] [${this.module}] ${message}`);
49
73
  const logger = this.standardLogger.getLogger();
74
+ const mirrorToConsole =
75
+ this.consoleMode === "always" ||
76
+ (this.consoleMode === "fallback" && !logger);
77
+ if (mirrorToConsole) {
78
+ writeToConsole(level, `[${level}] [${this.module}] ${message}`);
79
+ }
50
80
  if (!logger) {
51
81
  return;
52
82
  }
@@ -58,3 +88,50 @@ export class ModuleLogger {
58
88
  });
59
89
  }
60
90
  }
91
+
92
+ function normalizeError(value: unknown): Error | null {
93
+ if (value === undefined || value === null) {
94
+ return null;
95
+ }
96
+ if (value instanceof Error) {
97
+ return value;
98
+ }
99
+ return new Error(serializeUnknown(value));
100
+ }
101
+
102
+ function serializeUnknown(value: unknown): string {
103
+ if (typeof value === "string") {
104
+ return value;
105
+ }
106
+ try {
107
+ const json = JSON.stringify(value);
108
+ if (json !== undefined) {
109
+ return json;
110
+ }
111
+ } catch {
112
+ // Not serialisable; fall back to String below.
113
+ }
114
+ try {
115
+ return String(value);
116
+ } catch {
117
+ return "Unknown error";
118
+ }
119
+ }
120
+
121
+ function isSpanLike(value: unknown): value is Span {
122
+ return (
123
+ typeof value === "object" &&
124
+ value !== null &&
125
+ typeof (value as { spanContext?: unknown }).spanContext === "function"
126
+ );
127
+ }
128
+
129
+ function writeToConsole(level: string, line: string): void {
130
+ if (level === "error") {
131
+ console.error(line);
132
+ } else if (level === "warn") {
133
+ console.warn(line);
134
+ } else {
135
+ console.log(line);
136
+ }
137
+ }
@@ -1,9 +1,40 @@
1
1
  import { logs, SeverityNumber } from "@opentelemetry/api";
2
2
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
3
3
  import { LoggerProvider } from "@opentelemetry/sdk-logs";
4
+ import type { ConsoleLogMode } from "./models/ConfigOTelInterface";
4
5
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
5
6
  import { StandardLogger } from "./StandardLogger";
6
7
 
8
+ jest.mock("@opentelemetry/sdk-logs", () => {
9
+ const actual =
10
+ jest.requireActual<typeof import("@opentelemetry/sdk-logs")>(
11
+ "@opentelemetry/sdk-logs",
12
+ );
13
+ const batchProcessorOptions: unknown[] = [];
14
+ class RecordingBatchLogRecordProcessor extends actual.BatchLogRecordProcessor {
15
+ constructor(
16
+ options: ConstructorParameters<typeof actual.BatchLogRecordProcessor>[0],
17
+ ) {
18
+ super(options);
19
+ batchProcessorOptions.push(options);
20
+ }
21
+ }
22
+ return {
23
+ ...actual,
24
+ BatchLogRecordProcessor: RecordingBatchLogRecordProcessor,
25
+ __batchProcessorOptions: batchProcessorOptions,
26
+ };
27
+ });
28
+
29
+ interface SdkLogsMock {
30
+ __batchProcessorOptions: unknown[];
31
+ }
32
+
33
+ function batchProcessorOptions(): unknown[] {
34
+ return (jest.requireMock("@opentelemetry/sdk-logs") as SdkLogsMock)
35
+ .__batchProcessorOptions;
36
+ }
37
+
7
38
  jest.mock("@opentelemetry/exporter-logs-otlp-http", () => {
8
39
  const { ExportResultCode } = jest.requireActual<
9
40
  typeof import("@opentelemetry/core")
@@ -68,6 +99,7 @@ describe("StandardLogger", () => {
68
99
 
69
100
  beforeEach(() => {
70
101
  jest.clearAllMocks();
102
+ batchProcessorOptions().length = 0;
71
103
  logs.disable();
72
104
  warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
73
105
  logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
@@ -233,4 +265,215 @@ describe("StandardLogger", () => {
233
265
  logs.getLogger("external-lib").emit({ body: "dropped" }),
234
266
  ).not.toThrow();
235
267
  });
268
+
269
+ describe("batch processor tunables", () => {
270
+ it("passes the documented defaults to BatchLogRecordProcessor", async () => {
271
+ const logger = makeLogger();
272
+ await logger.initOTel(collectorConfig("http://collector/v1/logs"));
273
+
274
+ expect(batchProcessorOptions()).toHaveLength(1);
275
+ expect(batchProcessorOptions()[0]).toEqual({
276
+ exporter: expect.anything(),
277
+ maxQueueSize: 2048,
278
+ maxExportBatchSize: 512,
279
+ exportTimeoutMillis: 30000,
280
+ scheduledDelayMillis: 60000,
281
+ });
282
+ await logger.shutdown();
283
+ });
284
+
285
+ it("passes configured batch values to BatchLogRecordProcessor", async () => {
286
+ const logger = makeLogger();
287
+ await logger.initOTel({
288
+ ...collectorConfig("http://collector/v1/logs"),
289
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: 5,
290
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE: 4096,
291
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE: 1024,
292
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS: 15000,
293
+ });
294
+
295
+ expect(batchProcessorOptions()[0]).toEqual({
296
+ exporter: expect.anything(),
297
+ maxQueueSize: 4096,
298
+ maxExportBatchSize: 1024,
299
+ exportTimeoutMillis: 15000,
300
+ scheduledDelayMillis: 5000,
301
+ });
302
+ await logger.shutdown();
303
+ });
304
+
305
+ it("falls back to the documented defaults for invalid batch values", async () => {
306
+ const logger = makeLogger();
307
+ await logger.initOTel({
308
+ ...collectorConfig("http://collector/v1/logs"),
309
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE: 0,
310
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE: -1,
311
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS: 1.5,
312
+ });
313
+
314
+ expect(batchProcessorOptions()[0]).toEqual({
315
+ exporter: expect.anything(),
316
+ maxQueueSize: 2048,
317
+ maxExportBatchSize: 512,
318
+ exportTimeoutMillis: 30000,
319
+ scheduledDelayMillis: 60000,
320
+ });
321
+ await logger.shutdown();
322
+ });
323
+
324
+ it.each([0, -5, 0.5])(
325
+ "clamps the log export interval of %p seconds to one second",
326
+ async (interval) => {
327
+ const logger = makeLogger();
328
+ await logger.initOTel({
329
+ ...collectorConfig("http://collector/v1/logs"),
330
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: interval,
331
+ });
332
+
333
+ expect(
334
+ (batchProcessorOptions()[0] as { scheduledDelayMillis: number })
335
+ .scheduledDelayMillis,
336
+ ).toBe(1000);
337
+ await logger.shutdown();
338
+ },
339
+ );
340
+
341
+ it("falls back to the default delay for a NaN interval", async () => {
342
+ const logger = makeLogger();
343
+ await logger.initOTel({
344
+ ...collectorConfig("http://collector/v1/logs"),
345
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: NaN,
346
+ });
347
+
348
+ expect(
349
+ (batchProcessorOptions()[0] as { scheduledDelayMillis: number })
350
+ .scheduledDelayMillis,
351
+ ).toBe(60000);
352
+ await logger.shutdown();
353
+ });
354
+ });
355
+
356
+ describe("service identity validation", () => {
357
+ it("rejects an empty SERVICE_ID without creating an exporter", async () => {
358
+ const logger = makeLogger();
359
+
360
+ await expect(
361
+ logger.initOTel({
362
+ SERVICE_ID: " ",
363
+ VERSION: "1.0.0",
364
+ OPENTELEMETRY_COLLECTOR_HTTP_LOGS: "http://collector/v1/logs",
365
+ }),
366
+ ).rejects.toThrow("[otel-utils] SERVICE_ID must be a non-empty string");
367
+
368
+ expect(mockExporterConstructor).not.toHaveBeenCalled();
369
+ expect(logger.getLogger()).toBeUndefined();
370
+ await expect(logger.shutdown()).resolves.toBeUndefined();
371
+ });
372
+
373
+ it("rejects an empty VERSION without creating an exporter", async () => {
374
+ const logger = makeLogger();
375
+
376
+ await expect(
377
+ logger.initOTel({
378
+ SERVICE_ID: "log-service",
379
+ VERSION: "",
380
+ OPENTELEMETRY_COLLECTOR_HTTP_LOGS: "http://collector/v1/logs",
381
+ }),
382
+ ).rejects.toThrow("[otel-utils] VERSION must be a non-empty string");
383
+
384
+ expect(mockExporterConstructor).not.toHaveBeenCalled();
385
+ await expect(logger.shutdown()).resolves.toBeUndefined();
386
+ });
387
+
388
+ it("keeps the previous provider when a re-initialisation is invalid", async () => {
389
+ const logger = makeLogger();
390
+ await logger.initOTel(collectorConfig("http://collector-1/v1/logs"));
391
+ logger.createModuleLogger("billing").info("before-invalid-reinit");
392
+
393
+ await expect(
394
+ logger.initOTel({
395
+ SERVICE_ID: "",
396
+ VERSION: "1.0.0",
397
+ OPENTELEMETRY_COLLECTOR_HTTP_LOGS: "http://collector-2/v1/logs",
398
+ }),
399
+ ).rejects.toThrow("[otel-utils] SERVICE_ID must be a non-empty string");
400
+
401
+ expect(logger.getLogger()).toBeDefined();
402
+ expect(logs.getLoggerProvider()).toBeInstanceOf(LoggerProvider);
403
+ logger.createModuleLogger("billing").info("after-invalid-reinit");
404
+ await logger.shutdown();
405
+
406
+ expect(mockExporterConstructor).toHaveBeenCalledTimes(1);
407
+ expect(exportedBodies(exporterAt(0))).toEqual(
408
+ expect.arrayContaining([
409
+ "[billing] before-invalid-reinit",
410
+ "[billing] after-invalid-reinit",
411
+ ]),
412
+ );
413
+ });
414
+ });
415
+
416
+ describe("console log modes", () => {
417
+ it("mirrors through console.log by default", async () => {
418
+ const logger = makeLogger();
419
+ await logger.initOTel(collectorConfig("http://collector/v1/logs"));
420
+
421
+ logger.createModuleLogger("billing").info("mirrored");
422
+
423
+ expect(logSpy).toHaveBeenCalledWith("[info] [billing] mirrored");
424
+ await logger.shutdown();
425
+ });
426
+
427
+ it("never suppresses console output while records are still exported", async () => {
428
+ const logger = makeLogger();
429
+ await logger.initOTel({
430
+ ...collectorConfig("http://collector/v1/logs"),
431
+ OPENTELEMETRY_CONSOLE_LOG_MODE: "never",
432
+ });
433
+
434
+ logger.createModuleLogger("billing").info("silent");
435
+
436
+ expect(logSpy).not.toHaveBeenCalled();
437
+ await logger.shutdown();
438
+ expect(exportedBodies(exporterAt(0))).toContain("[billing] silent");
439
+ });
440
+
441
+ it("warns once and behaves as always for an invalid mode", async () => {
442
+ const logger = makeLogger();
443
+ await logger.initOTel({
444
+ ...collectorConfig("http://collector/v1/logs"),
445
+ OPENTELEMETRY_CONSOLE_LOG_MODE:
446
+ "sometimes" as unknown as ConsoleLogMode,
447
+ });
448
+
449
+ expect(warnSpy).toHaveBeenCalledWith(
450
+ expect.stringContaining("OPENTELEMETRY_CONSOLE_LOG_MODE"),
451
+ );
452
+ logger.createModuleLogger("billing").info("still mirrored");
453
+ expect(logSpy).toHaveBeenCalledWith("[info] [billing] still mirrored");
454
+ await logger.shutdown();
455
+ });
456
+
457
+ it("fallback mirrors only when no logs endpoint is configured", async () => {
458
+ const withEndpoint = makeLogger();
459
+ await withEndpoint.initOTel({
460
+ ...collectorConfig("http://collector/v1/logs"),
461
+ OPENTELEMETRY_CONSOLE_LOG_MODE: "fallback",
462
+ });
463
+ withEndpoint.createModuleLogger("billing").info("not-mirrored");
464
+ expect(logSpy).not.toHaveBeenCalled();
465
+ await withEndpoint.shutdown();
466
+ expect(exportedBodies(exporterAt(0))).toContain("[billing] not-mirrored");
467
+
468
+ const withoutEndpoint = makeLogger();
469
+ await withoutEndpoint.initOTel({
470
+ SERVICE_ID: "log-service",
471
+ VERSION: "1.0.0",
472
+ OPENTELEMETRY_CONSOLE_LOG_MODE: "fallback",
473
+ });
474
+ withoutEndpoint.createModuleLogger("billing").warn("mirrored-fallback");
475
+ expect(warnSpy).toHaveBeenCalledWith("[warn] [billing] mirrored-fallback");
476
+ await withoutEndpoint.shutdown();
477
+ });
478
+ });
236
479
  });
@@ -4,8 +4,17 @@ import {
4
4
  BatchLogRecordProcessor,
5
5
  LoggerProvider,
6
6
  } from "@opentelemetry/sdk-logs";
7
- import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
7
+ import type {
8
+ ConfigOTelInterface,
9
+ ConsoleLogMode,
10
+ } from "./models/ConfigOTelInterface";
8
11
  import { createOTelResource } from "./utils/createResource";
12
+ import {
13
+ normalizeConsoleLogMode,
14
+ normalizeExportIntervalSeconds,
15
+ normalizePositiveInteger,
16
+ validateServiceIdentity,
17
+ } from "./utils/configValidation";
9
18
  import { ModuleLogger } from "./ModuleLogger";
10
19
 
11
20
  import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
@@ -22,8 +31,15 @@ export class StandardLogger implements StandardLoggerInterface {
22
31
  private serviceName?: string;
23
32
  private ownsGlobalProvider = false;
24
33
  private shutdownPromise?: Promise<void>;
34
+ private consoleMode: ConsoleLogMode = "always";
25
35
 
26
36
  public async initOTel(config: ConfigOTelInterface): Promise<void> {
37
+ // Validate before releasing the previous provider: a bad re-init must keep
38
+ // the working provider in place.
39
+ validateServiceIdentity(config);
40
+ this.consoleMode = normalizeConsoleLogMode(
41
+ config.OPENTELEMETRY_CONSOLE_LOG_MODE,
42
+ );
27
43
  // Re-initialisation flushes and unregisters the previous provider first.
28
44
  if (this.loggerProvider) {
29
45
  await this.releaseProvider();
@@ -50,10 +66,23 @@ export class StandardLogger implements StandardLoggerInterface {
50
66
  processors: [
51
67
  new BatchLogRecordProcessor({
52
68
  exporter,
53
- maxQueueSize: 2048,
69
+ maxQueueSize: normalizePositiveInteger(
70
+ config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE,
71
+ 2048,
72
+ ),
73
+ maxExportBatchSize: normalizePositiveInteger(
74
+ config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE,
75
+ 512,
76
+ ),
77
+ exportTimeoutMillis: normalizePositiveInteger(
78
+ config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS,
79
+ 30000,
80
+ ),
54
81
  scheduledDelayMillis:
55
- (config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS ??
56
- 60) * 1000,
82
+ normalizeExportIntervalSeconds(
83
+ config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS,
84
+ 60,
85
+ ) * 1000,
57
86
  }),
58
87
  ],
59
88
  resource: createOTelResource(this.serviceName, this.serviceVersion),
@@ -77,7 +106,7 @@ export class StandardLogger implements StandardLoggerInterface {
77
106
  }
78
107
 
79
108
  public createModuleLogger(moduleName: string): ModuleLogger {
80
- return new ModuleLogger(moduleName, this);
109
+ return new ModuleLogger(moduleName, this, { consoleMode: this.consoleMode });
81
110
  }
82
111
 
83
112
  private registerGlobalProvider(): void {
@@ -9,6 +9,38 @@ import {
9
9
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
10
10
  import { StandardMeter } from "./StandardMeter";
11
11
 
12
+ jest.mock("@opentelemetry/sdk-metrics", () => {
13
+ const actual =
14
+ jest.requireActual<typeof import("@opentelemetry/sdk-metrics")>(
15
+ "@opentelemetry/sdk-metrics",
16
+ );
17
+ const metricReaderOptions: unknown[] = [];
18
+ class RecordingPeriodicExportingMetricReader extends actual.PeriodicExportingMetricReader {
19
+ constructor(
20
+ options: ConstructorParameters<
21
+ typeof actual.PeriodicExportingMetricReader
22
+ >[0],
23
+ ) {
24
+ super(options);
25
+ metricReaderOptions.push(options);
26
+ }
27
+ }
28
+ return {
29
+ ...actual,
30
+ PeriodicExportingMetricReader: RecordingPeriodicExportingMetricReader,
31
+ __metricReaderOptions: metricReaderOptions,
32
+ };
33
+ });
34
+
35
+ interface SdkMetricsMock {
36
+ __metricReaderOptions: unknown[];
37
+ }
38
+
39
+ function metricReaderOptions(): unknown[] {
40
+ return (jest.requireMock("@opentelemetry/sdk-metrics") as SdkMetricsMock)
41
+ .__metricReaderOptions;
42
+ }
43
+
12
44
  jest.mock("@opentelemetry/exporter-metrics-otlp-http", () => {
13
45
  const {
14
46
  AggregationTemporality,
@@ -80,6 +112,7 @@ describe("StandardMeter", () => {
80
112
 
81
113
  beforeEach(() => {
82
114
  jest.clearAllMocks();
115
+ metricReaderOptions().length = 0;
83
116
  metrics.disable();
84
117
  warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
85
118
  });
@@ -338,4 +371,76 @@ describe("StandardMeter", () => {
338
371
  expect(metricNames(exporterAt(1))).toContain("after-reinit");
339
372
  expect(shutdownSpy).toHaveBeenCalledTimes(1);
340
373
  });
374
+
375
+ describe("export interval tuning", () => {
376
+ it("uses the documented default interval", async () => {
377
+ const meter = makeMeter(
378
+ meterConfig("metric-service", "http://collector:4318/v1/metrics"),
379
+ );
380
+
381
+ expect(metricReaderOptions()).toHaveLength(1);
382
+ expect(metricReaderOptions()[0]).toEqual({
383
+ exporter: expect.anything(),
384
+ exportIntervalMillis: 60000,
385
+ });
386
+ await meter.shutdown();
387
+ });
388
+
389
+ it.each([0, -5, 0.5])(
390
+ "clamps the export interval of %p seconds to one second",
391
+ async (interval) => {
392
+ const meter = makeMeter({
393
+ ...meterConfig("metric-service", "http://collector:4318/v1/metrics"),
394
+ OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS: interval,
395
+ });
396
+
397
+ expect(
398
+ (metricReaderOptions()[0] as { exportIntervalMillis: number })
399
+ .exportIntervalMillis,
400
+ ).toBe(1000);
401
+ await meter.shutdown();
402
+ },
403
+ );
404
+
405
+ it("falls back to the default interval for a NaN value", async () => {
406
+ const meter = makeMeter({
407
+ ...meterConfig("metric-service", "http://collector:4318/v1/metrics"),
408
+ OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS: NaN,
409
+ });
410
+
411
+ expect(
412
+ (metricReaderOptions()[0] as { exportIntervalMillis: number })
413
+ .exportIntervalMillis,
414
+ ).toBe(60000);
415
+ await meter.shutdown();
416
+ });
417
+ });
418
+
419
+ describe("service identity validation", () => {
420
+ it("rejects an empty SERVICE_ID without creating an exporter", () => {
421
+ expect(
422
+ () =>
423
+ new StandardMeter({
424
+ SERVICE_ID: " ",
425
+ VERSION: "1.0.0",
426
+ OPENTELEMETRY_COLLECTOR_HTTP_METRICS:
427
+ "http://collector:4318/v1/metrics",
428
+ }),
429
+ ).toThrow("[otel-utils] SERVICE_ID must be a non-empty string");
430
+ expect(mockExporterConstructor).not.toHaveBeenCalled();
431
+ });
432
+
433
+ it("rejects an empty VERSION without creating an exporter", () => {
434
+ expect(
435
+ () =>
436
+ new StandardMeter({
437
+ SERVICE_ID: "metric-service",
438
+ VERSION: "",
439
+ OPENTELEMETRY_COLLECTOR_HTTP_METRICS:
440
+ "http://collector:4318/v1/metrics",
441
+ }),
442
+ ).toThrow("[otel-utils] VERSION must be a non-empty string");
443
+ expect(mockExporterConstructor).not.toHaveBeenCalled();
444
+ });
445
+ });
341
446
  });
@@ -14,6 +14,10 @@ import {
14
14
  PeriodicExportingMetricReader,
15
15
  } from "@opentelemetry/sdk-metrics";
16
16
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
17
+ import {
18
+ normalizeExportIntervalSeconds,
19
+ validateServiceIdentity,
20
+ } from "./utils/configValidation";
17
21
 
18
22
  let globalMeterProvider: MeterProvider | undefined;
19
23
 
@@ -43,6 +47,7 @@ export class StandardMeter {
43
47
  private shutdownPromise?: Promise<void>;
44
48
 
45
49
  constructor(config: ConfigOTelInterface) {
50
+ validateServiceIdentity(config);
46
51
  this.serviceName = config.SERVICE_ID;
47
52
  this.serviceVersion = config.VERSION;
48
53
  let hasCollector = false;
@@ -64,8 +69,10 @@ export class StandardMeter {
64
69
  new PeriodicExportingMetricReader({
65
70
  exporter: metricExporter,
66
71
  exportIntervalMillis:
67
- (config.OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS ??
68
- 60) * 1000,
72
+ normalizeExportIntervalSeconds(
73
+ config.OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS,
74
+ 60,
75
+ ) * 1000,
69
76
  }),
70
77
  ],
71
78
  });
@@ -225,6 +225,20 @@ describe("StandardTracer", () => {
225
225
  });
226
226
  });
227
227
 
228
+ describe("service identity validation", () => {
229
+ it("rejects an empty SERVICE_ID", () => {
230
+ expect(
231
+ () => new StandardTracer({ SERVICE_ID: " ", VERSION: "1.0.0" }),
232
+ ).toThrow("[otel-utils] SERVICE_ID must be a non-empty string");
233
+ });
234
+
235
+ it("rejects an empty VERSION", () => {
236
+ expect(
237
+ () => new StandardTracer({ SERVICE_ID: "test-service", VERSION: "" }),
238
+ ).toThrow("[otel-utils] VERSION must be a non-empty string");
239
+ });
240
+ });
241
+
228
242
  describe("provider ownership and global registration", () => {
229
243
  it("routes each instance to its own exporter and resource", async () => {
230
244
  const first = makeTracer(
@@ -21,6 +21,7 @@ import {
21
21
  ATTR_HTTP_ROUTE,
22
22
  } from "@opentelemetry/semantic-conventions";
23
23
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
24
+ import { validateServiceIdentity } from "./utils/configValidation";
24
25
  import { createOTelResource } from "./utils/createResource";
25
26
 
26
27
  let globalTracerProvider: TracerProvider | undefined;
@@ -64,6 +65,7 @@ export class StandardTracer {
64
65
  private shutdownPromise?: Promise<void>;
65
66
 
66
67
  constructor(config: ConfigOTelInterface) {
68
+ validateServiceIdentity(config);
67
69
  this.serviceName = config.SERVICE_ID;
68
70
  this.serviceVersion = config.VERSION;
69
71
  const spanProcessors = [];
@@ -1,3 +1,5 @@
1
+ export type ConsoleLogMode = "always" | "fallback" | "never";
2
+
1
3
  export interface ConfigOTelInterface {
2
4
  SERVICE_ID: string;
3
5
  VERSION: string;
@@ -6,5 +8,9 @@ export interface ConfigOTelInterface {
6
8
  OPENTELEMETRY_COLLECTOR_HTTP_LOGS?: string;
7
9
  OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS?: number;
8
10
  OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS?: number;
11
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_QUEUE_SIZE?: number;
12
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_MAX_BATCH_SIZE?: number;
13
+ OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_TIMEOUT_MILLIS?: number;
14
+ OPENTELEMETRY_CONSOLE_LOG_MODE?: ConsoleLogMode;
9
15
  OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER?: string;
10
16
  }