@devopsplaybook.io/otel-utils 1.5.3 → 1.6.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.
@@ -0,0 +1,75 @@
1
+ import { registerShutdownHooks, shutdownAll } from "./ShutdownHooks";
2
+
3
+ describe("shutdownAll", () => {
4
+ it("shuts down every provider", async () => {
5
+ const first = { shutdown: jest.fn().mockResolvedValue(undefined) };
6
+ const second = { shutdown: jest.fn().mockResolvedValue(undefined) };
7
+
8
+ await shutdownAll([first, second]);
9
+
10
+ expect(first.shutdown).toHaveBeenCalledTimes(1);
11
+ expect(second.shutdown).toHaveBeenCalledTimes(1);
12
+ });
13
+
14
+ it("resolves when a provider fails and reports it", async () => {
15
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
16
+ const failure = new Error("boom");
17
+ const failing = { shutdown: jest.fn().mockRejectedValue(failure) };
18
+ const healthy = { shutdown: jest.fn().mockResolvedValue(undefined) };
19
+
20
+ await expect(shutdownAll([failing, healthy])).resolves.toBeUndefined();
21
+
22
+ expect(healthy.shutdown).toHaveBeenCalledTimes(1);
23
+ expect(warnSpy).toHaveBeenCalledWith(
24
+ "[otel-utils] shutdown failed",
25
+ failure,
26
+ );
27
+ warnSpy.mockRestore();
28
+ });
29
+ });
30
+
31
+ describe("registerShutdownHooks", () => {
32
+ it("flushes the providers and exits once on SIGTERM/SIGINT", async () => {
33
+ const exitSpy = jest
34
+ .spyOn(process, "exit")
35
+ .mockImplementation((() => undefined) as never);
36
+ const provider = { shutdown: jest.fn().mockResolvedValue(undefined) };
37
+ const sigtermListeners = process.listenerCount("SIGTERM");
38
+ const sigintListeners = process.listenerCount("SIGINT");
39
+
40
+ const cleanup = registerShutdownHooks([provider]);
41
+ try {
42
+ expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners + 1);
43
+ expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1);
44
+
45
+ process.emit("SIGTERM");
46
+ await new Promise((resolve) => setImmediate(resolve));
47
+ expect(provider.shutdown).toHaveBeenCalledTimes(1);
48
+ expect(exitSpy).toHaveBeenCalledWith(0);
49
+
50
+ process.emit("SIGINT");
51
+ await new Promise((resolve) => setImmediate(resolve));
52
+ expect(provider.shutdown).toHaveBeenCalledTimes(1);
53
+ expect(exitSpy).toHaveBeenCalledTimes(1);
54
+ } finally {
55
+ cleanup();
56
+ exitSpy.mockRestore();
57
+ }
58
+
59
+ expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners);
60
+ expect(process.listenerCount("SIGINT")).toBe(sigintListeners);
61
+ });
62
+
63
+ it("the cleanup function removes both listeners without shutting down", () => {
64
+ const provider = { shutdown: jest.fn().mockResolvedValue(undefined) };
65
+ const sigtermListeners = process.listenerCount("SIGTERM");
66
+ const sigintListeners = process.listenerCount("SIGINT");
67
+
68
+ const cleanup = registerShutdownHooks([provider]);
69
+ cleanup();
70
+
71
+ expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners);
72
+ expect(process.listenerCount("SIGINT")).toBe(sigintListeners);
73
+ expect(provider.shutdown).not.toHaveBeenCalled();
74
+ });
75
+ });
@@ -0,0 +1,45 @@
1
+ export interface ShutdownableInterface {
2
+ shutdown(): Promise<void>;
3
+ }
4
+
5
+ /**
6
+ * Flushes and shuts down every provider, resolving even when one of them
7
+ * fails (the failure is reported with `console.warn`).
8
+ */
9
+ export async function shutdownAll(
10
+ providers: ReadonlyArray<ShutdownableInterface>,
11
+ ): Promise<void> {
12
+ await Promise.all(
13
+ providers.map((provider) =>
14
+ provider.shutdown().catch((error: unknown) => {
15
+ console.warn("[otel-utils] shutdown failed", error);
16
+ }),
17
+ ),
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Registers one-shot `SIGTERM`/`SIGINT` handlers that flush and shut down all
23
+ * providers, then exit the process with code 0.
24
+ *
25
+ * Register once with every configured provider. The returned function removes
26
+ * the handlers without shutting anything down.
27
+ */
28
+ export function registerShutdownHooks(
29
+ providers: ReadonlyArray<ShutdownableInterface>,
30
+ ): () => void {
31
+ let shuttingDown = false;
32
+ const onSignal = (): void => {
33
+ if (shuttingDown) {
34
+ return;
35
+ }
36
+ shuttingDown = true;
37
+ void shutdownAll(providers).then(() => process.exit(0));
38
+ };
39
+ process.once("SIGTERM", onSignal);
40
+ process.once("SIGINT", onSignal);
41
+ return () => {
42
+ process.removeListener("SIGTERM", onSignal);
43
+ process.removeListener("SIGINT", onSignal);
44
+ };
45
+ }
@@ -1,6 +1,7 @@
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 { ConfigOTelInterface } from "./models/ConfigOTelInterface";
4
5
  import { StandardLogger } from "./StandardLogger";
5
6
 
6
7
  jest.mock("@opentelemetry/exporter-logs-otlp-http", () => {
@@ -34,12 +35,19 @@ interface MockLogExporter {
34
35
 
35
36
  const mockExporterConstructor = OTLPLogExporter as unknown as jest.Mock;
36
37
 
38
+ function exporterAt(index: number): MockLogExporter {
39
+ return mockExporterConstructor.mock.results[index].value as MockLogExporter;
40
+ }
41
+
37
42
  function lastExporter(): MockLogExporter {
38
- const results = mockExporterConstructor.mock.results;
39
- return results[results.length - 1].value as MockLogExporter;
43
+ return exporterAt(mockExporterConstructor.mock.results.length - 1);
44
+ }
45
+
46
+ function exportedBodies(exporter: MockLogExporter): unknown[] {
47
+ return exporter.getExportedRecords().map((record) => record.body);
40
48
  }
41
49
 
42
- function collectorConfig(logsUrl: string) {
50
+ function collectorConfig(logsUrl: string): ConfigOTelInterface {
43
51
  return {
44
52
  SERVICE_ID: "log-service",
45
53
  VERSION: "1.0.0",
@@ -48,18 +56,35 @@ function collectorConfig(logsUrl: string) {
48
56
  }
49
57
 
50
58
  describe("StandardLogger", () => {
59
+ const createdLoggers: StandardLogger[] = [];
60
+ let warnSpy: jest.SpyInstance;
61
+ let logSpy: jest.SpyInstance;
62
+
63
+ function makeLogger(): StandardLogger {
64
+ const logger = new StandardLogger();
65
+ createdLoggers.push(logger);
66
+ return logger;
67
+ }
68
+
51
69
  beforeEach(() => {
52
70
  jest.clearAllMocks();
53
71
  logs.disable();
72
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
73
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
54
74
  });
55
75
 
56
- afterEach(() => {
76
+ afterEach(async () => {
77
+ for (const logger of createdLoggers.splice(0)) {
78
+ await logger.shutdown();
79
+ }
57
80
  logs.disable();
81
+ warnSpy.mockRestore();
82
+ logSpy.mockRestore();
58
83
  });
59
84
 
60
85
  it("builds the log exporter with the collector URL and no auth header", async () => {
61
- const logger = new StandardLogger();
62
- logger.initOTel(collectorConfig("http://collector:4318/v1/logs"));
86
+ const logger = makeLogger();
87
+ await logger.initOTel(collectorConfig("http://collector:4318/v1/logs"));
63
88
 
64
89
  expect(mockExporterConstructor).toHaveBeenCalledTimes(1);
65
90
  expect(mockExporterConstructor.mock.calls[0][0]).toEqual({
@@ -70,8 +95,8 @@ describe("StandardLogger", () => {
70
95
  });
71
96
 
72
97
  it("adds the Authorization header when configured", async () => {
73
- const logger = new StandardLogger();
74
- logger.initOTel({
98
+ const logger = makeLogger();
99
+ await logger.initOTel({
75
100
  ...collectorConfig("http://collector:4318/v1/logs"),
76
101
  OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER: "secret",
77
102
  });
@@ -84,16 +109,16 @@ describe("StandardLogger", () => {
84
109
  });
85
110
 
86
111
  it("registers the provider as the global logger provider", async () => {
87
- const logger = new StandardLogger();
88
- logger.initOTel(collectorConfig("http://collector:4318/v1/logs"));
112
+ const logger = makeLogger();
113
+ await logger.initOTel(collectorConfig("http://collector:4318/v1/logs"));
89
114
 
90
115
  expect(logs.getLoggerProvider()).toBeInstanceOf(LoggerProvider);
91
116
  await logger.shutdown();
92
117
  });
93
118
 
94
119
  it("flushes OTel logs and module logger records on shutdown", async () => {
95
- const logger = new StandardLogger();
96
- logger.initOTel({
120
+ const logger = makeLogger();
121
+ await logger.initOTel({
97
122
  ...collectorConfig("http://collector:4318/v1/logs"),
98
123
  OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: 5,
99
124
  });
@@ -107,22 +132,105 @@ describe("StandardLogger", () => {
107
132
 
108
133
  await logger.shutdown();
109
134
 
110
- const bodies = lastExporter()
111
- .getExportedRecords()
112
- .map((record) => record.body);
113
- expect(bodies).toEqual(
135
+ expect(exportedBodies(lastExporter())).toEqual(
114
136
  expect.arrayContaining(["from-global", "[billing] hello"]),
115
137
  );
116
138
  expect(lastExporter().shutdown).toHaveBeenCalled();
117
139
  });
118
140
 
119
141
  it("does not register a global provider without a logs endpoint", async () => {
120
- const logger = new StandardLogger();
121
- logger.initOTel({ SERVICE_ID: "log-service", VERSION: "1.0.0" });
142
+ const logger = makeLogger();
143
+ await logger.initOTel({ SERVICE_ID: "log-service", VERSION: "1.0.0" });
122
144
 
123
145
  expect(logger.getLogger()).toBeUndefined();
124
146
  expect(mockExporterConstructor).not.toHaveBeenCalled();
125
147
  expect(logs.getLoggerProvider()).not.toBeInstanceOf(LoggerProvider);
126
148
  await expect(logger.shutdown()).resolves.toBeUndefined();
127
149
  });
150
+
151
+ it("routes each instance to its own exporter and warns on the second registration", async () => {
152
+ const first = makeLogger();
153
+ const second = makeLogger();
154
+ await first.initOTel(collectorConfig("http://collector-1/v1/logs"));
155
+ await second.initOTel(collectorConfig("http://collector-2/v1/logs"));
156
+
157
+ first.createModuleLogger("first").info("from-first");
158
+ second.createModuleLogger("second").info("from-second");
159
+ logs.getLogger("external-lib").emit({
160
+ body: "from-global",
161
+ severityNumber: SeverityNumber.INFO,
162
+ });
163
+
164
+ await first.shutdown();
165
+ await second.shutdown();
166
+
167
+ expect(exportedBodies(exporterAt(0))).toEqual(
168
+ expect.arrayContaining(["[first] from-first", "from-global"]),
169
+ );
170
+ expect(exportedBodies(exporterAt(1))).toEqual(["[second] from-second"]);
171
+ expect(warnSpy).toHaveBeenCalledTimes(1);
172
+ expect(warnSpy).toHaveBeenCalledWith(
173
+ expect.stringContaining("A global logger provider is already registered"),
174
+ );
175
+ });
176
+
177
+ it("re-initialisation flushes the previous provider and keeps records", async () => {
178
+ const logger = makeLogger();
179
+ await logger.initOTel(collectorConfig("http://collector-1/v1/logs"));
180
+ logger.createModuleLogger("billing").info("before-reinit");
181
+
182
+ await logger.initOTel(collectorConfig("http://collector-2/v1/logs"));
183
+
184
+ expect(exportedBodies(exporterAt(0))).toContain("[billing] before-reinit");
185
+ expect(logs.getLoggerProvider()).toBeInstanceOf(LoggerProvider);
186
+
187
+ logs.getLogger("external-lib").emit({
188
+ body: "after-reinit",
189
+ severityNumber: SeverityNumber.INFO,
190
+ });
191
+ logger.createModuleLogger("billing").info("after-reinit-module");
192
+ await logger.shutdown();
193
+
194
+ expect(exportedBodies(exporterAt(1))).toEqual(
195
+ expect.arrayContaining(["after-reinit", "[billing] after-reinit-module"]),
196
+ );
197
+ });
198
+
199
+ it("can re-initialise after shutdown", async () => {
200
+ const logger = makeLogger();
201
+ await logger.initOTel(collectorConfig("http://collector/v1/logs"));
202
+ await logger.shutdown();
203
+
204
+ expect(logger.getLogger()).toBeUndefined();
205
+ expect(logs.getLoggerProvider()).not.toBeInstanceOf(LoggerProvider);
206
+
207
+ await logger.initOTel(collectorConfig("http://collector/v1/logs"));
208
+ expect(logger.getLogger()).toBeDefined();
209
+ expect(logs.getLoggerProvider()).toBeInstanceOf(LoggerProvider);
210
+
211
+ logs.getLogger("external-lib").emit({
212
+ body: "after-restart",
213
+ severityNumber: SeverityNumber.INFO,
214
+ });
215
+ await logger.shutdown();
216
+
217
+ expect(exportedBodies(exporterAt(1))).toContain("after-restart");
218
+ });
219
+
220
+ it("shuts down idempotently and leaves the global API on Noop", async () => {
221
+ const logger = makeLogger();
222
+ await logger.initOTel(collectorConfig("http://collector/v1/logs"));
223
+ logger.createModuleLogger("billing").info("recorded");
224
+
225
+ await logger.shutdown();
226
+ await logger.shutdown();
227
+
228
+ expect(exporterAt(0).shutdown).toHaveBeenCalledTimes(1);
229
+ expect(exportedBodies(exporterAt(0))).toContain("[billing] recorded");
230
+ expect(logger.getLogger()).toBeUndefined();
231
+ expect(logs.getLoggerProvider()).not.toBeInstanceOf(LoggerProvider);
232
+ expect(() =>
233
+ logs.getLogger("external-lib").emit({ body: "dropped" }),
234
+ ).not.toThrow();
235
+ });
128
236
  });
@@ -10,49 +10,66 @@ import { ModuleLogger } from "./ModuleLogger";
10
10
 
11
11
  import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
12
12
 
13
+ let globalLoggerProvider: LoggerProvider | undefined;
14
+
15
+ const ALREADY_REGISTERED_WARNING =
16
+ "[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.";
17
+
13
18
  export class StandardLogger implements StandardLoggerInterface {
14
19
  private logger?: OTelLogger;
15
20
  private loggerProvider?: LoggerProvider;
16
21
  private serviceVersion?: string;
17
22
  private serviceName?: string;
23
+ private ownsGlobalProvider = false;
24
+ private shutdownPromise?: Promise<void>;
18
25
 
19
- public initOTel(config: ConfigOTelInterface) {
26
+ public async initOTel(config: ConfigOTelInterface): Promise<void> {
27
+ // Re-initialisation flushes and unregisters the previous provider first.
28
+ if (this.loggerProvider) {
29
+ await this.releaseProvider();
30
+ }
31
+ this.shutdownPromise = undefined;
20
32
  this.serviceName = config.SERVICE_ID;
21
33
  this.serviceVersion = config.VERSION;
22
34
 
23
- if (config.OPENTELEMETRY_COLLECTOR_HTTP_LOGS) {
24
- const exporterHeaders: Record<string, string> = {};
25
- if (config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER) {
26
- exporterHeaders["Authorization"] =
27
- `Bearer ${config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER}`;
28
- }
29
- const exporter = new OTLPLogExporter({
30
- url: config.OPENTELEMETRY_COLLECTOR_HTTP_LOGS,
31
- headers: exporterHeaders,
32
- });
33
-
34
- this.loggerProvider = new LoggerProvider({
35
- processors: [
36
- new BatchLogRecordProcessor({
37
- exporter,
38
- maxQueueSize: 2048,
39
- scheduledDelayMillis:
40
- (config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS ??
41
- 60) * 1000,
42
- }),
43
- ],
44
- resource: createOTelResource(this.serviceName, this.serviceVersion),
45
- });
35
+ if (!config.OPENTELEMETRY_COLLECTOR_HTTP_LOGS) {
36
+ return;
37
+ }
46
38
 
47
- logs.setGlobalLoggerProvider(this.loggerProvider);
48
- this.logger = this.loggerProvider.getLogger(
49
- `${this.serviceName}:${this.serviceVersion}`,
50
- );
39
+ const exporterHeaders: Record<string, string> = {};
40
+ if (config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER) {
41
+ exporterHeaders["Authorization"] =
42
+ `Bearer ${config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER}`;
51
43
  }
44
+ const exporter = new OTLPLogExporter({
45
+ url: config.OPENTELEMETRY_COLLECTOR_HTTP_LOGS,
46
+ headers: exporterHeaders,
47
+ });
48
+
49
+ this.loggerProvider = new LoggerProvider({
50
+ processors: [
51
+ new BatchLogRecordProcessor({
52
+ exporter,
53
+ maxQueueSize: 2048,
54
+ scheduledDelayMillis:
55
+ (config.OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS ??
56
+ 60) * 1000,
57
+ }),
58
+ ],
59
+ resource: createOTelResource(this.serviceName, this.serviceVersion),
60
+ });
61
+
62
+ this.logger = this.loggerProvider.getLogger(
63
+ `${this.serviceName}:${this.serviceVersion}`,
64
+ );
65
+ this.registerGlobalProvider();
52
66
  }
53
67
 
54
- public async shutdown(): Promise<void> {
55
- await this.loggerProvider?.shutdown();
68
+ public shutdown(): Promise<void> {
69
+ if (!this.shutdownPromise) {
70
+ this.shutdownPromise = this.releaseProvider();
71
+ }
72
+ return this.shutdownPromise;
56
73
  }
57
74
 
58
75
  public getLogger(): OTelLogger | undefined {
@@ -62,4 +79,42 @@ export class StandardLogger implements StandardLoggerInterface {
62
79
  public createModuleLogger(moduleName: string): ModuleLogger {
63
80
  return new ModuleLogger(moduleName, this);
64
81
  }
82
+
83
+ private registerGlobalProvider(): void {
84
+ if (globalLoggerProvider) {
85
+ if (globalLoggerProvider === logs.getLoggerProvider()) {
86
+ console.warn(ALREADY_REGISTERED_WARNING);
87
+ return;
88
+ }
89
+ // The previous provider is no longer registered (disabled elsewhere).
90
+ globalLoggerProvider = undefined;
91
+ }
92
+ if (
93
+ this.loggerProvider &&
94
+ logs.setGlobalLoggerProvider(this.loggerProvider)
95
+ ) {
96
+ globalLoggerProvider = this.loggerProvider;
97
+ this.ownsGlobalProvider = true;
98
+ } else {
99
+ console.warn(ALREADY_REGISTERED_WARNING);
100
+ }
101
+ }
102
+
103
+ private async releaseProvider(): Promise<void> {
104
+ const provider = this.loggerProvider;
105
+ const ownedGlobalProvider = this.ownsGlobalProvider;
106
+ this.logger = undefined;
107
+ this.loggerProvider = undefined;
108
+ this.ownsGlobalProvider = false;
109
+ if (!provider) {
110
+ return;
111
+ }
112
+ await provider.shutdown();
113
+ if (ownedGlobalProvider && logs.getLoggerProvider() === provider) {
114
+ logs.disable();
115
+ }
116
+ if (globalLoggerProvider === provider) {
117
+ globalLoggerProvider = undefined;
118
+ }
119
+ }
65
120
  }
@@ -1,5 +1,10 @@
1
+ import { metrics } from "@opentelemetry/api";
1
2
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
2
- import type { InMemoryMetricExporter } from "@opentelemetry/sdk-metrics";
3
+ import {
4
+ type InMemoryMetricExporter,
5
+ MeterProvider,
6
+ } from "@opentelemetry/sdk-metrics";
7
+ import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
3
8
  import { StandardMeter } from "./StandardMeter";
4
9
 
5
10
  jest.mock("@opentelemetry/exporter-metrics-otlp-http", () => {
@@ -18,9 +23,13 @@ jest.mock("@opentelemetry/exporter-metrics-otlp-http", () => {
18
23
 
19
24
  const mockExporterConstructor = OTLPMetricExporter as unknown as jest.Mock;
20
25
 
26
+ function exporterAt(index: number): InMemoryMetricExporter {
27
+ return mockExporterConstructor.mock.results[index]
28
+ .value as InMemoryMetricExporter;
29
+ }
30
+
21
31
  function lastExporter(): InMemoryMetricExporter {
22
- const results = mockExporterConstructor.mock.results;
23
- return results[results.length - 1].value as InMemoryMetricExporter;
32
+ return exporterAt(mockExporterConstructor.mock.results.length - 1);
24
33
  }
25
34
 
26
35
  function metricNames(exporter: InMemoryMetricExporter): string[] {
@@ -31,13 +40,43 @@ function metricNames(exporter: InMemoryMetricExporter): string[] {
31
40
  .map((metric) => metric.descriptor.name);
32
41
  }
33
42
 
43
+ function meterConfig(
44
+ serviceId: string,
45
+ url?: string,
46
+ ): ConfigOTelInterface {
47
+ return {
48
+ SERVICE_ID: serviceId,
49
+ VERSION: "1.0.0",
50
+ OPENTELEMETRY_COLLECTOR_HTTP_METRICS: url,
51
+ };
52
+ }
53
+
34
54
  describe("StandardMeter", () => {
55
+ const createdMeters: StandardMeter[] = [];
56
+ let warnSpy: jest.SpyInstance;
57
+
58
+ function makeMeter(config: ConfigOTelInterface): StandardMeter {
59
+ const meter = new StandardMeter(config);
60
+ createdMeters.push(meter);
61
+ return meter;
62
+ }
63
+
35
64
  beforeEach(() => {
36
65
  jest.clearAllMocks();
66
+ metrics.disable();
67
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
68
+ });
69
+
70
+ afterEach(async () => {
71
+ for (const meter of createdMeters.splice(0)) {
72
+ await meter.shutdown();
73
+ }
74
+ metrics.disable();
75
+ warnSpy.mockRestore();
37
76
  });
38
77
 
39
78
  it("builds the metric exporter with URL and concurrency limit and honors the export interval", async () => {
40
- const meter = new StandardMeter({
79
+ const meter = makeMeter({
41
80
  SERVICE_ID: "metric-service",
42
81
  VERSION: "1.0.0",
43
82
  OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
@@ -53,7 +92,7 @@ describe("StandardMeter", () => {
53
92
  });
54
93
 
55
94
  it("adds the Authorization header when configured", async () => {
56
- const meter = new StandardMeter({
95
+ const meter = makeMeter({
57
96
  SERVICE_ID: "metric-service",
58
97
  VERSION: "1.0.0",
59
98
  OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
@@ -69,11 +108,9 @@ describe("StandardMeter", () => {
69
108
  });
70
109
 
71
110
  it("creates the four metric types and flushes them on shutdown", async () => {
72
- const meter = new StandardMeter({
73
- SERVICE_ID: "metric-service",
74
- VERSION: "1.0.0",
75
- OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
76
- });
111
+ const meter = makeMeter(
112
+ meterConfig("metric-service", "http://collector:4318/v1/metrics"),
113
+ );
77
114
 
78
115
  meter.createCounter("requests.total").add(1);
79
116
  meter.createUpDownCounter("users.active").add(1);
@@ -98,11 +135,9 @@ describe("StandardMeter", () => {
98
135
  });
99
136
 
100
137
  it("creates a histogram without the service-name prefix when the unprefixed option is set", async () => {
101
- const meter = new StandardMeter({
102
- SERVICE_ID: "metric-service",
103
- VERSION: "1.0.0",
104
- OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
105
- });
138
+ const meter = makeMeter(
139
+ meterConfig("metric-service", "http://collector:4318/v1/metrics"),
140
+ );
106
141
 
107
142
  meter.createHistogram("session.duration", { unprefixed: true }).record(1);
108
143
  meter.createHistogram("requests.latency").record(42);
@@ -115,13 +150,13 @@ describe("StandardMeter", () => {
115
150
  expect(names).not.toContain("metric-service.session.duration");
116
151
  });
117
152
 
118
- it("works without a collector and without a gauge description", async () => {
119
- const meter = new StandardMeter({
120
- SERVICE_ID: "metric-service",
121
- VERSION: "1.0.0",
122
- });
153
+ it("works without a collector, leaves the global API untouched and without a gauge description", async () => {
154
+ const meter = makeMeter(meterConfig("metric-service"));
123
155
 
124
156
  expect(mockExporterConstructor).not.toHaveBeenCalled();
157
+ expect(metrics.getMeterProvider()).not.toBeInstanceOf(MeterProvider);
158
+ expect(warnSpy).not.toHaveBeenCalled();
159
+
125
160
  meter.createCounter("requests.total").add(1);
126
161
  const gauge = meter.createObservableGauge("cpu.usage", (observableResult) =>
127
162
  observableResult.observe(0.5),
@@ -129,4 +164,65 @@ describe("StandardMeter", () => {
129
164
  expect(gauge).toBeDefined();
130
165
  await expect(meter.shutdown()).resolves.toBeUndefined();
131
166
  });
167
+
168
+ it("registers a configured meter as the global provider", async () => {
169
+ const meter = makeMeter(
170
+ meterConfig("metric-service", "http://collector-1/v1/metrics"),
171
+ );
172
+
173
+ expect(metrics.getMeterProvider()).toBeInstanceOf(MeterProvider);
174
+ metrics.getMeter("global").createCounter("global.counter").add(1);
175
+
176
+ await meter.shutdown();
177
+
178
+ expect(metricNames(exporterAt(0))).toContain("global.counter");
179
+ });
180
+
181
+ it("keeps each instance on its own exporter and warns on the second registration", async () => {
182
+ const first = makeMeter(
183
+ meterConfig("metric-a", "http://collector-1/v1/metrics"),
184
+ );
185
+ const second = makeMeter(
186
+ meterConfig("metric-b", "http://collector-2/v1/metrics"),
187
+ );
188
+
189
+ first.createCounter("a.total").add(1);
190
+ second.createCounter("b.total").add(1);
191
+ metrics.getMeter("global").createCounter("global.counter").add(1);
192
+
193
+ await first.shutdown();
194
+ await second.shutdown();
195
+
196
+ expect(metricNames(exporterAt(0))).toEqual(
197
+ expect.arrayContaining(["metric-a.a.total", "global.counter"]),
198
+ );
199
+ expect(metricNames(exporterAt(1))).toEqual(["metric-b.b.total"]);
200
+ expect(warnSpy).toHaveBeenCalledTimes(1);
201
+ expect(warnSpy).toHaveBeenCalledWith(
202
+ expect.stringContaining("A global meter provider is already registered"),
203
+ );
204
+ });
205
+
206
+ it("owner shutdown releases the global provider so a new meter can register", async () => {
207
+ const first = makeMeter(
208
+ meterConfig("metric-a", "http://collector-1/v1/metrics"),
209
+ );
210
+ expect(metrics.getMeterProvider()).toBeInstanceOf(MeterProvider);
211
+
212
+ await first.shutdown();
213
+ expect(metrics.getMeterProvider()).not.toBeInstanceOf(MeterProvider);
214
+
215
+ const second = makeMeter(
216
+ meterConfig("metric-b", "http://collector-2/v1/metrics"),
217
+ );
218
+ expect(metrics.getMeterProvider()).toBeInstanceOf(MeterProvider);
219
+ const shutdownSpy = jest.spyOn(exporterAt(1), "shutdown");
220
+
221
+ metrics.getMeter("global").createCounter("after-reinit").add(1);
222
+ await second.shutdown();
223
+ await second.shutdown();
224
+
225
+ expect(metricNames(exporterAt(1))).toContain("after-reinit");
226
+ expect(shutdownSpy).toHaveBeenCalledTimes(1);
227
+ });
132
228
  });