@devopsplaybook.io/otel-utils 1.5.3 → 1.6.0-beta.37.40daec3

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.
@@ -2,6 +2,7 @@ import {
2
2
  Counter,
3
3
  Histogram,
4
4
  Meter,
5
+ metrics,
5
6
  ObservableGauge,
6
7
  UpDownCounter,
7
8
  } from "@opentelemetry/api";
@@ -13,6 +14,11 @@ import {
13
14
  } from "@opentelemetry/sdk-metrics";
14
15
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
15
16
 
17
+ let globalMeterProvider: MeterProvider | undefined;
18
+
19
+ const ALREADY_REGISTERED_WARNING =
20
+ "[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.";
21
+
16
22
  /**
17
23
  * Options accepted by {@link StandardMeter.createHistogram}.
18
24
  */
@@ -29,11 +35,15 @@ export class StandardMeter {
29
35
  private meterProvider: MeterProvider;
30
36
  private serviceVersion: string;
31
37
  private serviceName: string;
38
+ private ownsGlobalProvider = false;
39
+ private shutdownPromise?: Promise<void>;
32
40
 
33
41
  constructor(config: ConfigOTelInterface) {
34
42
  this.serviceName = config.SERVICE_ID;
35
43
  this.serviceVersion = config.VERSION;
44
+ let hasCollector = false;
36
45
  if (config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS) {
46
+ hasCollector = true;
37
47
  const collectorOptions = {
38
48
  url: config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS,
39
49
  headers: {} as Record<string, string>,
@@ -63,10 +73,16 @@ export class StandardMeter {
63
73
  this.meter = this.meterProvider.getMeter(
64
74
  `${this.serviceName}:${this.serviceVersion}`,
65
75
  );
76
+ if (hasCollector) {
77
+ this.registerGlobalProvider();
78
+ }
66
79
  }
67
80
 
68
- public async shutdown(): Promise<void> {
69
- await this.meterProvider.shutdown();
81
+ public shutdown(): Promise<void> {
82
+ if (!this.shutdownPromise) {
83
+ this.shutdownPromise = this.doShutdown();
84
+ }
85
+ return this.shutdownPromise;
70
86
  }
71
87
 
72
88
  public createCounter(key: string): Counter {
@@ -99,4 +115,35 @@ export class StandardMeter {
99
115
  observableGauge.addCallback(callback);
100
116
  return observableGauge;
101
117
  }
118
+
119
+ private registerGlobalProvider(): void {
120
+ if (globalMeterProvider) {
121
+ if (globalMeterProvider === metrics.getMeterProvider()) {
122
+ console.warn(ALREADY_REGISTERED_WARNING);
123
+ return;
124
+ }
125
+ // The previous provider is no longer registered (disabled elsewhere).
126
+ globalMeterProvider = undefined;
127
+ }
128
+ if (metrics.setGlobalMeterProvider(this.meterProvider)) {
129
+ globalMeterProvider = this.meterProvider;
130
+ this.ownsGlobalProvider = true;
131
+ } else {
132
+ console.warn(ALREADY_REGISTERED_WARNING);
133
+ }
134
+ }
135
+
136
+ private async doShutdown(): Promise<void> {
137
+ await this.meterProvider.shutdown();
138
+ if (
139
+ this.ownsGlobalProvider &&
140
+ metrics.getMeterProvider() === this.meterProvider
141
+ ) {
142
+ metrics.disable();
143
+ }
144
+ if (globalMeterProvider === this.meterProvider) {
145
+ globalMeterProvider = undefined;
146
+ }
147
+ this.ownsGlobalProvider = false;
148
+ }
102
149
  }
@@ -1,9 +1,16 @@
1
- import { SpanKind } from "@opentelemetry/api";
1
+ import {
2
+ diag,
3
+ DiagLogLevel,
4
+ SpanKind,
5
+ trace,
6
+ } from "@opentelemetry/api";
2
7
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
8
+ import { TracerProvider } from "@opentelemetry/sdk-trace";
3
9
  import {
4
10
  ATTR_HTTP_REQUEST_METHOD,
5
11
  ATTR_HTTP_ROUTE,
6
12
  } from "@opentelemetry/semantic-conventions";
13
+ import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
7
14
  import { StandardTracer } from "./StandardTracer";
8
15
 
9
16
  jest.mock("@opentelemetry/exporter-trace-otlp-http", () => {
@@ -12,53 +19,104 @@ jest.mock("@opentelemetry/exporter-trace-otlp-http", () => {
12
19
  >("@opentelemetry/core");
13
20
  return {
14
21
  OTLPTraceExporter: jest.fn().mockImplementation(function () {
22
+ const exportedSpans: ExportedSpan[] = [];
15
23
  return {
16
24
  export: (
17
- _spans: unknown,
25
+ spans: ExportedSpan[],
18
26
  resultCallback: (result: { code: number }) => void,
19
- ) => resultCallback({ code: ExportResultCode.SUCCESS }),
27
+ ) => {
28
+ exportedSpans.push(...spans);
29
+ resultCallback({ code: ExportResultCode.SUCCESS });
30
+ },
20
31
  forceFlush: jest.fn().mockResolvedValue(undefined),
21
32
  shutdown: jest.fn().mockResolvedValue(undefined),
33
+ getExportedSpans: () => exportedSpans,
22
34
  };
23
35
  }),
24
36
  };
25
37
  });
26
38
 
39
+ interface ExportedSpan {
40
+ name: string;
41
+ resource: { attributes: Record<string, unknown> };
42
+ }
43
+
27
44
  interface MockTraceExporter {
28
45
  export: jest.Mock;
29
46
  forceFlush: jest.Mock;
30
47
  shutdown: jest.Mock;
48
+ getExportedSpans: () => ExportedSpan[];
31
49
  }
32
50
 
33
51
  const mockExporterConstructor = OTLPTraceExporter as unknown as jest.Mock;
34
52
 
53
+ function exporterAt(index: number): MockTraceExporter {
54
+ return mockExporterConstructor.mock.results[index]
55
+ .value as MockTraceExporter;
56
+ }
57
+
35
58
  function lastExporter(): MockTraceExporter {
36
- const results = mockExporterConstructor.mock.results;
37
- return results[results.length - 1].value as MockTraceExporter;
59
+ return exporterAt(mockExporterConstructor.mock.results.length - 1);
60
+ }
61
+
62
+ function registeredTracerProvider(): unknown {
63
+ const provider = trace.getTracerProvider() as unknown as {
64
+ getDelegate?: () => unknown;
65
+ };
66
+ return typeof provider.getDelegate === "function"
67
+ ? provider.getDelegate()
68
+ : provider;
69
+ }
70
+
71
+ function tracerConfig(
72
+ serviceId: string,
73
+ url?: string,
74
+ ): ConfigOTelInterface {
75
+ return {
76
+ SERVICE_ID: serviceId,
77
+ VERSION: "1.0.0",
78
+ OPENTELEMETRY_COLLECTOR_HTTP_TRACES: url,
79
+ };
80
+ }
81
+
82
+ function spanNames(exporter: MockTraceExporter): string[] {
83
+ return exporter.getExportedSpans().map((span) => span.name);
38
84
  }
39
85
 
40
86
  describe("StandardTracer", () => {
41
- let tracer: StandardTracer;
87
+ const createdTracers: StandardTracer[] = [];
88
+ let warnSpy: jest.SpyInstance;
42
89
 
43
- beforeAll(() => {
44
- tracer = new StandardTracer({
45
- SERVICE_ID: "test-service",
46
- VERSION: "1.0.0",
47
- });
48
- });
90
+ function makeTracer(config: ConfigOTelInterface): StandardTracer {
91
+ const tracer = new StandardTracer(config);
92
+ createdTracers.push(tracer);
93
+ return tracer;
94
+ }
49
95
 
50
96
  beforeEach(() => {
51
97
  jest.clearAllMocks();
98
+ trace.disable();
99
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
100
+ });
101
+
102
+ afterEach(async () => {
103
+ for (const tracer of createdTracers.splice(0)) {
104
+ await tracer.shutdown();
105
+ }
106
+ trace.disable();
107
+ warnSpy.mockRestore();
52
108
  });
53
109
 
54
110
  describe("startSpan", () => {
55
111
  it("sanitizes span names", () => {
112
+ const tracer = makeTracer(tracerConfig("test-service"));
56
113
  const span = tracer.startSpan("GET-/api/files/:id");
57
114
  expect(span.name).toBe("GET-/api/files/_id");
58
115
  span.end();
59
116
  });
60
117
 
61
118
  it("keeps the default parentless behavior when no options are given", () => {
119
+ const tracer = makeTracer(tracerConfig("test-service"));
62
120
  const span = tracer.startSpan("my-operation");
63
121
  expect(span.kind).toBe(SpanKind.INTERNAL);
64
122
  expect(span.attributes[ATTR_HTTP_REQUEST_METHOD]).toBe("BACKEND");
@@ -69,6 +127,7 @@ describe("StandardTracer", () => {
69
127
  });
70
128
 
71
129
  it("creates a child of the parent span without synthetic attributes", () => {
130
+ const tracer = makeTracer(tracerConfig("test-service"));
72
131
  const parent = tracer.startSpan("parent-operation");
73
132
  const child = tracer.startSpan("child-operation", parent);
74
133
  expect(child.spanContext().traceId).toBe(parent.spanContext().traceId);
@@ -80,6 +139,7 @@ describe("StandardTracer", () => {
80
139
  });
81
140
 
82
141
  it("applies the options instead of the synthetic attributes", () => {
142
+ const tracer = makeTracer(tracerConfig("test-service"));
83
143
  const span = tracer.startSpan("GET-/api/files/:id", undefined, {
84
144
  kind: SpanKind.SERVER,
85
145
  });
@@ -90,6 +150,7 @@ describe("StandardTracer", () => {
90
150
  });
91
151
 
92
152
  it("combines the options with a parent span", () => {
153
+ const tracer = makeTracer(tracerConfig("test-service"));
93
154
  const parent = tracer.startSpan("parent-operation");
94
155
  const child = tracer.startSpan("child-operation", parent, {
95
156
  kind: SpanKind.CLIENT,
@@ -104,6 +165,7 @@ describe("StandardTracer", () => {
104
165
 
105
166
  describe("updateHttpHeader", () => {
106
167
  it("injects the W3C trace context into a new headers object", () => {
168
+ const tracer = makeTracer(tracerConfig("test-service"));
107
169
  const span = tracer.startSpan("propagated");
108
170
  const headers = StandardTracer.updateHttpHeader(span);
109
171
 
@@ -113,6 +175,7 @@ describe("StandardTracer", () => {
113
175
  });
114
176
 
115
177
  it("injects into and returns the provided headers object", () => {
178
+ const tracer = makeTracer(tracerConfig("test-service"));
116
179
  const span = tracer.startSpan("propagated");
117
180
  const headers: Record<string, string> = { "x-custom": "value" };
118
181
  const result = StandardTracer.updateHttpHeader(span, headers);
@@ -124,9 +187,9 @@ describe("StandardTracer", () => {
124
187
  });
125
188
  });
126
189
 
127
- describe("shutdown", () => {
190
+ describe("exporter configuration", () => {
128
191
  it("builds the trace exporter with URL and auth header and shuts it down", async () => {
129
- const exportTracer = new StandardTracer({
192
+ const exportTracer = makeTracer({
130
193
  SERVICE_ID: "export-service",
131
194
  VERSION: "2.0.0",
132
195
  OPENTELEMETRY_COLLECTOR_HTTP_TRACES: "http://collector:4318/v1/traces",
@@ -143,7 +206,7 @@ describe("StandardTracer", () => {
143
206
  });
144
207
 
145
208
  it("builds the trace exporter without auth header", async () => {
146
- const exportTracer = new StandardTracer({
209
+ const exportTracer = makeTracer({
147
210
  SERVICE_ID: "export-service",
148
211
  VERSION: "2.0.0",
149
212
  OPENTELEMETRY_COLLECTOR_HTTP_TRACES: "http://collector:4318/v1/traces",
@@ -157,12 +220,116 @@ describe("StandardTracer", () => {
157
220
  });
158
221
 
159
222
  it("resolves without a collector", async () => {
160
- const plainTracer = new StandardTracer({
161
- SERVICE_ID: "plain-service",
162
- VERSION: "1.0.0",
163
- });
164
-
223
+ const plainTracer = makeTracer(tracerConfig("plain-service"));
165
224
  await expect(plainTracer.shutdown()).resolves.toBeUndefined();
166
225
  });
167
226
  });
227
+
228
+ describe("provider ownership and global registration", () => {
229
+ it("routes each instance to its own exporter and resource", async () => {
230
+ const first = makeTracer(
231
+ tracerConfig("svc-1", "http://collector-1/v1/traces"),
232
+ );
233
+ const second = makeTracer(
234
+ tracerConfig("svc-2", "http://collector-2/v1/traces"),
235
+ );
236
+
237
+ first.startSpan("span-from-svc1").end();
238
+ second.startSpan("span-from-svc2").end();
239
+
240
+ await first.shutdown();
241
+ await second.shutdown();
242
+
243
+ const firstSpans = exporterAt(0).getExportedSpans();
244
+ const secondSpans = exporterAt(1).getExportedSpans();
245
+ expect(firstSpans.map((span) => span.name)).toEqual(["span-from-svc1"]);
246
+ expect(secondSpans.map((span) => span.name)).toEqual(["span-from-svc2"]);
247
+ expect(firstSpans[0].resource.attributes["service.name"]).toBe("svc-1");
248
+ expect(secondSpans[0].resource.attributes["service.name"]).toBe("svc-2");
249
+ expect(warnSpy).toHaveBeenCalledTimes(1);
250
+ expect(warnSpy).toHaveBeenCalledWith(
251
+ expect.stringContaining("A global tracer provider is already registered"),
252
+ );
253
+ });
254
+
255
+ it("routes global API spans to the first registered provider", async () => {
256
+ const first = makeTracer(
257
+ tracerConfig("svc-1", "http://collector-1/v1/traces"),
258
+ );
259
+ const second = makeTracer(
260
+ tracerConfig("svc-2", "http://collector-2/v1/traces"),
261
+ );
262
+
263
+ trace.getTracer("global").startSpan("global-span").end();
264
+ second.startSpan("second-span").end();
265
+
266
+ await first.shutdown();
267
+ await second.shutdown();
268
+
269
+ expect(spanNames(exporterAt(0))).toEqual(["global-span"]);
270
+ expect(spanNames(exporterAt(1))).toEqual(["second-span"]);
271
+ });
272
+
273
+ it("only the owner disables the global provider and re-registration works", async () => {
274
+ const first = makeTracer(
275
+ tracerConfig("svc-1", "http://collector-1/v1/traces"),
276
+ );
277
+ const second = makeTracer(
278
+ tracerConfig("svc-2", "http://collector-2/v1/traces"),
279
+ );
280
+
281
+ await second.shutdown();
282
+ expect(registeredTracerProvider()).toBeInstanceOf(TracerProvider);
283
+
284
+ trace.getTracer("global").startSpan("after-second-shutdown").end();
285
+ await first.shutdown();
286
+ expect(registeredTracerProvider()).not.toBeInstanceOf(TracerProvider);
287
+ expect(spanNames(exporterAt(0))).toContain("after-second-shutdown");
288
+
289
+ const third = makeTracer(
290
+ tracerConfig("svc-3", "http://collector-3/v1/traces"),
291
+ );
292
+ trace.getTracer("global").startSpan("after-reinit").end();
293
+ await third.shutdown();
294
+
295
+ expect(spanNames(exporterAt(2))).toEqual(["after-reinit"]);
296
+ expect(warnSpy).toHaveBeenCalledTimes(1);
297
+ });
298
+
299
+ it("shuts down idempotently", async () => {
300
+ const tracer = makeTracer(
301
+ tracerConfig("svc-1", "http://collector-1/v1/traces"),
302
+ );
303
+ tracer.startSpan("only-span").end();
304
+
305
+ await tracer.shutdown();
306
+ await tracer.shutdown();
307
+
308
+ expect(exporterAt(0).shutdown).toHaveBeenCalledTimes(1);
309
+ expect(spanNames(exporterAt(0))).toEqual(["only-span"]);
310
+ });
311
+
312
+ it("emits no duplicate-registration diagnostics for a normal startup", async () => {
313
+ const errors: unknown[][] = [];
314
+ diag.setLogger(
315
+ {
316
+ error: (...args: unknown[]) => errors.push(args),
317
+ warn: () => {},
318
+ info: () => {},
319
+ debug: () => {},
320
+ verbose: () => {},
321
+ },
322
+ DiagLogLevel.ERROR,
323
+ );
324
+ try {
325
+ makeTracer(tracerConfig("svc-1", "http://collector-1/v1/traces"));
326
+ makeTracer(tracerConfig("svc-2", "http://collector-2/v1/traces"));
327
+ } finally {
328
+ diag.disable();
329
+ }
330
+
331
+ expect(errors).toEqual([]);
332
+ expect(warnSpy).toHaveBeenCalledTimes(1);
333
+ });
334
+ });
168
335
  });
@@ -1,4 +1,5 @@
1
- import opentelemetry, {
1
+ import {
2
+ context,
2
3
  defaultTextMapSetter,
3
4
  ROOT_CONTEXT,
4
5
  SpanKind,
@@ -22,6 +23,21 @@ import {
22
23
  import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
23
24
  import { createOTelResource } from "./utils/createResource";
24
25
 
26
+ let globalTracerProvider: TracerProvider | undefined;
27
+ let contextManagerInitialized = false;
28
+
29
+ function getRegisteredTracerProvider(): unknown {
30
+ const provider = trace.getTracerProvider() as unknown as {
31
+ getDelegate?: () => unknown;
32
+ };
33
+ return typeof provider.getDelegate === "function"
34
+ ? provider.getDelegate()
35
+ : provider;
36
+ }
37
+
38
+ const ALREADY_REGISTERED_WARNING =
39
+ "[otel-utils] A global tracer provider is already registered; StandardTracer will not replace it. This instance still exports spans through its own provider, but trace.getTracer() keeps using the existing provider.";
40
+
25
41
  /**
26
42
  * Options accepted by {@link StandardTracer.startSpan}.
27
43
  */
@@ -44,6 +60,8 @@ export class StandardTracer {
44
60
  private tracer: Tracer;
45
61
  private serviceVersion: string;
46
62
  private serviceName: string;
63
+ private ownsGlobalProvider = false;
64
+ private shutdownPromise?: Promise<void>;
47
65
 
48
66
  constructor(config: ConfigOTelInterface) {
49
67
  this.serviceName = config.SERVICE_ID;
@@ -67,19 +85,20 @@ export class StandardTracer {
67
85
  resource: createOTelResource(this.serviceName, this.serviceVersion),
68
86
  spanProcessors,
69
87
  });
70
- // OTel v3 removed TracerProvider.register(): the provider and the context
71
- // manager must be installed on the global API explicitly.
72
- trace.setGlobalTracerProvider(this.traceProvider);
73
- const contextManager = new AsyncLocalStorageContextManager();
74
- contextManager.enable();
75
- opentelemetry.context.setGlobalContextManager(contextManager);
76
- this.tracer = opentelemetry.trace.getTracer(
88
+ // Resolve the tracer from this instance's provider: the global API may be
89
+ // owned by another instance and would silently export through it.
90
+ this.tracer = this.traceProvider.getTracer(
77
91
  `${this.serviceName}:${this.serviceVersion}`,
78
92
  );
93
+ this.registerGlobalProvider();
94
+ initializeContextManager();
79
95
  }
80
96
 
81
- public async shutdown(): Promise<void> {
82
- await this.traceProvider.shutdown();
97
+ public shutdown(): Promise<void> {
98
+ if (!this.shutdownPromise) {
99
+ this.shutdownPromise = this.doShutdown();
100
+ }
101
+ return this.shutdownPromise;
83
102
  }
84
103
 
85
104
  public startSpan(
@@ -95,7 +114,7 @@ export class StandardTracer {
95
114
  return this.tracer.startSpan(
96
115
  sanitizedName,
97
116
  options,
98
- opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan),
117
+ trace.setSpan(context.active(), parentSpan),
99
118
  ) as Span;
100
119
  }
101
120
  if (options) {
@@ -120,4 +139,50 @@ export class StandardTracer {
120
139
  );
121
140
  return headers;
122
141
  }
142
+
143
+ private registerGlobalProvider(): void {
144
+ if (globalTracerProvider) {
145
+ if (getRegisteredTracerProvider() === globalTracerProvider) {
146
+ console.warn(ALREADY_REGISTERED_WARNING);
147
+ return;
148
+ }
149
+ // The previous provider is no longer registered (disabled elsewhere).
150
+ globalTracerProvider = undefined;
151
+ }
152
+ if (trace.setGlobalTracerProvider(this.traceProvider)) {
153
+ globalTracerProvider = this.traceProvider;
154
+ this.ownsGlobalProvider = true;
155
+ } else {
156
+ console.warn(ALREADY_REGISTERED_WARNING);
157
+ }
158
+ }
159
+
160
+ private async doShutdown(): Promise<void> {
161
+ await this.traceProvider.shutdown();
162
+ if (
163
+ this.ownsGlobalProvider &&
164
+ getRegisteredTracerProvider() === this.traceProvider
165
+ ) {
166
+ trace.disable();
167
+ }
168
+ if (globalTracerProvider === this.traceProvider) {
169
+ globalTracerProvider = undefined;
170
+ }
171
+ this.ownsGlobalProvider = false;
172
+ }
173
+ }
174
+
175
+ function initializeContextManager(): void {
176
+ if (contextManagerInitialized) {
177
+ return;
178
+ }
179
+ contextManagerInitialized = true;
180
+ const contextManager = new AsyncLocalStorageContextManager();
181
+ if (context.setGlobalContextManager(contextManager)) {
182
+ contextManager.enable();
183
+ } else {
184
+ console.warn(
185
+ "[otel-utils] A global context manager is already registered; StandardTracer will keep using it for context propagation.",
186
+ );
187
+ }
123
188
  }