@devopsplaybook.io/otel-utils 1.3.1 → 1.4.0-beta.32.a33ff6f
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 +20 -3
- package/dist/src/ModuleLogger.d.ts +2 -2
- package/dist/src/ModuleLogger.js +7 -11
- package/dist/src/StandardLogger.d.ts +3 -1
- package/dist/src/StandardLogger.js +8 -2
- package/dist/src/StandardMeter.d.ts +2 -0
- package/dist/src/StandardMeter.js +6 -4
- package/dist/src/StandardTracer.d.ts +4 -1
- package/dist/src/StandardTracer.js +6 -6
- package/dist/src/models/ConfigOTelInterface.d.ts +0 -1
- package/package.json +1 -4
- package/src/ModuleLogger.spec.ts +101 -0
- package/src/ModuleLogger.ts +9 -13
- package/src/StandardLogger.spec.ts +128 -0
- package/src/StandardLogger.ts +9 -3
- package/src/StandardMeter.spec.ts +114 -0
- package/src/StandardMeter.ts +8 -4
- package/src/StandardTracer.spec.ts +100 -0
- package/src/StandardTracer.ts +10 -7
- package/src/models/ConfigOTelInterface.ts +0 -1
package/README.md
CHANGED
|
@@ -21,7 +21,6 @@ All classes accept a `ConfigOTelInterface` object:
|
|
|
21
21
|
| `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` | `string?` | — | OTLP HTTP endpoint for logs |
|
|
22
22
|
| `OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS` | `number?` | `60` | Log export interval |
|
|
23
23
|
| `OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS` | `number?` | `60` | Metrics export interval |
|
|
24
|
-
| `OPENTELEMETRY_COLLECTOR_AWS` | `boolean?` | — | AWS-specific feature flag |
|
|
25
24
|
| `OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER` | `string?` | — | Bearer token for collector auth |
|
|
26
25
|
|
|
27
26
|
When a collector endpoint is not provided, the corresponding signal (traces, logs, or metrics) is initialized with no export — the provider is still available but no data is sent.
|
|
@@ -50,10 +49,11 @@ span.end();
|
|
|
50
49
|
|
|
51
50
|
- `startSpan(name, parentSpan?, options?)` — Creates a span. Span names are sanitized to `[a-zA-Z0-9-_/]`. When neither `parentSpan` nor `options` is given, the span is `SpanKind.INTERNAL` and carries `http.request_method=BACKEND` and a synthetic `http.route` attribute. Passing `options` (e.g. `{ kind: SpanKind.SERVER }`) forwards them to the OpenTelemetry SDK instead and skips those synthetic attributes, because the caller then describes the span itself.
|
|
52
51
|
- `static updateHttpHeader(context, headers?)` — Injects W3C trace context into an HTTP headers object for propagation to downstream services.
|
|
52
|
+
- `shutdown()` — Flushes buffered spans and shuts the tracer provider down.
|
|
53
53
|
|
|
54
54
|
### StandardLogger
|
|
55
55
|
|
|
56
|
-
Initializes a `LoggerProvider` with an OTLP log exporter and a `BatchLogRecordProcessor` (`maxQueueSize: 2048`).
|
|
56
|
+
Initializes a `LoggerProvider` with an OTLP log exporter and a `BatchLogRecordProcessor` (`maxQueueSize: 2048`). When `OPENTELEMETRY_COLLECTOR_HTTP_LOGS` is set, the provider is also registered as the global OTel logger provider, so `logs.getLogger(...)` from `@opentelemetry/api-logs` returns loggers wired to the configured exporter. Without that endpoint, `initOTel` still records the service identity but registers nothing globally and `getLogger()` returns `undefined`.
|
|
57
57
|
|
|
58
58
|
```typescript
|
|
59
59
|
import { StandardLogger } from "@devopsplaybook.io/otel-utils";
|
|
@@ -67,9 +67,10 @@ moduleLog.info("Hello world");
|
|
|
67
67
|
|
|
68
68
|
**Methods:**
|
|
69
69
|
|
|
70
|
-
- `initOTel(config)` — Initializes the logger provider and optional OTLP exporter.
|
|
70
|
+
- `initOTel(config)` — Initializes the logger provider and optional OTLP exporter, and registers the provider globally when a logs endpoint is configured.
|
|
71
71
|
- `getLogger()` — Returns the underlying OTel `Logger` or `undefined`.
|
|
72
72
|
- `createModuleLogger(moduleName)` — Creates a `ModuleLogger` scoped to a module name.
|
|
73
|
+
- `shutdown()` — Flushes buffered log records and shuts the logger provider down. No-op when no logs endpoint is configured.
|
|
73
74
|
|
|
74
75
|
### ModuleLogger
|
|
75
76
|
|
|
@@ -122,6 +123,22 @@ const cpuGauge = meter.createObservableGauge(
|
|
|
122
123
|
- `createUpDownCounter(key)` — Creates an `UpDownCounter`.
|
|
123
124
|
- `createHistogram(key)` — Creates a `Histogram`.
|
|
124
125
|
- `createObservableGauge(key, callback, description?)` — Creates an `ObservableGauge` with a callback. Description is optional.
|
|
126
|
+
- `shutdown()` — Flushes buffered metrics and shuts the meter provider down.
|
|
127
|
+
|
|
128
|
+
## Graceful shutdown
|
|
129
|
+
|
|
130
|
+
`StandardTracer`, `StandardLogger` and `StandardMeter` buffer telemetry: `BatchSpanProcessor`, `BatchLogRecordProcessor` and `PeriodicExportingMetricReader` hold data until their next export cycle. On process exit that buffered data would be lost, so wire the `shutdown()` methods to `SIGTERM`/`SIGINT` handlers:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const shutdown = async () => {
|
|
134
|
+
await Promise.all([tracer.shutdown(), logger.shutdown(), meter.shutdown()]);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
};
|
|
137
|
+
process.on("SIGTERM", shutdown);
|
|
138
|
+
process.on("SIGINT", shutdown);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Each `shutdown()` flushes pending telemetry to the collector before resolving; the methods are idempotent and can be called even when a signal was not configured (they resolve immediately).
|
|
125
142
|
|
|
126
143
|
## Internal Utilities
|
|
127
144
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import type { Span } from "@opentelemetry/api";
|
|
1
2
|
import { StandardLoggerInterface } from "./models/StandardLoggerInterface";
|
|
2
|
-
import { Span } from "@opentelemetry/sdk-trace-base";
|
|
3
3
|
export declare class ModuleLogger {
|
|
4
4
|
private module;
|
|
5
|
-
private standardLogger
|
|
5
|
+
private standardLogger;
|
|
6
6
|
constructor(module: string, standardLogger: StandardLoggerInterface);
|
|
7
7
|
info(message: string, context?: Span): void;
|
|
8
8
|
warn(message: string, context?: Span): void;
|
package/dist/src/ModuleLogger.js
CHANGED
|
@@ -17,31 +17,27 @@ class ModuleLogger {
|
|
|
17
17
|
this.display("error", message, api_logs_1.SeverityNumber.ERROR, error, context);
|
|
18
18
|
}
|
|
19
19
|
display(level, message, severityNumber = api_logs_1.SeverityNumber.INFO, error, context) {
|
|
20
|
-
var _a, _b;
|
|
21
|
-
let formattedMessage = message;
|
|
22
20
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
21
|
const attributes = { "log.type": "custom" };
|
|
24
22
|
if (error) {
|
|
25
23
|
attributes["exception.type"] = error.name;
|
|
26
24
|
attributes["exception.message"] = error.message;
|
|
27
25
|
attributes["exception.stacktrace"] = error.stack;
|
|
28
|
-
formattedMessage += "\n" + error.stack;
|
|
29
26
|
}
|
|
30
27
|
if (context) {
|
|
31
28
|
const spanCtx = context.spanContext();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
attributes["trace.id"] = spanCtx.traceId;
|
|
35
|
-
}
|
|
29
|
+
attributes["span.id"] = spanCtx.spanId;
|
|
30
|
+
attributes["trace.id"] = spanCtx.traceId;
|
|
36
31
|
}
|
|
37
|
-
console.log(`[${level}] [${this.module}] ${
|
|
38
|
-
|
|
32
|
+
console.log(`[${level}] [${this.module}] ${message}`);
|
|
33
|
+
const logger = this.standardLogger.getLogger();
|
|
34
|
+
if (!logger) {
|
|
39
35
|
return;
|
|
40
36
|
}
|
|
41
|
-
|
|
37
|
+
logger.emit({
|
|
42
38
|
severityNumber,
|
|
43
39
|
severityText: level,
|
|
44
|
-
body: `[${this.module}] ${
|
|
40
|
+
body: `[${this.module}] ${message}`,
|
|
45
41
|
attributes,
|
|
46
42
|
});
|
|
47
43
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Logger as OTelLogger } from "@opentelemetry/api-logs";
|
|
2
2
|
import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
|
|
3
3
|
import { ModuleLogger } from "./ModuleLogger";
|
|
4
4
|
import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
|
|
5
5
|
export declare class StandardLogger implements StandardLoggerInterface {
|
|
6
6
|
private logger?;
|
|
7
|
+
private loggerProvider?;
|
|
7
8
|
private serviceVersion?;
|
|
8
9
|
private serviceName?;
|
|
9
10
|
initOTel(config: ConfigOTelInterface): void;
|
|
11
|
+
shutdown(): Promise<void>;
|
|
10
12
|
getLogger(): OTelLogger | undefined;
|
|
11
13
|
createModuleLogger(moduleName: string): ModuleLogger;
|
|
12
14
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.StandardLogger = void 0;
|
|
4
|
+
const api_logs_1 = require("@opentelemetry/api-logs");
|
|
4
5
|
const exporter_logs_otlp_http_1 = require("@opentelemetry/exporter-logs-otlp-http");
|
|
5
6
|
const sdk_logs_1 = require("@opentelemetry/sdk-logs");
|
|
6
7
|
const createResource_1 = require("./utils/createResource");
|
|
@@ -20,7 +21,7 @@ class StandardLogger {
|
|
|
20
21
|
url: config.OPENTELEMETRY_COLLECTOR_HTTP_LOGS,
|
|
21
22
|
headers: exporterHeaders,
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
+
this.loggerProvider = new sdk_logs_1.LoggerProvider({
|
|
24
25
|
processors: [
|
|
25
26
|
new sdk_logs_1.BatchLogRecordProcessor({
|
|
26
27
|
exporter,
|
|
@@ -30,9 +31,14 @@ class StandardLogger {
|
|
|
30
31
|
],
|
|
31
32
|
resource: (0, createResource_1.createOTelResource)(this.serviceName, this.serviceVersion),
|
|
32
33
|
});
|
|
33
|
-
|
|
34
|
+
api_logs_1.logs.setGlobalLoggerProvider(this.loggerProvider);
|
|
35
|
+
this.logger = this.loggerProvider.getLogger(`${this.serviceName}:${this.serviceVersion}`);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
async shutdown() {
|
|
39
|
+
var _a;
|
|
40
|
+
await ((_a = this.loggerProvider) === null || _a === void 0 ? void 0 : _a.shutdown());
|
|
41
|
+
}
|
|
36
42
|
getLogger() {
|
|
37
43
|
return this.logger;
|
|
38
44
|
}
|
|
@@ -2,9 +2,11 @@ import { Counter, Histogram, ObservableGauge, UpDownCounter } from "@opentelemet
|
|
|
2
2
|
import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
|
|
3
3
|
export declare class StandardMeter {
|
|
4
4
|
private meter;
|
|
5
|
+
private meterProvider;
|
|
5
6
|
private serviceVersion;
|
|
6
7
|
private serviceName;
|
|
7
8
|
constructor(config: ConfigOTelInterface);
|
|
9
|
+
shutdown(): Promise<void>;
|
|
8
10
|
createCounter(key: string): Counter;
|
|
9
11
|
createUpDownCounter(key: string): UpDownCounter;
|
|
10
12
|
createHistogram(key: string): Histogram;
|
|
@@ -9,7 +9,6 @@ class StandardMeter {
|
|
|
9
9
|
var _a;
|
|
10
10
|
this.serviceName = config.SERVICE_ID;
|
|
11
11
|
this.serviceVersion = config.VERSION;
|
|
12
|
-
let meterProvider;
|
|
13
12
|
if (config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS) {
|
|
14
13
|
const collectorOptions = {
|
|
15
14
|
url: config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS,
|
|
@@ -21,7 +20,7 @@ class StandardMeter {
|
|
|
21
20
|
`Bearer ${config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER}`;
|
|
22
21
|
}
|
|
23
22
|
const metricExporter = new exporter_metrics_otlp_http_1.OTLPMetricExporter(collectorOptions);
|
|
24
|
-
meterProvider = new sdk_metrics_1.MeterProvider({
|
|
23
|
+
this.meterProvider = new sdk_metrics_1.MeterProvider({
|
|
25
24
|
resource: (0, createResource_1.createOTelResource)(this.serviceName, this.serviceVersion),
|
|
26
25
|
readers: [
|
|
27
26
|
new sdk_metrics_1.PeriodicExportingMetricReader({
|
|
@@ -32,11 +31,14 @@ class StandardMeter {
|
|
|
32
31
|
});
|
|
33
32
|
}
|
|
34
33
|
else {
|
|
35
|
-
meterProvider = new sdk_metrics_1.MeterProvider({
|
|
34
|
+
this.meterProvider = new sdk_metrics_1.MeterProvider({
|
|
36
35
|
resource: (0, createResource_1.createOTelResource)(this.serviceName, this.serviceVersion),
|
|
37
36
|
});
|
|
38
37
|
}
|
|
39
|
-
this.meter = meterProvider.getMeter(`${this.serviceName}:${this.serviceVersion}`);
|
|
38
|
+
this.meter = this.meterProvider.getMeter(`${this.serviceName}:${this.serviceVersion}`);
|
|
39
|
+
}
|
|
40
|
+
async shutdown() {
|
|
41
|
+
await this.meterProvider.shutdown();
|
|
40
42
|
}
|
|
41
43
|
createCounter(key) {
|
|
42
44
|
return this.meter.createCounter(`${this.serviceName}.${key}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SpanKind } from "@opentelemetry/api";
|
|
2
|
+
import type { Span as ApiSpan } from "@opentelemetry/api";
|
|
2
3
|
import { Span } from "@opentelemetry/sdk-trace-base";
|
|
3
4
|
import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
|
|
4
5
|
/**
|
|
@@ -17,10 +18,12 @@ export interface StandardTracerStartSpanOptions {
|
|
|
17
18
|
export declare class StandardTracer {
|
|
18
19
|
private static readonly SPAN_NAME_SANITIZE_RE;
|
|
19
20
|
private static readonly PROPAGATOR;
|
|
21
|
+
private traceProvider;
|
|
20
22
|
private tracer;
|
|
21
23
|
private serviceVersion;
|
|
22
24
|
private serviceName;
|
|
23
25
|
constructor(config: ConfigOTelInterface);
|
|
26
|
+
shutdown(): Promise<void>;
|
|
24
27
|
startSpan(name: string, parentSpan?: Span, options?: StandardTracerStartSpanOptions): Span;
|
|
25
|
-
static updateHttpHeader(context:
|
|
28
|
+
static updateHttpHeader(context: ApiSpan, headers?: {}): any;
|
|
26
29
|
}
|
|
@@ -60,19 +60,22 @@ class StandardTracer {
|
|
|
60
60
|
});
|
|
61
61
|
spanProcessors.push(new sdk_trace_base_1.BatchSpanProcessor(exporter));
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
this.traceProvider = new sdk_trace_node_1.NodeTracerProvider({
|
|
64
64
|
idGenerator: new id_generator_aws_xray_1.AWSXRayIdGenerator(),
|
|
65
65
|
resource: (0, createResource_1.createOTelResource)(this.serviceName, this.serviceVersion),
|
|
66
66
|
spanProcessors,
|
|
67
67
|
});
|
|
68
|
-
traceProvider.register();
|
|
68
|
+
this.traceProvider.register();
|
|
69
69
|
const contextManager = new context_async_hooks_1.AsyncHooksContextManager();
|
|
70
70
|
contextManager.enable();
|
|
71
71
|
api_1.default.context.setGlobalContextManager(contextManager);
|
|
72
72
|
this.tracer = api_1.default.trace.getTracer(`${this.serviceName}:${this.serviceVersion}`);
|
|
73
73
|
}
|
|
74
|
+
async shutdown() {
|
|
75
|
+
await this.traceProvider.shutdown();
|
|
76
|
+
}
|
|
74
77
|
startSpan(name, parentSpan, options) {
|
|
75
|
-
const sanitizedName =
|
|
78
|
+
const sanitizedName = name.replace(StandardTracer.SPAN_NAME_SANITIZE_RE, "_");
|
|
76
79
|
if (parentSpan) {
|
|
77
80
|
return this.tracer.startSpan(sanitizedName, options, api_1.default.trace.setSpan(api_1.default.context.active(), parentSpan));
|
|
78
81
|
}
|
|
@@ -86,9 +89,6 @@ class StandardTracer {
|
|
|
86
89
|
}
|
|
87
90
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
91
|
static updateHttpHeader(context, headers = {}) {
|
|
89
|
-
if (!headers) {
|
|
90
|
-
headers = {};
|
|
91
|
-
}
|
|
92
92
|
StandardTracer.PROPAGATOR.inject(api_1.trace.setSpanContext(api_1.ROOT_CONTEXT, context.spanContext()),
|
|
93
93
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
94
|
headers, api_1.defaultTextMapSetter);
|
|
@@ -6,6 +6,5 @@ export interface ConfigOTelInterface {
|
|
|
6
6
|
OPENTELEMETRY_COLLECTOR_HTTP_LOGS?: string;
|
|
7
7
|
OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS?: number;
|
|
8
8
|
OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS?: number;
|
|
9
|
-
OPENTELEMETRY_COLLECTOR_AWS?: boolean;
|
|
10
9
|
OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER?: string;
|
|
11
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devopsplaybook.io/otel-utils",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-beta.32.a33ff6f",
|
|
4
4
|
"description": "Utility to simplify integration with Open Telemetry",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Open",
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@opentelemetry/api": "^1.9.1",
|
|
25
25
|
"@opentelemetry/api-logs": "^0.222.0",
|
|
26
|
-
"@opentelemetry/auto-instrumentations-node": "^0.80.0",
|
|
27
26
|
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
28
27
|
"@opentelemetry/core": "^2.11.0",
|
|
29
28
|
"@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
|
|
@@ -33,10 +32,8 @@
|
|
|
33
32
|
"@opentelemetry/resources": "^2.11.0",
|
|
34
33
|
"@opentelemetry/sdk-logs": "^0.222.0",
|
|
35
34
|
"@opentelemetry/sdk-metrics": "^2.11.0",
|
|
36
|
-
"@opentelemetry/sdk-node": "^0.222.0",
|
|
37
35
|
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
38
36
|
"@opentelemetry/sdk-trace-node": "^2.11.0",
|
|
39
|
-
"@opentelemetry/sdk-trace-web": "^2.11.0",
|
|
40
37
|
"@opentelemetry/semantic-conventions": "^1.43.0"
|
|
41
38
|
},
|
|
42
39
|
"devDependencies": {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Span } from "@opentelemetry/api";
|
|
2
|
+
import {
|
|
3
|
+
SeverityNumber,
|
|
4
|
+
type Logger as OTelLogger,
|
|
5
|
+
} from "@opentelemetry/api-logs";
|
|
6
|
+
import { ModuleLogger } from "./ModuleLogger";
|
|
7
|
+
import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
|
|
8
|
+
|
|
9
|
+
describe("ModuleLogger", () => {
|
|
10
|
+
let consoleSpy: jest.SpyInstance;
|
|
11
|
+
let emit: jest.Mock;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
emit = jest.fn();
|
|
15
|
+
consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
consoleSpy.mockRestore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function createLogger(
|
|
23
|
+
getLogger: () => OTelLogger | undefined,
|
|
24
|
+
): StandardLoggerInterface {
|
|
25
|
+
return { getLogger };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loggerWithEmit(): ModuleLogger {
|
|
29
|
+
const fakeLogger = { emit } as unknown as OTelLogger;
|
|
30
|
+
return new ModuleLogger("billing", createLogger(() => fakeLogger));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it("logs info to console and the OTel logger", () => {
|
|
34
|
+
loggerWithEmit().info("hello");
|
|
35
|
+
|
|
36
|
+
expect(consoleSpy).toHaveBeenCalledWith("[info] [billing] hello");
|
|
37
|
+
expect(emit).toHaveBeenCalledWith({
|
|
38
|
+
severityNumber: SeverityNumber.INFO,
|
|
39
|
+
severityText: "info",
|
|
40
|
+
body: "[billing] hello",
|
|
41
|
+
attributes: { "log.type": "custom" },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("logs warn at WARN severity", () => {
|
|
46
|
+
loggerWithEmit().warn("careful");
|
|
47
|
+
|
|
48
|
+
expect(consoleSpy).toHaveBeenCalledWith("[warn] [billing] careful");
|
|
49
|
+
expect(emit).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
severityNumber: SeverityNumber.WARN,
|
|
52
|
+
severityText: "warn",
|
|
53
|
+
body: "[billing] careful",
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("logs errors without duplicating the stack in the record body", () => {
|
|
59
|
+
const error = new Error("boom");
|
|
60
|
+
loggerWithEmit().error("request failed", error);
|
|
61
|
+
|
|
62
|
+
expect(consoleSpy).toHaveBeenCalledWith("[error] [billing] request failed");
|
|
63
|
+
expect(emit).toHaveBeenCalledWith({
|
|
64
|
+
severityNumber: SeverityNumber.ERROR,
|
|
65
|
+
severityText: "error",
|
|
66
|
+
body: "[billing] request failed",
|
|
67
|
+
attributes: {
|
|
68
|
+
"log.type": "custom",
|
|
69
|
+
"exception.type": "Error",
|
|
70
|
+
"exception.message": "boom",
|
|
71
|
+
"exception.stacktrace": error.stack,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("adds span and trace ids when a span context is provided", () => {
|
|
77
|
+
const span = {
|
|
78
|
+
spanContext: () => ({ spanId: "span-1", traceId: "trace-1" }),
|
|
79
|
+
} as unknown as Span;
|
|
80
|
+
|
|
81
|
+
loggerWithEmit().info("inside a span", span);
|
|
82
|
+
|
|
83
|
+
expect(emit).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
attributes: expect.objectContaining({
|
|
86
|
+
"span.id": "span-1",
|
|
87
|
+
"trace.id": "trace-1",
|
|
88
|
+
}),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("falls back to console only when no OTel logger is available", () => {
|
|
94
|
+
const logger = new ModuleLogger("billing", createLogger(() => undefined));
|
|
95
|
+
|
|
96
|
+
logger.warn("offline");
|
|
97
|
+
|
|
98
|
+
expect(consoleSpy).toHaveBeenCalledWith("[warn] [billing] offline");
|
|
99
|
+
expect(emit).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/ModuleLogger.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
2
|
+
import type { Span } from "@opentelemetry/api";
|
|
2
3
|
import { StandardLoggerInterface } from "./models/StandardLoggerInterface";
|
|
3
|
-
import { Span } from "@opentelemetry/sdk-trace-base";
|
|
4
4
|
|
|
5
5
|
export class ModuleLogger {
|
|
6
6
|
private module: string;
|
|
7
|
-
private standardLogger
|
|
7
|
+
private standardLogger: StandardLoggerInterface;
|
|
8
8
|
|
|
9
9
|
constructor(module: string, standardLogger: StandardLoggerInterface) {
|
|
10
10
|
this.module = module;
|
|
@@ -30,8 +30,6 @@ export class ModuleLogger {
|
|
|
30
30
|
error?: Error | null,
|
|
31
31
|
context?: Span,
|
|
32
32
|
): void {
|
|
33
|
-
let formattedMessage = message;
|
|
34
|
-
|
|
35
33
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
34
|
const attributes: Record<string, any> = { "log.type": "custom" };
|
|
37
35
|
|
|
@@ -39,25 +37,23 @@ export class ModuleLogger {
|
|
|
39
37
|
attributes["exception.type"] = error.name;
|
|
40
38
|
attributes["exception.message"] = error.message;
|
|
41
39
|
attributes["exception.stacktrace"] = error.stack;
|
|
42
|
-
formattedMessage += "\n" + error.stack;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
if (context) {
|
|
46
43
|
const spanCtx = context.spanContext();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
attributes["trace.id"] = spanCtx.traceId;
|
|
50
|
-
}
|
|
44
|
+
attributes["span.id"] = spanCtx.spanId;
|
|
45
|
+
attributes["trace.id"] = spanCtx.traceId;
|
|
51
46
|
}
|
|
52
47
|
|
|
53
|
-
console.log(`[${level}] [${this.module}] ${
|
|
54
|
-
|
|
48
|
+
console.log(`[${level}] [${this.module}] ${message}`);
|
|
49
|
+
const logger = this.standardLogger.getLogger();
|
|
50
|
+
if (!logger) {
|
|
55
51
|
return;
|
|
56
52
|
}
|
|
57
|
-
|
|
53
|
+
logger.emit({
|
|
58
54
|
severityNumber,
|
|
59
55
|
severityText: level,
|
|
60
|
-
body: `[${this.module}] ${
|
|
56
|
+
body: `[${this.module}] ${message}`,
|
|
61
57
|
attributes,
|
|
62
58
|
});
|
|
63
59
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
|
|
2
|
+
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
3
|
+
import { LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
4
|
+
import { StandardLogger } from "./StandardLogger";
|
|
5
|
+
|
|
6
|
+
jest.mock("@opentelemetry/exporter-logs-otlp-http", () => {
|
|
7
|
+
const { ExportResultCode } = jest.requireActual<
|
|
8
|
+
typeof import("@opentelemetry/core")
|
|
9
|
+
>("@opentelemetry/core");
|
|
10
|
+
return {
|
|
11
|
+
OTLPLogExporter: jest.fn().mockImplementation(function () {
|
|
12
|
+
const exportedRecords: { body?: unknown }[] = [];
|
|
13
|
+
return {
|
|
14
|
+
export: (
|
|
15
|
+
records: { body?: unknown }[],
|
|
16
|
+
resultCallback: (result: { code: number }) => void,
|
|
17
|
+
) => {
|
|
18
|
+
exportedRecords.push(...records);
|
|
19
|
+
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
20
|
+
},
|
|
21
|
+
forceFlush: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
23
|
+
getExportedRecords: () => exportedRecords,
|
|
24
|
+
};
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface MockLogExporter {
|
|
30
|
+
forceFlush: jest.Mock;
|
|
31
|
+
shutdown: jest.Mock;
|
|
32
|
+
getExportedRecords: () => { body?: unknown }[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const mockExporterConstructor = OTLPLogExporter as unknown as jest.Mock;
|
|
36
|
+
|
|
37
|
+
function lastExporter(): MockLogExporter {
|
|
38
|
+
const results = mockExporterConstructor.mock.results;
|
|
39
|
+
return results[results.length - 1].value as MockLogExporter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectorConfig(logsUrl: string) {
|
|
43
|
+
return {
|
|
44
|
+
SERVICE_ID: "log-service",
|
|
45
|
+
VERSION: "1.0.0",
|
|
46
|
+
OPENTELEMETRY_COLLECTOR_HTTP_LOGS: logsUrl,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("StandardLogger", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
jest.clearAllMocks();
|
|
53
|
+
logs.disable();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
logs.disable();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
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"));
|
|
63
|
+
|
|
64
|
+
expect(mockExporterConstructor).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(mockExporterConstructor.mock.calls[0][0]).toEqual({
|
|
66
|
+
url: "http://collector:4318/v1/logs",
|
|
67
|
+
headers: {},
|
|
68
|
+
});
|
|
69
|
+
await logger.shutdown();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("adds the Authorization header when configured", async () => {
|
|
73
|
+
const logger = new StandardLogger();
|
|
74
|
+
logger.initOTel({
|
|
75
|
+
...collectorConfig("http://collector:4318/v1/logs"),
|
|
76
|
+
OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER: "secret",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(mockExporterConstructor.mock.calls[0][0]).toEqual({
|
|
80
|
+
url: "http://collector:4318/v1/logs",
|
|
81
|
+
headers: { Authorization: "Bearer secret" },
|
|
82
|
+
});
|
|
83
|
+
await logger.shutdown();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("registers the provider as the global logger provider", async () => {
|
|
87
|
+
const logger = new StandardLogger();
|
|
88
|
+
logger.initOTel(collectorConfig("http://collector:4318/v1/logs"));
|
|
89
|
+
|
|
90
|
+
expect(logs.getLoggerProvider()).toBeInstanceOf(LoggerProvider);
|
|
91
|
+
await logger.shutdown();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("flushes api-logs and module logger records on shutdown", async () => {
|
|
95
|
+
const logger = new StandardLogger();
|
|
96
|
+
logger.initOTel({
|
|
97
|
+
...collectorConfig("http://collector:4318/v1/logs"),
|
|
98
|
+
OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS: 5,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
logs.getLogger("external-lib").emit({
|
|
102
|
+
body: "from-global",
|
|
103
|
+
severityNumber: SeverityNumber.INFO,
|
|
104
|
+
});
|
|
105
|
+
logger.createModuleLogger("billing").info("hello");
|
|
106
|
+
expect(logger.getLogger()).toBeDefined();
|
|
107
|
+
|
|
108
|
+
await logger.shutdown();
|
|
109
|
+
|
|
110
|
+
const bodies = lastExporter()
|
|
111
|
+
.getExportedRecords()
|
|
112
|
+
.map((record) => record.body);
|
|
113
|
+
expect(bodies).toEqual(
|
|
114
|
+
expect.arrayContaining(["from-global", "[billing] hello"]),
|
|
115
|
+
);
|
|
116
|
+
expect(lastExporter().shutdown).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
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" });
|
|
122
|
+
|
|
123
|
+
expect(logger.getLogger()).toBeUndefined();
|
|
124
|
+
expect(mockExporterConstructor).not.toHaveBeenCalled();
|
|
125
|
+
expect(logs.getLoggerProvider()).not.toBeInstanceOf(LoggerProvider);
|
|
126
|
+
await expect(logger.shutdown()).resolves.toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/StandardLogger.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { logs, type Logger as OTelLogger } from "@opentelemetry/api-logs";
|
|
2
2
|
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
3
3
|
import {
|
|
4
4
|
BatchLogRecordProcessor,
|
|
@@ -12,6 +12,7 @@ import type { StandardLoggerInterface } from "./models/StandardLoggerInterface";
|
|
|
12
12
|
|
|
13
13
|
export class StandardLogger implements StandardLoggerInterface {
|
|
14
14
|
private logger?: OTelLogger;
|
|
15
|
+
private loggerProvider?: LoggerProvider;
|
|
15
16
|
private serviceVersion?: string;
|
|
16
17
|
private serviceName?: string;
|
|
17
18
|
|
|
@@ -30,7 +31,7 @@ export class StandardLogger implements StandardLoggerInterface {
|
|
|
30
31
|
headers: exporterHeaders,
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
this.loggerProvider = new LoggerProvider({
|
|
34
35
|
processors: [
|
|
35
36
|
new BatchLogRecordProcessor({
|
|
36
37
|
exporter,
|
|
@@ -43,12 +44,17 @@ export class StandardLogger implements StandardLoggerInterface {
|
|
|
43
44
|
resource: createOTelResource(this.serviceName, this.serviceVersion),
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
this.
|
|
47
|
+
logs.setGlobalLoggerProvider(this.loggerProvider);
|
|
48
|
+
this.logger = this.loggerProvider.getLogger(
|
|
47
49
|
`${this.serviceName}:${this.serviceVersion}`,
|
|
48
50
|
);
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
public async shutdown(): Promise<void> {
|
|
55
|
+
await this.loggerProvider?.shutdown();
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
public getLogger(): OTelLogger | undefined {
|
|
53
59
|
return this.logger;
|
|
54
60
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
2
|
+
import type { InMemoryMetricExporter } from "@opentelemetry/sdk-metrics";
|
|
3
|
+
import { StandardMeter } from "./StandardMeter";
|
|
4
|
+
|
|
5
|
+
jest.mock("@opentelemetry/exporter-metrics-otlp-http", () => {
|
|
6
|
+
const {
|
|
7
|
+
AggregationTemporality,
|
|
8
|
+
InMemoryMetricExporter,
|
|
9
|
+
} = jest.requireActual<typeof import("@opentelemetry/sdk-metrics")>(
|
|
10
|
+
"@opentelemetry/sdk-metrics",
|
|
11
|
+
);
|
|
12
|
+
return {
|
|
13
|
+
OTLPMetricExporter: jest.fn().mockImplementation(function () {
|
|
14
|
+
return new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const mockExporterConstructor = OTLPMetricExporter as unknown as jest.Mock;
|
|
20
|
+
|
|
21
|
+
function lastExporter(): InMemoryMetricExporter {
|
|
22
|
+
const results = mockExporterConstructor.mock.results;
|
|
23
|
+
return results[results.length - 1].value as InMemoryMetricExporter;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function metricNames(exporter: InMemoryMetricExporter): string[] {
|
|
27
|
+
return exporter
|
|
28
|
+
.getMetrics()
|
|
29
|
+
.flatMap((resourceMetrics) => resourceMetrics.scopeMetrics)
|
|
30
|
+
.flatMap((scopeMetrics) => scopeMetrics.metrics)
|
|
31
|
+
.map((metric) => metric.descriptor.name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("StandardMeter", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("builds the metric exporter with URL and concurrency limit and honors the export interval", async () => {
|
|
40
|
+
const meter = new StandardMeter({
|
|
41
|
+
SERVICE_ID: "metric-service",
|
|
42
|
+
VERSION: "1.0.0",
|
|
43
|
+
OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
|
|
44
|
+
OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS: 5,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(mockExporterConstructor).toHaveBeenCalledWith({
|
|
48
|
+
url: "http://collector:4318/v1/metrics",
|
|
49
|
+
headers: {},
|
|
50
|
+
concurrencyLimit: 5,
|
|
51
|
+
});
|
|
52
|
+
await meter.shutdown();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("adds the Authorization header when configured", async () => {
|
|
56
|
+
const meter = new StandardMeter({
|
|
57
|
+
SERVICE_ID: "metric-service",
|
|
58
|
+
VERSION: "1.0.0",
|
|
59
|
+
OPENTELEMETRY_COLLECTOR_HTTP_METRICS: "http://collector:4318/v1/metrics",
|
|
60
|
+
OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER: "secret",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(mockExporterConstructor).toHaveBeenCalledWith({
|
|
64
|
+
url: "http://collector:4318/v1/metrics",
|
|
65
|
+
headers: { Authorization: "Bearer secret" },
|
|
66
|
+
concurrencyLimit: 5,
|
|
67
|
+
});
|
|
68
|
+
await meter.shutdown();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
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
|
+
});
|
|
77
|
+
|
|
78
|
+
meter.createCounter("requests.total").add(1);
|
|
79
|
+
meter.createUpDownCounter("users.active").add(1);
|
|
80
|
+
meter.createHistogram("requests.latency").record(42);
|
|
81
|
+
const gauge = meter.createObservableGauge(
|
|
82
|
+
"cpu.usage",
|
|
83
|
+
(observableResult) => observableResult.observe(0.5),
|
|
84
|
+
"Current CPU usage",
|
|
85
|
+
);
|
|
86
|
+
expect(gauge).toBeDefined();
|
|
87
|
+
|
|
88
|
+
await meter.shutdown();
|
|
89
|
+
|
|
90
|
+
expect(metricNames(lastExporter())).toEqual(
|
|
91
|
+
expect.arrayContaining([
|
|
92
|
+
"metric-service.requests.total",
|
|
93
|
+
"metric-service.users.active",
|
|
94
|
+
"metric-service.requests.latency",
|
|
95
|
+
"cpu.usage",
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("works without a collector and without a gauge description", async () => {
|
|
101
|
+
const meter = new StandardMeter({
|
|
102
|
+
SERVICE_ID: "metric-service",
|
|
103
|
+
VERSION: "1.0.0",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(mockExporterConstructor).not.toHaveBeenCalled();
|
|
107
|
+
meter.createCounter("requests.total").add(1);
|
|
108
|
+
const gauge = meter.createObservableGauge("cpu.usage", (observableResult) =>
|
|
109
|
+
observableResult.observe(0.5),
|
|
110
|
+
);
|
|
111
|
+
expect(gauge).toBeDefined();
|
|
112
|
+
await expect(meter.shutdown()).resolves.toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/StandardMeter.ts
CHANGED
|
@@ -15,13 +15,13 @@ import { ConfigOTelInterface } from "./models/ConfigOTelInterface";
|
|
|
15
15
|
|
|
16
16
|
export class StandardMeter {
|
|
17
17
|
private meter: Meter;
|
|
18
|
+
private meterProvider: MeterProvider;
|
|
18
19
|
private serviceVersion: string;
|
|
19
20
|
private serviceName: string;
|
|
20
21
|
|
|
21
22
|
constructor(config: ConfigOTelInterface) {
|
|
22
23
|
this.serviceName = config.SERVICE_ID;
|
|
23
24
|
this.serviceVersion = config.VERSION;
|
|
24
|
-
let meterProvider;
|
|
25
25
|
if (config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS) {
|
|
26
26
|
const collectorOptions = {
|
|
27
27
|
url: config.OPENTELEMETRY_COLLECTOR_HTTP_METRICS,
|
|
@@ -33,7 +33,7 @@ export class StandardMeter {
|
|
|
33
33
|
`Bearer ${config.OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER}`;
|
|
34
34
|
}
|
|
35
35
|
const metricExporter = new OTLPMetricExporter(collectorOptions);
|
|
36
|
-
meterProvider = new MeterProvider({
|
|
36
|
+
this.meterProvider = new MeterProvider({
|
|
37
37
|
resource: createOTelResource(this.serviceName, this.serviceVersion),
|
|
38
38
|
readers: [
|
|
39
39
|
new PeriodicExportingMetricReader({
|
|
@@ -45,15 +45,19 @@ export class StandardMeter {
|
|
|
45
45
|
],
|
|
46
46
|
});
|
|
47
47
|
} else {
|
|
48
|
-
meterProvider = new MeterProvider({
|
|
48
|
+
this.meterProvider = new MeterProvider({
|
|
49
49
|
resource: createOTelResource(this.serviceName, this.serviceVersion),
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
-
this.meter = meterProvider.getMeter(
|
|
52
|
+
this.meter = this.meterProvider.getMeter(
|
|
53
53
|
`${this.serviceName}:${this.serviceVersion}`,
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
public async shutdown(): Promise<void> {
|
|
58
|
+
await this.meterProvider.shutdown();
|
|
59
|
+
}
|
|
60
|
+
|
|
57
61
|
public createCounter(key: string): Counter {
|
|
58
62
|
return this.meter.createCounter(`${this.serviceName}.${key}`);
|
|
59
63
|
}
|
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
import { SpanKind } from "@opentelemetry/api";
|
|
2
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
2
3
|
import {
|
|
3
4
|
ATTR_HTTP_REQUEST_METHOD,
|
|
4
5
|
ATTR_HTTP_ROUTE,
|
|
5
6
|
} from "@opentelemetry/semantic-conventions";
|
|
6
7
|
import { StandardTracer } from "./StandardTracer";
|
|
7
8
|
|
|
9
|
+
jest.mock("@opentelemetry/exporter-trace-otlp-http", () => {
|
|
10
|
+
const { ExportResultCode } = jest.requireActual<
|
|
11
|
+
typeof import("@opentelemetry/core")
|
|
12
|
+
>("@opentelemetry/core");
|
|
13
|
+
return {
|
|
14
|
+
OTLPTraceExporter: jest.fn().mockImplementation(function () {
|
|
15
|
+
return {
|
|
16
|
+
export: (
|
|
17
|
+
_spans: unknown,
|
|
18
|
+
resultCallback: (result: { code: number }) => void,
|
|
19
|
+
) => resultCallback({ code: ExportResultCode.SUCCESS }),
|
|
20
|
+
forceFlush: jest.fn().mockResolvedValue(undefined),
|
|
21
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
interface MockTraceExporter {
|
|
28
|
+
export: jest.Mock;
|
|
29
|
+
forceFlush: jest.Mock;
|
|
30
|
+
shutdown: jest.Mock;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const mockExporterConstructor = OTLPTraceExporter as unknown as jest.Mock;
|
|
34
|
+
|
|
35
|
+
function lastExporter(): MockTraceExporter {
|
|
36
|
+
const results = mockExporterConstructor.mock.results;
|
|
37
|
+
return results[results.length - 1].value as MockTraceExporter;
|
|
38
|
+
}
|
|
39
|
+
|
|
8
40
|
describe("StandardTracer", () => {
|
|
9
41
|
let tracer: StandardTracer;
|
|
10
42
|
|
|
@@ -15,6 +47,10 @@ describe("StandardTracer", () => {
|
|
|
15
47
|
});
|
|
16
48
|
});
|
|
17
49
|
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
jest.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
18
54
|
describe("startSpan", () => {
|
|
19
55
|
it("sanitizes span names", () => {
|
|
20
56
|
const span = tracer.startSpan("GET-/api/files/:id");
|
|
@@ -65,4 +101,68 @@ describe("StandardTracer", () => {
|
|
|
65
101
|
parent.end();
|
|
66
102
|
});
|
|
67
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("updateHttpHeader", () => {
|
|
106
|
+
it("injects the W3C trace context into a new headers object", () => {
|
|
107
|
+
const span = tracer.startSpan("propagated");
|
|
108
|
+
const headers = StandardTracer.updateHttpHeader(span);
|
|
109
|
+
|
|
110
|
+
expect(headers.traceparent).toMatch(/^00-/);
|
|
111
|
+
expect(headers.traceparent).toContain(span.spanContext().traceId);
|
|
112
|
+
span.end();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("injects into and returns the provided headers object", () => {
|
|
116
|
+
const span = tracer.startSpan("propagated");
|
|
117
|
+
const headers: Record<string, string> = { "x-custom": "value" };
|
|
118
|
+
const result = StandardTracer.updateHttpHeader(span, headers);
|
|
119
|
+
|
|
120
|
+
expect(result).toBe(headers);
|
|
121
|
+
expect(headers["x-custom"]).toBe("value");
|
|
122
|
+
expect(headers["traceparent"]).toContain(span.spanContext().traceId);
|
|
123
|
+
span.end();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("shutdown", () => {
|
|
128
|
+
it("builds the trace exporter with URL and auth header and shuts it down", async () => {
|
|
129
|
+
const exportTracer = new StandardTracer({
|
|
130
|
+
SERVICE_ID: "export-service",
|
|
131
|
+
VERSION: "2.0.0",
|
|
132
|
+
OPENTELEMETRY_COLLECTOR_HTTP_TRACES: "http://collector:4318/v1/traces",
|
|
133
|
+
OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER: "secret",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mockExporterConstructor).toHaveBeenCalledWith({
|
|
137
|
+
url: "http://collector:4318/v1/traces",
|
|
138
|
+
headers: { Authorization: "Bearer secret" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await exportTracer.shutdown();
|
|
142
|
+
expect(lastExporter().shutdown).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("builds the trace exporter without auth header", async () => {
|
|
146
|
+
const exportTracer = new StandardTracer({
|
|
147
|
+
SERVICE_ID: "export-service",
|
|
148
|
+
VERSION: "2.0.0",
|
|
149
|
+
OPENTELEMETRY_COLLECTOR_HTTP_TRACES: "http://collector:4318/v1/traces",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(mockExporterConstructor).toHaveBeenCalledWith({
|
|
153
|
+
url: "http://collector:4318/v1/traces",
|
|
154
|
+
headers: {},
|
|
155
|
+
});
|
|
156
|
+
await exportTracer.shutdown();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("resolves without a collector", async () => {
|
|
160
|
+
const plainTracer = new StandardTracer({
|
|
161
|
+
SERVICE_ID: "plain-service",
|
|
162
|
+
VERSION: "1.0.0",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await expect(plainTracer.shutdown()).resolves.toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
68
168
|
});
|
package/src/StandardTracer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import opentelemetry, {
|
|
|
5
5
|
trace,
|
|
6
6
|
Tracer,
|
|
7
7
|
} from "@opentelemetry/api";
|
|
8
|
+
import type { Span as ApiSpan } from "@opentelemetry/api";
|
|
8
9
|
import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks";
|
|
9
10
|
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
10
11
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
@@ -36,6 +37,7 @@ export class StandardTracer {
|
|
|
36
37
|
private static readonly SPAN_NAME_SANITIZE_RE = /[^a-zA-Z0-9-_/]/g;
|
|
37
38
|
private static readonly PROPAGATOR = new W3CTraceContextPropagator();
|
|
38
39
|
|
|
40
|
+
private traceProvider: NodeTracerProvider;
|
|
39
41
|
private tracer: Tracer;
|
|
40
42
|
private serviceVersion: string;
|
|
41
43
|
private serviceName: string;
|
|
@@ -57,12 +59,12 @@ export class StandardTracer {
|
|
|
57
59
|
});
|
|
58
60
|
spanProcessors.push(new BatchSpanProcessor(exporter));
|
|
59
61
|
}
|
|
60
|
-
|
|
62
|
+
this.traceProvider = new NodeTracerProvider({
|
|
61
63
|
idGenerator: new AWSXRayIdGenerator(),
|
|
62
64
|
resource: createOTelResource(this.serviceName, this.serviceVersion),
|
|
63
65
|
spanProcessors,
|
|
64
66
|
});
|
|
65
|
-
traceProvider.register();
|
|
67
|
+
this.traceProvider.register();
|
|
66
68
|
const contextManager = new AsyncHooksContextManager();
|
|
67
69
|
contextManager.enable();
|
|
68
70
|
opentelemetry.context.setGlobalContextManager(contextManager);
|
|
@@ -71,12 +73,16 @@ export class StandardTracer {
|
|
|
71
73
|
);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
public async shutdown(): Promise<void> {
|
|
77
|
+
await this.traceProvider.shutdown();
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
public startSpan(
|
|
75
81
|
name: string,
|
|
76
82
|
parentSpan?: Span,
|
|
77
83
|
options?: StandardTracerStartSpanOptions,
|
|
78
84
|
): Span {
|
|
79
|
-
const sanitizedName =
|
|
85
|
+
const sanitizedName = name.replace(
|
|
80
86
|
StandardTracer.SPAN_NAME_SANITIZE_RE,
|
|
81
87
|
"_",
|
|
82
88
|
);
|
|
@@ -100,10 +106,7 @@ export class StandardTracer {
|
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
-
public static updateHttpHeader(context:
|
|
104
|
-
if (!headers) {
|
|
105
|
-
headers = {};
|
|
106
|
-
}
|
|
109
|
+
public static updateHttpHeader(context: ApiSpan, headers = {}): any {
|
|
107
110
|
StandardTracer.PROPAGATOR.inject(
|
|
108
111
|
trace.setSpanContext(ROOT_CONTEXT, context.spanContext()),
|
|
109
112
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -6,6 +6,5 @@ export interface ConfigOTelInterface {
|
|
|
6
6
|
OPENTELEMETRY_COLLECTOR_HTTP_LOGS?: string;
|
|
7
7
|
OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS?: number;
|
|
8
8
|
OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS?: number;
|
|
9
|
-
OPENTELEMETRY_COLLECTOR_AWS?: boolean;
|
|
10
9
|
OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER?: string;
|
|
11
10
|
}
|