@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.
- package/README.md +43 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/ShutdownHooks.d.ts +16 -0
- package/dist/src/ShutdownHooks.js +36 -0
- package/dist/src/StandardLogger.d.ts +5 -1
- package/dist/src/StandardLogger.js +74 -26
- package/dist/src/StandardMeter.d.ts +4 -0
- package/dist/src/StandardMeter.js +42 -2
- package/dist/src/StandardTracer.d.ts +4 -0
- package/dist/src/StandardTracer.js +63 -44
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/ShutdownHooks.spec.ts +75 -0
- package/src/ShutdownHooks.ts +45 -0
- package/src/StandardLogger.spec.ts +126 -18
- package/src/StandardLogger.ts +85 -30
- package/src/StandardMeter.spec.ts +116 -20
- package/src/StandardMeter.ts +49 -2
- package/src/StandardTracer.spec.ts +187 -20
- package/src/StandardTracer.ts +76 -11
|
@@ -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
|
-
|
|
39
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
});
|
package/src/StandardLogger.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
102
|
-
|
|
103
|
-
|
|
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 =
|
|
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
|
});
|