@carecard/telemetry 3.16.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 +136 -0
- package/index.d.ts +36 -0
- package/index.js +16 -0
- package/index.mjs +9 -0
- package/package.json +78 -0
- package/src/configuration.js +64 -0
- package/src/initializeServiceTelemetry.js +132 -0
- package/src/instrumentations.js +84 -0
- package/src/privacySafeSpanExporter.js +145 -0
- package/src/sampler.js +24 -0
- package/src/telemetryShutdown.js +29 -0
- package/src/traceContext.js +78 -0
- package/src/traceparentOnlyPropagator.js +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# @carecard/telemetry
|
|
2
|
+
|
|
3
|
+
Privacy-safe OpenTelemetry tracing for CareCard Node.js server processes.
|
|
4
|
+
|
|
5
|
+
The package owns one explicit tracing bootstrap for CareCard services. It uses
|
|
6
|
+
W3C Trace Context, exports OTLP/HTTP spans to Grafana Alloy, and instruments
|
|
7
|
+
HTTP, PostgreSQL, and Kafka without sending application payloads to the tracing
|
|
8
|
+
backend.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @carecard/telemetry@3.16.0 --save-exact
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Bootstrap
|
|
17
|
+
|
|
18
|
+
Initialize tracing after environment configuration and before loading `http`,
|
|
19
|
+
`express`, `pg`, `kafkajs`, or `undici`. Node instrumentation patches modules
|
|
20
|
+
when they load, so importing those libraries first can leave requests
|
|
21
|
+
uninstrumented.
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
require('./src/config');
|
|
25
|
+
|
|
26
|
+
const packageJson = require('./package.json');
|
|
27
|
+
const { createTelemetryShutdown, initializeServiceTelemetry } = require('@carecard/telemetry');
|
|
28
|
+
|
|
29
|
+
const telemetry = initializeServiceTelemetry({
|
|
30
|
+
serviceName: 'ms-auth',
|
|
31
|
+
serviceVersion: packageJson.version,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const serverRuntime = require('./src/serverRuntime');
|
|
35
|
+
|
|
36
|
+
const shutdown = createTelemetryShutdown({
|
|
37
|
+
telemetryRuntime: telemetry,
|
|
38
|
+
shutdownResources: serverRuntime.shutdown,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`installTelemetryShutdownHandlers(telemetry)` is available for small processes
|
|
43
|
+
that do not already own `SIGINT` and `SIGTERM` handling. Services with an
|
|
44
|
+
existing graceful-shutdown path should use `createTelemetryShutdown()` so
|
|
45
|
+
application resources stop first and telemetry still flushes if cleanup fails.
|
|
46
|
+
|
|
47
|
+
The Next.js dashboard calls the same initializer only from its Node.js
|
|
48
|
+
`instrumentation.ts` registration hook. Browser and Edge runtimes must not load
|
|
49
|
+
this package.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
| Variable | Default | Meaning |
|
|
54
|
+
| ------------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
55
|
+
| `OTEL_SDK_DISABLED` | `false` | Set to `true` to disable the SDK. It is disabled by default when `NODE_ENV=test` unless explicitly set to `false`. |
|
|
56
|
+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://127.0.0.1:4318/v1/traces` | Alloy OTLP/HTTP trace receiver. Credentials, query parameters, and fragments are rejected. |
|
|
57
|
+
| `OTEL_TRACES_SAMPLER` | `parentbased_traceidratio` | `parentbased_traceidratio`, `always_on`, or `always_off`. |
|
|
58
|
+
| `OTEL_TRACES_SAMPLER_ARG` | `1` development; `0.1` production | Root sampling ratio from `0` through `1`. |
|
|
59
|
+
|
|
60
|
+
Use a ratio of `1` for local development and Docker Desktop Kubernetes.
|
|
61
|
+
Production defaults to `0.1`; the self-hosted production profile also sets it
|
|
62
|
+
explicitly. Parent-based sampling preserves an upstream decision across service
|
|
63
|
+
boundaries.
|
|
64
|
+
|
|
65
|
+
## Propagation
|
|
66
|
+
|
|
67
|
+
Automatic instrumentation creates and propagates child spans for:
|
|
68
|
+
|
|
69
|
+
- inbound and outbound Node HTTP requests;
|
|
70
|
+
- Express request handling;
|
|
71
|
+
- PostgreSQL operations without query parameters or enhanced database
|
|
72
|
+
reporting;
|
|
73
|
+
- KafkaJS producers and consumers using the existing `traceparent` message
|
|
74
|
+
header;
|
|
75
|
+
- server-side `fetch` requests through Undici.
|
|
76
|
+
|
|
77
|
+
Routine health, readiness, metrics, and Next.js static requests are not traced.
|
|
78
|
+
Only W3C `traceparent` is propagated. Caller-provided `tracestate` is ignored,
|
|
79
|
+
and baggage is intentionally not configured.
|
|
80
|
+
|
|
81
|
+
For durable queues, capture the active context when creating the record and
|
|
82
|
+
restore it when the record is later processed:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const { captureActiveTraceparent, runWithTraceparent } = require('@carecard/telemetry');
|
|
86
|
+
|
|
87
|
+
const eventMetadata = {
|
|
88
|
+
traceparent: captureActiveTraceparent(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
await runWithTraceparent(
|
|
92
|
+
{
|
|
93
|
+
spanName: 'outbox publish',
|
|
94
|
+
traceparent: eventMetadata.traceparent,
|
|
95
|
+
},
|
|
96
|
+
() => publishOutboxEvent(),
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Span names passed to `runWithTraceparent` must be fixed, lower-case operational
|
|
101
|
+
names. Never build them from user, route, record, email, token, or payload data.
|
|
102
|
+
|
|
103
|
+
## Privacy boundary
|
|
104
|
+
|
|
105
|
+
The exporter applies a source-side privacy boundary before a span leaves the
|
|
106
|
+
process. It keeps bounded operational fields such as route templates, HTTP
|
|
107
|
+
methods and status codes, database operation names, database namespaces, and
|
|
108
|
+
Kafka topic names.
|
|
109
|
+
|
|
110
|
+
It removes SQL text, query parameters, message payloads, message keys, URL
|
|
111
|
+
values, headers, cookies, authorization data, request and response bodies,
|
|
112
|
+
client addresses, user agents, exception messages, exception stacks, status
|
|
113
|
+
descriptions, dynamic span-event names, and link attributes. Grafana Alloy
|
|
114
|
+
repeats defensive attribute deletion before forwarding spans to Tempo.
|
|
115
|
+
|
|
116
|
+
Do not add hooks that attach domain identifiers, email addresses, JWTs, file
|
|
117
|
+
names, form data, or other application values to spans.
|
|
118
|
+
|
|
119
|
+
## Public API
|
|
120
|
+
|
|
121
|
+
- `initializeServiceTelemetry({ serviceName, serviceVersion })`
|
|
122
|
+
- `createTelemetryShutdown({ telemetryRuntime, shutdownResources })`
|
|
123
|
+
- `captureActiveTraceparent()`
|
|
124
|
+
- `runWithTraceparent({ spanName, traceparent }, operation)`
|
|
125
|
+
- `installTelemetryShutdownHandlers(runtime)`
|
|
126
|
+
|
|
127
|
+
The package exposes CommonJS, ESM, and TypeScript declarations.
|
|
128
|
+
|
|
129
|
+
## Validation
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
npm run test:All
|
|
133
|
+
npm run lint
|
|
134
|
+
npm run format:check
|
|
135
|
+
npm audit --audit-level=high
|
|
136
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface TelemetryRuntime {
|
|
2
|
+
forceFlush(): Promise<void>;
|
|
3
|
+
shutdown(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ServiceTelemetryOptions {
|
|
7
|
+
serviceName: 'app-dashboard' | `ms-${string}`;
|
|
8
|
+
serviceVersion: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DurableTraceContextOptions {
|
|
12
|
+
spanName: string;
|
|
13
|
+
traceparent?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TelemetryShutdownOptions {
|
|
17
|
+
telemetryRuntime: Pick<TelemetryRuntime, 'shutdown'>;
|
|
18
|
+
shutdownResources?: () => void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare function initializeServiceTelemetry(
|
|
22
|
+
options: ServiceTelemetryOptions,
|
|
23
|
+
): TelemetryRuntime;
|
|
24
|
+
|
|
25
|
+
export declare function createTelemetryShutdown(
|
|
26
|
+
options: TelemetryShutdownOptions,
|
|
27
|
+
): () => Promise<void>;
|
|
28
|
+
|
|
29
|
+
export declare function installTelemetryShutdownHandlers(runtime: TelemetryRuntime): void;
|
|
30
|
+
|
|
31
|
+
export declare function captureActiveTraceparent(): string | undefined;
|
|
32
|
+
|
|
33
|
+
export declare function runWithTraceparent<Result>(
|
|
34
|
+
options: DurableTraceContextOptions,
|
|
35
|
+
operation: () => Result | Promise<Result>,
|
|
36
|
+
): Promise<Result>;
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
initializeServiceTelemetry,
|
|
5
|
+
installTelemetryShutdownHandlers,
|
|
6
|
+
} = require('./src/initializeServiceTelemetry');
|
|
7
|
+
const { createTelemetryShutdown } = require('./src/telemetryShutdown');
|
|
8
|
+
const { captureActiveTraceparent, runWithTraceparent } = require('./src/traceContext');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
captureActiveTraceparent,
|
|
12
|
+
createTelemetryShutdown,
|
|
13
|
+
initializeServiceTelemetry,
|
|
14
|
+
installTelemetryShutdownHandlers,
|
|
15
|
+
runWithTraceparent,
|
|
16
|
+
};
|
package/index.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@carecard/telemetry",
|
|
3
|
+
"version": "3.16.0",
|
|
4
|
+
"description": "Privacy-safe OpenTelemetry tracing for CareCard Node.js services",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"module": "index.mjs",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./index.mjs",
|
|
11
|
+
"require": "./index.js",
|
|
12
|
+
"types": "./index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"index.mjs",
|
|
18
|
+
"index.d.ts",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24.0.0"
|
|
23
|
+
},
|
|
24
|
+
"sideEffects": true,
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/CareCard-ca/pkg-telemetry.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/*.test.js test/*.test.mjs",
|
|
35
|
+
"test:types": "tsc --noEmit",
|
|
36
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-functions=90 --test-coverage-branches=85 test/*.test.js test/*.test.mjs",
|
|
37
|
+
"test:All": "npm run test && npm run test:types && npm run test:coverage",
|
|
38
|
+
"format": "prettier --write .",
|
|
39
|
+
"format:check": "prettier --check .",
|
|
40
|
+
"prepare": "husky",
|
|
41
|
+
"lint:fix": "eslint --fix .",
|
|
42
|
+
"lint": "eslint ."
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"carecard",
|
|
46
|
+
"opentelemetry",
|
|
47
|
+
"distributed-tracing",
|
|
48
|
+
"trace-context"
|
|
49
|
+
],
|
|
50
|
+
"author": "CareCard team",
|
|
51
|
+
"license": "ISC",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@opentelemetry/api": "1.9.1",
|
|
54
|
+
"@opentelemetry/context-async-hooks": "2.10.0",
|
|
55
|
+
"@opentelemetry/core": "2.10.0",
|
|
56
|
+
"@opentelemetry/exporter-trace-otlp-http": "0.221.0",
|
|
57
|
+
"@opentelemetry/instrumentation": "0.221.0",
|
|
58
|
+
"@opentelemetry/instrumentation-express": "0.69.0",
|
|
59
|
+
"@opentelemetry/instrumentation-http": "0.221.0",
|
|
60
|
+
"@opentelemetry/instrumentation-kafkajs": "0.30.0",
|
|
61
|
+
"@opentelemetry/instrumentation-pg": "0.73.0",
|
|
62
|
+
"@opentelemetry/instrumentation-undici": "0.31.0",
|
|
63
|
+
"@opentelemetry/resources": "2.10.0",
|
|
64
|
+
"@opentelemetry/sdk-trace-base": "2.10.0",
|
|
65
|
+
"@opentelemetry/sdk-trace-node": "2.10.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/node": "25.9.3",
|
|
69
|
+
"eslint": "9.39.4",
|
|
70
|
+
"husky": "9.1.7",
|
|
71
|
+
"prettier": "3.8.4",
|
|
72
|
+
"typescript": "6.0.3"
|
|
73
|
+
},
|
|
74
|
+
"overrides": {
|
|
75
|
+
"brace-expansion": "5.0.8",
|
|
76
|
+
"minimatch": "10.2.6"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ENDPOINT = 'http://127.0.0.1:4318/v1/traces';
|
|
4
|
+
const DEFAULT_SAMPLER = 'parentbased_traceidratio';
|
|
5
|
+
const SUPPORTED_SAMPLERS = new Set(['always_off', 'always_on', 'parentbased_traceidratio']);
|
|
6
|
+
|
|
7
|
+
function parseBoolean(value, defaultValue) {
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
return defaultValue;
|
|
10
|
+
}
|
|
11
|
+
if (value === 'true' || value === 'false') {
|
|
12
|
+
return value === 'true';
|
|
13
|
+
}
|
|
14
|
+
throw new Error('OTEL_SDK_DISABLED must be true or false');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseEndpoint(value = DEFAULT_ENDPOINT) {
|
|
18
|
+
let endpoint;
|
|
19
|
+
try {
|
|
20
|
+
endpoint = new URL(value);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT must be a valid http or https URL');
|
|
23
|
+
}
|
|
24
|
+
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
|
25
|
+
throw new Error('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT must be a valid http or https URL');
|
|
26
|
+
}
|
|
27
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT must not contain credentials, query parameters, or fragments',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return endpoint.toString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseSampler(value = DEFAULT_SAMPLER) {
|
|
36
|
+
const samplerName = value.toLowerCase();
|
|
37
|
+
if (!SUPPORTED_SAMPLERS.has(samplerName)) {
|
|
38
|
+
throw new Error('OTEL_TRACES_SAMPLER must be a supported sampler');
|
|
39
|
+
}
|
|
40
|
+
return samplerName;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSamplingRatio(value = '1') {
|
|
44
|
+
const ratio = Number(value);
|
|
45
|
+
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
|
46
|
+
throw new Error('OTEL_TRACES_SAMPLER_ARG must be between 0 and 1');
|
|
47
|
+
}
|
|
48
|
+
return ratio;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readTelemetryConfiguration(environment = process.env) {
|
|
52
|
+
const testDefault = environment.NODE_ENV === 'test';
|
|
53
|
+
const defaultSamplingRatio = environment.NODE_ENV === 'production' ? '0.1' : '1';
|
|
54
|
+
return {
|
|
55
|
+
disabled: parseBoolean(environment.OTEL_SDK_DISABLED, testDefault),
|
|
56
|
+
endpoint: parseEndpoint(environment.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT),
|
|
57
|
+
samplerName: parseSampler(environment.OTEL_TRACES_SAMPLER),
|
|
58
|
+
samplingRatio: parseSamplingRatio(environment.OTEL_TRACES_SAMPLER_ARG ?? defaultSamplingRatio),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
readTelemetryConfiguration,
|
|
64
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks');
|
|
4
|
+
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
5
|
+
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
|
6
|
+
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
|
|
7
|
+
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
|
|
8
|
+
|
|
9
|
+
const { readTelemetryConfiguration } = require('./configuration');
|
|
10
|
+
const { registerServiceInstrumentations } = require('./instrumentations');
|
|
11
|
+
const { PrivacySafeSpanExporter } = require('./privacySafeSpanExporter');
|
|
12
|
+
const { createSampler } = require('./sampler');
|
|
13
|
+
const { createTraceparentOnlyPropagator } = require('./traceparentOnlyPropagator');
|
|
14
|
+
|
|
15
|
+
let activeRuntime;
|
|
16
|
+
const installedShutdownHandlers = new WeakSet();
|
|
17
|
+
|
|
18
|
+
function validateServiceIdentity({ serviceName, serviceVersion }) {
|
|
19
|
+
const namePattern = /^(?:ms-[a-z0-9-]+|app-dashboard)$/;
|
|
20
|
+
const versionPattern = /^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/;
|
|
21
|
+
if (!namePattern.test(serviceName)) {
|
|
22
|
+
throw new Error('serviceName must identify a CareCard server application');
|
|
23
|
+
}
|
|
24
|
+
if (!versionPattern.test(serviceVersion)) {
|
|
25
|
+
throw new Error('serviceVersion must be a semantic version');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createNoopRuntime() {
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
forceFlush: async () => undefined,
|
|
32
|
+
shutdown: async () => undefined,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createResource(options) {
|
|
37
|
+
return resourceFromAttributes({
|
|
38
|
+
'service.name': options.serviceName,
|
|
39
|
+
'service.namespace': 'carecard',
|
|
40
|
+
'service.version': options.serviceVersion,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createProvider(options, configuration) {
|
|
45
|
+
const exporter = new OTLPTraceExporter({ url: configuration.endpoint });
|
|
46
|
+
const processor = new BatchSpanProcessor(new PrivacySafeSpanExporter(exporter));
|
|
47
|
+
return new NodeTracerProvider({
|
|
48
|
+
resource: createResource(options),
|
|
49
|
+
sampler: createSampler(configuration),
|
|
50
|
+
spanProcessors: [processor],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildRuntime(provider, contextManager, instrumentations) {
|
|
55
|
+
let stopped = false;
|
|
56
|
+
return {
|
|
57
|
+
forceFlush: () => provider.forceFlush(),
|
|
58
|
+
async shutdown() {
|
|
59
|
+
if (stopped) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
stopped = true;
|
|
63
|
+
try {
|
|
64
|
+
await provider.shutdown();
|
|
65
|
+
} finally {
|
|
66
|
+
instrumentations.forEach((instrumentation) => instrumentation.disable());
|
|
67
|
+
contextManager.disable();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startTelemetry(options, configuration) {
|
|
74
|
+
const contextManager = new AsyncLocalStorageContextManager();
|
|
75
|
+
const provider = createProvider(options, configuration);
|
|
76
|
+
provider.register({
|
|
77
|
+
contextManager,
|
|
78
|
+
propagator: createTraceparentOnlyPropagator(),
|
|
79
|
+
});
|
|
80
|
+
const instrumentations = registerServiceInstrumentations({
|
|
81
|
+
endpoint: configuration.endpoint,
|
|
82
|
+
tracerProvider: provider,
|
|
83
|
+
});
|
|
84
|
+
return buildRuntime(provider, contextManager, instrumentations);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function initializeServiceTelemetry(options) {
|
|
88
|
+
validateServiceIdentity(options);
|
|
89
|
+
const configuration = readTelemetryConfiguration();
|
|
90
|
+
if (configuration.disabled) {
|
|
91
|
+
return createNoopRuntime();
|
|
92
|
+
}
|
|
93
|
+
activeRuntime ||= startTelemetry(options, configuration);
|
|
94
|
+
return activeRuntime;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function installTelemetryShutdownHandlers(runtime) {
|
|
98
|
+
if (installedShutdownHandlers.has(runtime)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
installedShutdownHandlers.add(runtime);
|
|
102
|
+
installTelemetrySignalHandler(runtime, 'SIGINT');
|
|
103
|
+
installTelemetrySignalHandler(runtime, 'SIGTERM');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pattern: Signal Adapter - flushes telemetry and leaves an existing lifecycle owner in control.
|
|
107
|
+
function installTelemetrySignalHandler(runtime, signal) {
|
|
108
|
+
process.once(signal, async () => {
|
|
109
|
+
try {
|
|
110
|
+
await runtime.shutdown();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
reportShutdownFailure(error);
|
|
113
|
+
}
|
|
114
|
+
preserveSignalTermination(signal);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Pattern: Lifecycle Fallback - restores default termination when telemetry was the only listener.
|
|
119
|
+
function preserveSignalTermination(signal) {
|
|
120
|
+
if (process.listenerCount(signal) === 0) {
|
|
121
|
+
process.kill(process.pid, signal);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function reportShutdownFailure(error) {
|
|
126
|
+
console.error('Telemetry shutdown failed', error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
initializeServiceTelemetry,
|
|
131
|
+
installTelemetryShutdownHandlers,
|
|
132
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
|
|
4
|
+
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
|
|
5
|
+
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
|
|
6
|
+
const { KafkaJsInstrumentation } = require('@opentelemetry/instrumentation-kafkajs');
|
|
7
|
+
const { PgInstrumentation } = require('@opentelemetry/instrumentation-pg');
|
|
8
|
+
const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici');
|
|
9
|
+
|
|
10
|
+
const IGNORED_REQUEST_PATHS = new Set([
|
|
11
|
+
'/api/health',
|
|
12
|
+
'/favicon.ico',
|
|
13
|
+
'/health',
|
|
14
|
+
'/health-check',
|
|
15
|
+
'/healthz',
|
|
16
|
+
'/metrics',
|
|
17
|
+
'/ready',
|
|
18
|
+
'/readyz',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function requestPath(request) {
|
|
22
|
+
const rawPath = request.url || request.path || '/';
|
|
23
|
+
return String(rawPath).split('?')[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRoutineRequest(request) {
|
|
27
|
+
const path = requestPath(request);
|
|
28
|
+
return (
|
|
29
|
+
IGNORED_REQUEST_PATHS.has(path) ||
|
|
30
|
+
path.startsWith('/_next/image') ||
|
|
31
|
+
path.startsWith('/_next/static/')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Pattern: Normalizer - creates the endpoint host form without duplicating a supplied port.
|
|
36
|
+
function requestHostWithPort(request) {
|
|
37
|
+
const requestHost = request.hostname || request.host || request.origin;
|
|
38
|
+
const normalizedHost = String(requestHost || '').replace(/^https?:\/\//, '');
|
|
39
|
+
if (!request.port || normalizedHost.includes(':')) {
|
|
40
|
+
return normalizedHost;
|
|
41
|
+
}
|
|
42
|
+
return `${normalizedHost}:${request.port}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isExporterRequest(request, endpoint) {
|
|
46
|
+
return (
|
|
47
|
+
requestHostWithPort(request) === endpoint.host && requestPath(request) === endpoint.pathname
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createInstrumentations(endpointUrl) {
|
|
52
|
+
const endpoint = new URL(endpointUrl);
|
|
53
|
+
return [
|
|
54
|
+
new HttpInstrumentation({
|
|
55
|
+
ignoreIncomingRequestHook: isRoutineRequest,
|
|
56
|
+
ignoreOutgoingRequestHook: (request) => isExporterRequest(request, endpoint),
|
|
57
|
+
}),
|
|
58
|
+
new ExpressInstrumentation(),
|
|
59
|
+
new PgInstrumentation({
|
|
60
|
+
addSqlCommenterCommentToQueries: false,
|
|
61
|
+
enableTraceContextPropagation: false,
|
|
62
|
+
enhancedDatabaseReporting: false,
|
|
63
|
+
}),
|
|
64
|
+
new KafkaJsInstrumentation(),
|
|
65
|
+
new UndiciInstrumentation({
|
|
66
|
+
ignoreRequestHook: (request) => isExporterRequest(request, endpoint),
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function registerServiceInstrumentations(options) {
|
|
72
|
+
const instrumentations = createInstrumentations(options.endpoint);
|
|
73
|
+
registerInstrumentations({
|
|
74
|
+
instrumentations,
|
|
75
|
+
tracerProvider: options.tracerProvider,
|
|
76
|
+
});
|
|
77
|
+
return instrumentations;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
createInstrumentations,
|
|
82
|
+
isRoutineRequest,
|
|
83
|
+
registerServiceInstrumentations,
|
|
84
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SAFE_ATTRIBUTE_NAMES = new Set([
|
|
4
|
+
'db.namespace',
|
|
5
|
+
'db.operation.name',
|
|
6
|
+
'db.system',
|
|
7
|
+
'db.system.name',
|
|
8
|
+
'error.type',
|
|
9
|
+
'http.request.method',
|
|
10
|
+
'http.response.status_code',
|
|
11
|
+
'http.route',
|
|
12
|
+
'messaging.batch.message_count',
|
|
13
|
+
'messaging.destination.name',
|
|
14
|
+
'messaging.kafka.consumer.group',
|
|
15
|
+
'messaging.operation.name',
|
|
16
|
+
'messaging.operation.type',
|
|
17
|
+
'messaging.system',
|
|
18
|
+
'network.protocol.name',
|
|
19
|
+
'network.protocol.version',
|
|
20
|
+
'network.transport',
|
|
21
|
+
'server.port',
|
|
22
|
+
'url.scheme',
|
|
23
|
+
]);
|
|
24
|
+
const SAFE_HTTP_METHODS = new Set([
|
|
25
|
+
'CONNECT',
|
|
26
|
+
'DELETE',
|
|
27
|
+
'GET',
|
|
28
|
+
'HEAD',
|
|
29
|
+
'OPTIONS',
|
|
30
|
+
'PATCH',
|
|
31
|
+
'POST',
|
|
32
|
+
'PUT',
|
|
33
|
+
'TRACE',
|
|
34
|
+
]);
|
|
35
|
+
const SAFE_TEXT_PATTERN = /^[a-zA-Z0-9_.:*/ -]+$/;
|
|
36
|
+
const SAFE_SPAN_NAME_PATTERN = /^[a-z][a-z]*(?:[ .:_-][a-z]+)*$/;
|
|
37
|
+
const SAFE_EVENT_NAMES = new Set(['exception']);
|
|
38
|
+
const MAX_ATTRIBUTE_LENGTH = 256;
|
|
39
|
+
|
|
40
|
+
function sanitizeAttributeValue(value) {
|
|
41
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string' || value.length > MAX_ATTRIBUTE_LENGTH) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return SAFE_TEXT_PATTERN.test(value) ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeSpanAttributeValue(name, value) {
|
|
51
|
+
if (name === 'http.request.method') {
|
|
52
|
+
return SAFE_HTTP_METHODS.has(value) ? value : undefined;
|
|
53
|
+
}
|
|
54
|
+
return sanitizeAttributeValue(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sanitizeSpanAttributes(attributes = {}) {
|
|
58
|
+
return Object.fromEntries(
|
|
59
|
+
Object.entries(attributes).flatMap(([name, value]) => {
|
|
60
|
+
if (!SAFE_ATTRIBUTE_NAMES.has(name)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const safeValue = sanitizeSpanAttributeValue(name, value);
|
|
64
|
+
return safeValue === undefined ? [] : [[name, safeValue]];
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizeEvents(events = []) {
|
|
70
|
+
return events.map((event) => ({
|
|
71
|
+
...event,
|
|
72
|
+
name: SAFE_EVENT_NAMES.has(event.name) ? event.name : 'event',
|
|
73
|
+
attributes: sanitizeExceptionAttributes(event.attributes),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sanitizeExceptionAttributes(attributes = {}) {
|
|
78
|
+
const exceptionType = sanitizeAttributeValue(attributes['exception.type']);
|
|
79
|
+
return exceptionType ? { 'exception.type': exceptionType } : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sanitizeLinks(links = []) {
|
|
83
|
+
return links.map((link) => ({ ...link, attributes: {} }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildSemanticSpanName(attributes) {
|
|
87
|
+
if (attributes['http.request.method']) {
|
|
88
|
+
return `${attributes['http.request.method']} ${attributes['http.route'] || 'request'}`;
|
|
89
|
+
}
|
|
90
|
+
if (attributes['db.operation.name']) {
|
|
91
|
+
return `db ${String(attributes['db.operation.name']).toLowerCase()}`;
|
|
92
|
+
}
|
|
93
|
+
if (attributes['messaging.operation.name']) {
|
|
94
|
+
return `messaging ${String(attributes['messaging.operation.name']).toLowerCase()}`;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sanitizeSpanName(span, attributes) {
|
|
100
|
+
const semanticName = buildSemanticSpanName(attributes);
|
|
101
|
+
if (semanticName) {
|
|
102
|
+
return semanticName;
|
|
103
|
+
}
|
|
104
|
+
return SAFE_SPAN_NAME_PATTERN.test(span.name) ? span.name : 'operation';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizeSpan(span) {
|
|
108
|
+
const attributes = sanitizeSpanAttributes(span.attributes);
|
|
109
|
+
const overrides = {
|
|
110
|
+
attributes,
|
|
111
|
+
events: sanitizeEvents(span.events),
|
|
112
|
+
links: sanitizeLinks(span.links),
|
|
113
|
+
name: sanitizeSpanName(span, attributes),
|
|
114
|
+
status: { code: span.status.code },
|
|
115
|
+
};
|
|
116
|
+
return new Proxy(span, {
|
|
117
|
+
get(target, property) {
|
|
118
|
+
const value = property in overrides ? overrides[property] : Reflect.get(target, property);
|
|
119
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class PrivacySafeSpanExporter {
|
|
125
|
+
constructor(delegate) {
|
|
126
|
+
this.delegate = delegate;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export(spans, callback) {
|
|
130
|
+
this.delegate.export(spans.map(sanitizeSpan), callback);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
forceFlush() {
|
|
134
|
+
return this.delegate.forceFlush?.() || Promise.resolve();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
shutdown() {
|
|
138
|
+
return this.delegate.shutdown();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
PrivacySafeSpanExporter,
|
|
144
|
+
sanitizeSpanAttributes,
|
|
145
|
+
};
|
package/src/sampler.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
AlwaysOffSampler,
|
|
5
|
+
AlwaysOnSampler,
|
|
6
|
+
ParentBasedSampler,
|
|
7
|
+
TraceIdRatioBasedSampler,
|
|
8
|
+
} = require('@opentelemetry/sdk-trace-base');
|
|
9
|
+
|
|
10
|
+
function createSampler({ samplerName, samplingRatio }) {
|
|
11
|
+
if (samplerName === 'always_off') {
|
|
12
|
+
return new AlwaysOffSampler();
|
|
13
|
+
}
|
|
14
|
+
if (samplerName === 'always_on') {
|
|
15
|
+
return new AlwaysOnSampler();
|
|
16
|
+
}
|
|
17
|
+
return new ParentBasedSampler({
|
|
18
|
+
root: new TraceIdRatioBasedSampler(samplingRatio),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
createSampler,
|
|
24
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pattern: Decorator - preserves application cleanup order and always stops telemetry.
|
|
4
|
+
function createTelemetryShutdown({ telemetryRuntime, shutdownResources = async () => undefined }) {
|
|
5
|
+
if (typeof telemetryRuntime?.shutdown !== 'function' || typeof shutdownResources !== 'function') {
|
|
6
|
+
throw new TypeError('telemetryRuntime.shutdown and shutdownResources must be functions');
|
|
7
|
+
}
|
|
8
|
+
return async function shutdownResourcesAndTelemetry() {
|
|
9
|
+
const failures = [];
|
|
10
|
+
await captureShutdownFailure(shutdownResources, failures);
|
|
11
|
+
await captureShutdownFailure(() => telemetryRuntime.shutdown(), failures);
|
|
12
|
+
if (failures.length) {
|
|
13
|
+
throw new AggregateError(failures, 'Service resource shutdown failed');
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Pattern: Error Collector - records one cleanup failure without skipping later cleanup.
|
|
19
|
+
async function captureShutdownFailure(shutdownOperation, failures) {
|
|
20
|
+
try {
|
|
21
|
+
await shutdownOperation();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
failures.push(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
createTelemetryShutdown,
|
|
29
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
ROOT_CONTEXT,
|
|
5
|
+
SpanKind,
|
|
6
|
+
SpanStatusCode,
|
|
7
|
+
context,
|
|
8
|
+
propagation,
|
|
9
|
+
trace,
|
|
10
|
+
} = require('@opentelemetry/api');
|
|
11
|
+
|
|
12
|
+
const TRACEPARENT_HEADER = 'traceparent';
|
|
13
|
+
const SAFE_SPAN_NAME_PATTERN = /^[a-z][a-z]*(?:[ .:_-][a-z]+)*$/;
|
|
14
|
+
const MAX_SPAN_NAME_LENGTH = 64;
|
|
15
|
+
const carrierGetter = {
|
|
16
|
+
get(carrier, key) {
|
|
17
|
+
return carrier[key];
|
|
18
|
+
},
|
|
19
|
+
keys(carrier) {
|
|
20
|
+
return Object.keys(carrier);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const carrierSetter = {
|
|
24
|
+
set(carrier, key, value) {
|
|
25
|
+
carrier[key] = value;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function captureActiveTraceparent() {
|
|
30
|
+
const carrier = {};
|
|
31
|
+
propagation.inject(context.active(), carrier, carrierSetter);
|
|
32
|
+
return carrier[TRACEPARENT_HEADER];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateOperation(spanName, operation) {
|
|
36
|
+
if (typeof operation !== 'function') {
|
|
37
|
+
throw new TypeError('operation must be a function');
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
typeof spanName !== 'string' ||
|
|
41
|
+
spanName.length > MAX_SPAN_NAME_LENGTH ||
|
|
42
|
+
!SAFE_SPAN_NAME_PATTERN.test(spanName)
|
|
43
|
+
) {
|
|
44
|
+
throw new TypeError('spanName must be a fixed privacy-safe operation name');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractParentContext(traceparent) {
|
|
49
|
+
if (typeof traceparent !== 'string') {
|
|
50
|
+
return ROOT_CONTEXT;
|
|
51
|
+
}
|
|
52
|
+
return propagation.extract(ROOT_CONTEXT, { traceparent }, carrierGetter);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function invokeOperation(span, operation) {
|
|
56
|
+
try {
|
|
57
|
+
return await operation();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
60
|
+
throw error;
|
|
61
|
+
} finally {
|
|
62
|
+
span.end();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runWithTraceparent({ spanName, traceparent }, operation) {
|
|
67
|
+
validateOperation(spanName, operation);
|
|
68
|
+
const tracer = trace.getTracer('@carecard/telemetry');
|
|
69
|
+
const parentContext = extractParentContext(traceparent);
|
|
70
|
+
return tracer.startActiveSpan(spanName, { kind: SpanKind.INTERNAL }, parentContext, (span) =>
|
|
71
|
+
invokeOperation(span, operation),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
captureActiveTraceparent,
|
|
77
|
+
runWithTraceparent,
|
|
78
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { W3CTraceContextPropagator } = require('@opentelemetry/core');
|
|
4
|
+
|
|
5
|
+
const TRACEPARENT_HEADER = 'traceparent';
|
|
6
|
+
const TRACESTATE_HEADER = 'tracestate';
|
|
7
|
+
|
|
8
|
+
// Pattern: Protocol Adapter - delegates W3C parsing while exposing only CareCard traceparent.
|
|
9
|
+
class TraceparentOnlyPropagator {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.delegate = new W3CTraceContextPropagator();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
inject(context, carrier, setter) {
|
|
15
|
+
this.delegate.inject(context, carrier, createTraceparentSetter(setter));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
extract(context, carrier, getter) {
|
|
19
|
+
return this.delegate.extract(context, carrier, createTraceparentGetter(getter));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fields() {
|
|
23
|
+
return [TRACEPARENT_HEADER];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Pattern: Filtering Adapter - prevents W3C tracestate from entering active context.
|
|
28
|
+
function createTraceparentGetter(getter) {
|
|
29
|
+
return {
|
|
30
|
+
get(carrier, key) {
|
|
31
|
+
return key.toLowerCase() === TRACESTATE_HEADER ? undefined : getter.get(carrier, key);
|
|
32
|
+
},
|
|
33
|
+
keys(carrier) {
|
|
34
|
+
return getter.keys(carrier).filter((key) => key.toLowerCase() !== TRACESTATE_HEADER);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pattern: Filtering Adapter - prevents any propagation field except traceparent from leaving.
|
|
40
|
+
function createTraceparentSetter(setter) {
|
|
41
|
+
return {
|
|
42
|
+
set(carrier, key, value) {
|
|
43
|
+
if (key.toLowerCase() === TRACEPARENT_HEADER) {
|
|
44
|
+
setter.set(carrier, key, value);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createTraceparentOnlyPropagator() {
|
|
51
|
+
return new TraceparentOnlyPropagator();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
createTraceparentOnlyPropagator,
|
|
56
|
+
};
|