@foam-ai/node-cliengo 0.1.0-alpha.1
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/dist/node-cliengo/src/constants.d.ts +26 -0
- package/dist/node-cliengo/src/constants.js +49 -0
- package/dist/node-cliengo/src/http/express.d.ts +41 -0
- package/dist/node-cliengo/src/http/express.js +123 -0
- package/dist/node-cliengo/src/http/fastify.d.ts +28 -0
- package/dist/node-cliengo/src/http/fastify.js +101 -0
- package/dist/node-cliengo/src/http/request-context.d.ts +32 -0
- package/dist/node-cliengo/src/http/request-context.js +114 -0
- package/dist/node-cliengo/src/index.d.ts +26 -0
- package/dist/node-cliengo/src/index.js +42 -0
- package/dist/node-cliengo/src/init.d.ts +50 -0
- package/dist/node-cliengo/src/init.js +348 -0
- package/dist/node-cliengo/src/job.d.ts +31 -0
- package/dist/node-cliengo/src/job.js +86 -0
- package/dist/node-cliengo/src/logs/pino-destination.d.ts +31 -0
- package/dist/node-cliengo/src/logs/pino-destination.js +100 -0
- package/dist/node-cliengo/src/logs/pino-mixin.d.ts +22 -0
- package/dist/node-cliengo/src/logs/pino-mixin.js +40 -0
- package/dist/node-cliengo/src/logs/winston-format.d.ts +28 -0
- package/dist/node-cliengo/src/logs/winston-format.js +48 -0
- package/dist/node-cliengo/src/logs/winston-transport.d.ts +31 -0
- package/dist/node-cliengo/src/logs/winston-transport.js +93 -0
- package/dist/node-cliengo/src/metrics/instruments.d.ts +29 -0
- package/dist/node-cliengo/src/metrics/instruments.js +78 -0
- package/dist/node-cliengo/src/nr.d.ts +35 -0
- package/dist/node-cliengo/src/nr.js +71 -0
- package/dist/node-cliengo/src/sns.d.ts +19 -0
- package/dist/node-cliengo/src/sns.js +31 -0
- package/dist/node-cliengo/src/sqs.d.ts +35 -0
- package/dist/node-cliengo/src/sqs.js +110 -0
- package/dist/node-cliengo/src/trace-bridge.d.ts +39 -0
- package/dist/node-cliengo/src/trace-bridge.js +79 -0
- package/dist/node-cliengo/src/types.d.ts +115 -0
- package/dist/node-cliengo/src/types.js +26 -0
- package/dist/shared/constants.d.ts +1 -0
- package/dist/shared/constants.js +4 -0
- package/dist/shared/util.d.ts +9 -0
- package/dist/shared/util.js +21 -0
- package/package.json +54 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for the @foam/node-cliengo bridge.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists:
|
|
5
|
+
* Centralizes all hardcoded values (endpoint, limits, redaction keys, health
|
|
6
|
+
* routes, request fields) so they are defined once and shared across every
|
|
7
|
+
* module. Changing a constant here propagates everywhere without hunting
|
|
8
|
+
* through individual files.
|
|
9
|
+
*
|
|
10
|
+
* Edge cases covered:
|
|
11
|
+
* - FOAM_OTEL_ENDPOINT is hardcoded (not read from env) to prevent
|
|
12
|
+
* misconfiguration; services override via `options.endpoint` only.
|
|
13
|
+
* - REDACTED_KEYS uses lowercase comparison so "Authorization", "PASSWORD",
|
|
14
|
+
* "Token" all match. Includes both "creditcard" and "credit_card" variants.
|
|
15
|
+
* - HEALTH_ROUTES covers the four most common patterns across all 5 Cliengo
|
|
16
|
+
* services so health-check traffic doesn't bloat span events.
|
|
17
|
+
* - REQUEST_CONTEXT_FIELDS lists every property the five services may put on
|
|
18
|
+
* req (Express or Fastify): auth, user, jwtPayload, session, cookies, etc.
|
|
19
|
+
* Missing properties are silently skipped at capture time.
|
|
20
|
+
*/
|
|
21
|
+
export declare const FOAM_OTEL_ENDPOINT = "https://otel.api.foam.ai";
|
|
22
|
+
export declare const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
23
|
+
export declare const BODY_TRUNCATION_LIMIT = 4096;
|
|
24
|
+
export declare const REDACTED_KEYS: Set<string>;
|
|
25
|
+
export declare const HEALTH_ROUTES: Set<string>;
|
|
26
|
+
export declare const REQUEST_CONTEXT_FIELDS: readonly ["params", "query", "body", "headers", "auth", "user", "jwtPayload", "session", "cookies", "ip", "hostname", "protocol"];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared constants for the @foam/node-cliengo bridge.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists:
|
|
6
|
+
* Centralizes all hardcoded values (endpoint, limits, redaction keys, health
|
|
7
|
+
* routes, request fields) so they are defined once and shared across every
|
|
8
|
+
* module. Changing a constant here propagates everywhere without hunting
|
|
9
|
+
* through individual files.
|
|
10
|
+
*
|
|
11
|
+
* Edge cases covered:
|
|
12
|
+
* - FOAM_OTEL_ENDPOINT is hardcoded (not read from env) to prevent
|
|
13
|
+
* misconfiguration; services override via `options.endpoint` only.
|
|
14
|
+
* - REDACTED_KEYS uses lowercase comparison so "Authorization", "PASSWORD",
|
|
15
|
+
* "Token" all match. Includes both "creditcard" and "credit_card" variants.
|
|
16
|
+
* - HEALTH_ROUTES covers the four most common patterns across all 5 Cliengo
|
|
17
|
+
* services so health-check traffic doesn't bloat span events.
|
|
18
|
+
* - REQUEST_CONTEXT_FIELDS lists every property the five services may put on
|
|
19
|
+
* req (Express or Fastify): auth, user, jwtPayload, session, cookies, etc.
|
|
20
|
+
* Missing properties are silently skipped at capture time.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.REQUEST_CONTEXT_FIELDS = exports.HEALTH_ROUTES = exports.REDACTED_KEYS = exports.BODY_TRUNCATION_LIMIT = exports.SHUTDOWN_TIMEOUT_MS = exports.FOAM_OTEL_ENDPOINT = void 0;
|
|
24
|
+
exports.FOAM_OTEL_ENDPOINT = 'https://otel.api.foam.ai';
|
|
25
|
+
exports.SHUTDOWN_TIMEOUT_MS = 5000;
|
|
26
|
+
exports.BODY_TRUNCATION_LIMIT = 4096;
|
|
27
|
+
exports.REDACTED_KEYS = new Set([
|
|
28
|
+
'password',
|
|
29
|
+
'token',
|
|
30
|
+
'authorization',
|
|
31
|
+
'secret',
|
|
32
|
+
'creditcard',
|
|
33
|
+
'credit_card',
|
|
34
|
+
]);
|
|
35
|
+
exports.HEALTH_ROUTES = new Set(['/health', '/healthz', '/ready', '/status']);
|
|
36
|
+
exports.REQUEST_CONTEXT_FIELDS = [
|
|
37
|
+
'params',
|
|
38
|
+
'query',
|
|
39
|
+
'body',
|
|
40
|
+
'headers',
|
|
41
|
+
'auth',
|
|
42
|
+
'user',
|
|
43
|
+
'jwtPayload',
|
|
44
|
+
'session',
|
|
45
|
+
'cookies',
|
|
46
|
+
'ip',
|
|
47
|
+
'hostname',
|
|
48
|
+
'protocol',
|
|
49
|
+
];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express shadow HTTP middleware — creates one OTel span per request on our
|
|
3
|
+
* non-global provider, sharing NR's traceId for correlation.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists:
|
|
6
|
+
* NR auto-instruments HTTP via monkey-patching, but those are NR segments —
|
|
7
|
+
* not OTel spans. We can't inject into NR's provider (addSpanProcessor was
|
|
8
|
+
* removed in OTel SDK v2). Instead, this middleware creates a "shadow" span
|
|
9
|
+
* that mirrors the request lifecycle. Foam sees every HTTP request; NR is
|
|
10
|
+
* completely untouched.
|
|
11
|
+
*
|
|
12
|
+
* Two functions required:
|
|
13
|
+
* - createExpressMiddleware(): registered BEFORE routes. Creates the span on
|
|
14
|
+
* request start, records status/route/duration on `res.on('finish')`, and
|
|
15
|
+
* captures request context as a span event.
|
|
16
|
+
* - createExpressErrorHandler(): registered AFTER routes. Records exception
|
|
17
|
+
* details (type, message, stacktrace) on the shadow span. Must have the
|
|
18
|
+
* 4-argument Express error handler signature (err, req, res, next).
|
|
19
|
+
*
|
|
20
|
+
* Edge cases covered:
|
|
21
|
+
* - Error handler not registered: if a 500 response finishes without the
|
|
22
|
+
* error handler, we log a one-time warning so devs know they're missing
|
|
23
|
+
* exception details. Shadow span still shows status 500 — just no
|
|
24
|
+
* exception.type/message/stacktrace.
|
|
25
|
+
* - NR not loaded: getTraceContext returns empty strings. Span still created
|
|
26
|
+
* but without nr.traceId/nr.spanId attributes.
|
|
27
|
+
* - Health routes (/health, /healthz, /ready, /status): no request.context
|
|
28
|
+
* span event attached — avoids bloating spans with empty health check data.
|
|
29
|
+
* - req.route is undefined for 404s or middleware-only errors: falls back to
|
|
30
|
+
* req.originalUrl for the http.route attribute.
|
|
31
|
+
* - Errors thrown as non-Error objects (e.g. strings): wrapped in new Error()
|
|
32
|
+
* for consistent recording.
|
|
33
|
+
* - All callbacks wrapped in try/catch — a crash in span logic never takes
|
|
34
|
+
* down the request.
|
|
35
|
+
*/
|
|
36
|
+
import { type Tracer } from '@opentelemetry/api';
|
|
37
|
+
import type { ExpressErrorHandler, ExpressRequestHandler } from '../types';
|
|
38
|
+
export declare function createExpressMiddleware(tracer: Tracer): ExpressRequestHandler;
|
|
39
|
+
export declare function createExpressErrorHandler(middlewareRef?: ExpressRequestHandler & {
|
|
40
|
+
_markErrorHandler?: () => void;
|
|
41
|
+
}): ExpressErrorHandler;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Express shadow HTTP middleware — creates one OTel span per request on our
|
|
4
|
+
* non-global provider, sharing NR's traceId for correlation.
|
|
5
|
+
*
|
|
6
|
+
* Why this file exists:
|
|
7
|
+
* NR auto-instruments HTTP via monkey-patching, but those are NR segments —
|
|
8
|
+
* not OTel spans. We can't inject into NR's provider (addSpanProcessor was
|
|
9
|
+
* removed in OTel SDK v2). Instead, this middleware creates a "shadow" span
|
|
10
|
+
* that mirrors the request lifecycle. Foam sees every HTTP request; NR is
|
|
11
|
+
* completely untouched.
|
|
12
|
+
*
|
|
13
|
+
* Two functions required:
|
|
14
|
+
* - createExpressMiddleware(): registered BEFORE routes. Creates the span on
|
|
15
|
+
* request start, records status/route/duration on `res.on('finish')`, and
|
|
16
|
+
* captures request context as a span event.
|
|
17
|
+
* - createExpressErrorHandler(): registered AFTER routes. Records exception
|
|
18
|
+
* details (type, message, stacktrace) on the shadow span. Must have the
|
|
19
|
+
* 4-argument Express error handler signature (err, req, res, next).
|
|
20
|
+
*
|
|
21
|
+
* Edge cases covered:
|
|
22
|
+
* - Error handler not registered: if a 500 response finishes without the
|
|
23
|
+
* error handler, we log a one-time warning so devs know they're missing
|
|
24
|
+
* exception details. Shadow span still shows status 500 — just no
|
|
25
|
+
* exception.type/message/stacktrace.
|
|
26
|
+
* - NR not loaded: getTraceContext returns empty strings. Span still created
|
|
27
|
+
* but without nr.traceId/nr.spanId attributes.
|
|
28
|
+
* - Health routes (/health, /healthz, /ready, /status): no request.context
|
|
29
|
+
* span event attached — avoids bloating spans with empty health check data.
|
|
30
|
+
* - req.route is undefined for 404s or middleware-only errors: falls back to
|
|
31
|
+
* req.originalUrl for the http.route attribute.
|
|
32
|
+
* - Errors thrown as non-Error objects (e.g. strings): wrapped in new Error()
|
|
33
|
+
* for consistent recording.
|
|
34
|
+
* - All callbacks wrapped in try/catch — a crash in span logic never takes
|
|
35
|
+
* down the request.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.createExpressMiddleware = createExpressMiddleware;
|
|
39
|
+
exports.createExpressErrorHandler = createExpressErrorHandler;
|
|
40
|
+
const api_1 = require("@opentelemetry/api");
|
|
41
|
+
const trace_bridge_1 = require("../trace-bridge");
|
|
42
|
+
const request_context_1 = require("./request-context");
|
|
43
|
+
const SPAN_KEY = Symbol.for('foam.shadow.span');
|
|
44
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
45
|
+
function createExpressMiddleware(tracer) {
|
|
46
|
+
let errorHandlerRegistered = false;
|
|
47
|
+
let warnedMissingErrorHandler = false;
|
|
48
|
+
const markErrorHandlerRegistered = () => {
|
|
49
|
+
errorHandlerRegistered = true;
|
|
50
|
+
};
|
|
51
|
+
const middleware = (req, res, next) => {
|
|
52
|
+
const method = req.method ?? 'UNKNOWN';
|
|
53
|
+
const target = req.originalUrl ?? req.url ?? '/';
|
|
54
|
+
const { traceId, spanId } = (0, trace_bridge_1.getTraceContext)();
|
|
55
|
+
const span = tracer.startSpan(`${method} ${target}`, {
|
|
56
|
+
attributes: {
|
|
57
|
+
'http.method': method,
|
|
58
|
+
'http.target': target,
|
|
59
|
+
...(traceId ? { 'nr.traceId': traceId } : {}),
|
|
60
|
+
...(spanId ? { 'nr.spanId': spanId } : {}),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
req[SPAN_KEY] = span;
|
|
64
|
+
const startTime = Date.now();
|
|
65
|
+
const onFinish = () => {
|
|
66
|
+
try {
|
|
67
|
+
const statusCode = res.statusCode ?? 0;
|
|
68
|
+
const route = req.route?.path ?? target;
|
|
69
|
+
const contentLength = res.getHeader?.('content-length') ?? '';
|
|
70
|
+
span.setAttribute('http.status_code', statusCode);
|
|
71
|
+
span.setAttribute('http.route', route);
|
|
72
|
+
span.setAttribute('http.response_content_length', String(contentLength));
|
|
73
|
+
span.setAttribute('http.duration_ms', Date.now() - startTime);
|
|
74
|
+
if (statusCode >= 500) {
|
|
75
|
+
span.setStatus({ code: api_1.SpanStatusCode.ERROR });
|
|
76
|
+
if (!errorHandlerRegistered && !warnedMissingErrorHandler) {
|
|
77
|
+
warnedMissingErrorHandler = true;
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.warn('[foam] createExpressErrorHandler() not registered — exception details will be missing from shadow spans');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
span.setStatus({ code: api_1.SpanStatusCode.OK });
|
|
84
|
+
}
|
|
85
|
+
if (!(0, request_context_1.isHealthRoute)(route) && !(0, request_context_1.isHealthRoute)(target)) {
|
|
86
|
+
(0, request_context_1.captureRequestContext)(span, req);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* never crash on finish */
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
span.end();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
res.on('finish', onFinish);
|
|
97
|
+
next();
|
|
98
|
+
};
|
|
99
|
+
middleware._markErrorHandler = markErrorHandlerRegistered;
|
|
100
|
+
return middleware;
|
|
101
|
+
}
|
|
102
|
+
function createExpressErrorHandler(middlewareRef) {
|
|
103
|
+
middlewareRef?._markErrorHandler?.();
|
|
104
|
+
return (err, req, _res, next) => {
|
|
105
|
+
try {
|
|
106
|
+
const span = req[SPAN_KEY];
|
|
107
|
+
if (span) {
|
|
108
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
109
|
+
span.recordException({
|
|
110
|
+
name: error.constructor.name,
|
|
111
|
+
message: error.message,
|
|
112
|
+
stack: error.stack,
|
|
113
|
+
});
|
|
114
|
+
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* never crash error handling */
|
|
119
|
+
}
|
|
120
|
+
next(err);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fastify shadow HTTP plugin — creates one OTel span per request on our
|
|
3
|
+
* non-global provider, sharing NR's traceId for correlation.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists:
|
|
6
|
+
* Same rationale as the Express middleware (see express.ts). Fastify's hook
|
|
7
|
+
* system is different — we use three hooks instead of middleware + error handler:
|
|
8
|
+
* - onRequest: creates the shadow span, reads NR's traceId
|
|
9
|
+
* - onError: records exception details (fires BEFORE errorHandler, so we see
|
|
10
|
+
* every error including ones mapped to non-500 responses like AppError.badRequest)
|
|
11
|
+
* - onResponse: records status/route/duration, captures request context, ends span
|
|
12
|
+
*
|
|
13
|
+
* Used by kori (~230 Fastify routes).
|
|
14
|
+
*
|
|
15
|
+
* Edge cases covered:
|
|
16
|
+
* - Fastify's onError fires for ALL errors including 4xx AppError variants.
|
|
17
|
+
* We record the exception on the span regardless — Foam sees the full error
|
|
18
|
+
* even for 400s. The span status is only set to ERROR by onError (not
|
|
19
|
+
* overwritten to OK by onResponse for <500 codes, since onError already set it).
|
|
20
|
+
* - request.routeOptions?.url may not exist in older Fastify versions — falls
|
|
21
|
+
* back to request.url.
|
|
22
|
+
* - Health routes skipped for request.context capture (same as Express).
|
|
23
|
+
* - All hooks wrapped in try/catch — never crashes the request pipeline.
|
|
24
|
+
* - done() always called even if our code throws — Fastify hook contract.
|
|
25
|
+
*/
|
|
26
|
+
import { type Tracer } from '@opentelemetry/api';
|
|
27
|
+
import type { FastifyPluginAsync } from '../types';
|
|
28
|
+
export declare function createFastifyPlugin(tracer: Tracer): FastifyPluginAsync;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fastify shadow HTTP plugin — creates one OTel span per request on our
|
|
4
|
+
* non-global provider, sharing NR's traceId for correlation.
|
|
5
|
+
*
|
|
6
|
+
* Why this file exists:
|
|
7
|
+
* Same rationale as the Express middleware (see express.ts). Fastify's hook
|
|
8
|
+
* system is different — we use three hooks instead of middleware + error handler:
|
|
9
|
+
* - onRequest: creates the shadow span, reads NR's traceId
|
|
10
|
+
* - onError: records exception details (fires BEFORE errorHandler, so we see
|
|
11
|
+
* every error including ones mapped to non-500 responses like AppError.badRequest)
|
|
12
|
+
* - onResponse: records status/route/duration, captures request context, ends span
|
|
13
|
+
*
|
|
14
|
+
* Used by kori (~230 Fastify routes).
|
|
15
|
+
*
|
|
16
|
+
* Edge cases covered:
|
|
17
|
+
* - Fastify's onError fires for ALL errors including 4xx AppError variants.
|
|
18
|
+
* We record the exception on the span regardless — Foam sees the full error
|
|
19
|
+
* even for 400s. The span status is only set to ERROR by onError (not
|
|
20
|
+
* overwritten to OK by onResponse for <500 codes, since onError already set it).
|
|
21
|
+
* - request.routeOptions?.url may not exist in older Fastify versions — falls
|
|
22
|
+
* back to request.url.
|
|
23
|
+
* - Health routes skipped for request.context capture (same as Express).
|
|
24
|
+
* - All hooks wrapped in try/catch — never crashes the request pipeline.
|
|
25
|
+
* - done() always called even if our code throws — Fastify hook contract.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.createFastifyPlugin = createFastifyPlugin;
|
|
29
|
+
const api_1 = require("@opentelemetry/api");
|
|
30
|
+
const trace_bridge_1 = require("../trace-bridge");
|
|
31
|
+
const request_context_1 = require("./request-context");
|
|
32
|
+
const SPAN_KEY = Symbol.for('foam.shadow.span');
|
|
33
|
+
const START_KEY = Symbol.for('foam.shadow.start');
|
|
34
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
35
|
+
function createFastifyPlugin(tracer) {
|
|
36
|
+
return async (fastify) => {
|
|
37
|
+
fastify.addHook('onRequest', (request, _reply, done) => {
|
|
38
|
+
try {
|
|
39
|
+
const method = request.method ?? 'UNKNOWN';
|
|
40
|
+
const url = request.url ?? '/';
|
|
41
|
+
const { traceId, spanId } = (0, trace_bridge_1.getTraceContext)();
|
|
42
|
+
const span = tracer.startSpan(`${method} ${url}`, {
|
|
43
|
+
attributes: {
|
|
44
|
+
'http.method': method,
|
|
45
|
+
'http.target': url,
|
|
46
|
+
...(traceId ? { 'nr.traceId': traceId } : {}),
|
|
47
|
+
...(spanId ? { 'nr.spanId': spanId } : {}),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
request[SPAN_KEY] = span;
|
|
51
|
+
request[START_KEY] = Date.now();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* never crash */
|
|
55
|
+
}
|
|
56
|
+
done();
|
|
57
|
+
});
|
|
58
|
+
fastify.addHook('onError', (request, _reply, error, done) => {
|
|
59
|
+
try {
|
|
60
|
+
const span = request[SPAN_KEY];
|
|
61
|
+
if (span) {
|
|
62
|
+
span.recordException({
|
|
63
|
+
name: error.constructor.name,
|
|
64
|
+
message: error.message,
|
|
65
|
+
stack: error.stack,
|
|
66
|
+
});
|
|
67
|
+
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* never crash */
|
|
72
|
+
}
|
|
73
|
+
done();
|
|
74
|
+
});
|
|
75
|
+
fastify.addHook('onResponse', (request, reply, done) => {
|
|
76
|
+
try {
|
|
77
|
+
const span = request[SPAN_KEY];
|
|
78
|
+
if (span) {
|
|
79
|
+
const statusCode = reply.statusCode ?? 0;
|
|
80
|
+
const route = request.routeOptions?.url ?? request.url ?? '/';
|
|
81
|
+
const start = request[START_KEY] ?? Date.now();
|
|
82
|
+
span.setAttribute('http.status_code', statusCode);
|
|
83
|
+
span.setAttribute('http.route', route);
|
|
84
|
+
span.setAttribute('http.duration_ms', Date.now() - start);
|
|
85
|
+
if (statusCode < 500) {
|
|
86
|
+
span.setStatus({ code: api_1.SpanStatusCode.OK });
|
|
87
|
+
}
|
|
88
|
+
if (!(0, request_context_1.isHealthRoute)(route) && !(0, request_context_1.isHealthRoute)(request.url ?? '/')) {
|
|
89
|
+
(0, request_context_1.captureRequestContext)(span, request);
|
|
90
|
+
}
|
|
91
|
+
span.end();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* never crash */
|
|
96
|
+
}
|
|
97
|
+
done();
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request context capture — always-on, fail-safe serialization of HTTP request
|
|
3
|
+
* inputs into a single OTel span event called "request.context".
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists:
|
|
6
|
+
* When a crash occurs, the exception alone (type + message + stack) often
|
|
7
|
+
* isn't enough to reproduce it — you need the inputs: route params, query,
|
|
8
|
+
* body, auth token, etc. This module generically captures whatever is on the
|
|
9
|
+
* request object and attaches it as a span event (not span attributes — those
|
|
10
|
+
* would blow up cardinality indexes).
|
|
11
|
+
*
|
|
12
|
+
* Why always-on:
|
|
13
|
+
* If opt-in, nobody enables it. The safety controls make it safe on every request.
|
|
14
|
+
*
|
|
15
|
+
* Why generic field capture:
|
|
16
|
+
* Different services put auth in different places (req.auth, req.user,
|
|
17
|
+
* req.jwtPayload). Rather than maintaining a per-service mapping, we loop
|
|
18
|
+
* over REQUEST_CONTEXT_FIELDS and serialize whatever is present.
|
|
19
|
+
*
|
|
20
|
+
* Safety controls:
|
|
21
|
+
* - Body truncation: 4KB limit prevents span bloat from file uploads, bulk
|
|
22
|
+
* CSV, or base64 media.
|
|
23
|
+
* - Field redaction: password, token, authorization, secret, creditCard are
|
|
24
|
+
* replaced with "[REDACTED]". Deep-redacts nested objects. Case-insensitive.
|
|
25
|
+
* - Health route skip: /health, /healthz, /ready, /status produce no event.
|
|
26
|
+
* - Fail-safe: every property access AND serialization is in its own try/catch.
|
|
27
|
+
* A throwing getter, circular object, or proxy trap on one field can never
|
|
28
|
+
* prevent other fields from being captured, and can never crash the request.
|
|
29
|
+
*/
|
|
30
|
+
import type { Span } from '@opentelemetry/api';
|
|
31
|
+
export declare function isHealthRoute(path: string): boolean;
|
|
32
|
+
export declare function captureRequestContext(span: Span, req: any): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Request context capture — always-on, fail-safe serialization of HTTP request
|
|
4
|
+
* inputs into a single OTel span event called "request.context".
|
|
5
|
+
*
|
|
6
|
+
* Why this file exists:
|
|
7
|
+
* When a crash occurs, the exception alone (type + message + stack) often
|
|
8
|
+
* isn't enough to reproduce it — you need the inputs: route params, query,
|
|
9
|
+
* body, auth token, etc. This module generically captures whatever is on the
|
|
10
|
+
* request object and attaches it as a span event (not span attributes — those
|
|
11
|
+
* would blow up cardinality indexes).
|
|
12
|
+
*
|
|
13
|
+
* Why always-on:
|
|
14
|
+
* If opt-in, nobody enables it. The safety controls make it safe on every request.
|
|
15
|
+
*
|
|
16
|
+
* Why generic field capture:
|
|
17
|
+
* Different services put auth in different places (req.auth, req.user,
|
|
18
|
+
* req.jwtPayload). Rather than maintaining a per-service mapping, we loop
|
|
19
|
+
* over REQUEST_CONTEXT_FIELDS and serialize whatever is present.
|
|
20
|
+
*
|
|
21
|
+
* Safety controls:
|
|
22
|
+
* - Body truncation: 4KB limit prevents span bloat from file uploads, bulk
|
|
23
|
+
* CSV, or base64 media.
|
|
24
|
+
* - Field redaction: password, token, authorization, secret, creditCard are
|
|
25
|
+
* replaced with "[REDACTED]". Deep-redacts nested objects. Case-insensitive.
|
|
26
|
+
* - Health route skip: /health, /healthz, /ready, /status produce no event.
|
|
27
|
+
* - Fail-safe: every property access AND serialization is in its own try/catch.
|
|
28
|
+
* A throwing getter, circular object, or proxy trap on one field can never
|
|
29
|
+
* prevent other fields from being captured, and can never crash the request.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.isHealthRoute = isHealthRoute;
|
|
33
|
+
exports.captureRequestContext = captureRequestContext;
|
|
34
|
+
const constants_1 = require("../constants");
|
|
35
|
+
function truncate(value, limit) {
|
|
36
|
+
if (value.length <= limit) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
return value.slice(0, limit) + '...[truncated]';
|
|
40
|
+
}
|
|
41
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
42
|
+
function redactValue(obj) {
|
|
43
|
+
if (typeof obj === 'string') {
|
|
44
|
+
return obj;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(obj)) {
|
|
47
|
+
return obj.map((item) => redactValue(item));
|
|
48
|
+
}
|
|
49
|
+
if (obj !== null && typeof obj === 'object') {
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
52
|
+
if (constants_1.REDACTED_KEYS.has(key.toLowerCase())) {
|
|
53
|
+
result[key] = '[REDACTED]';
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
result[key] = redactValue(val);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
return obj;
|
|
62
|
+
}
|
|
63
|
+
function redact(raw) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
return JSON.stringify(redactValue(parsed));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
73
|
+
function isHealthRoute(path) {
|
|
74
|
+
return constants_1.HEALTH_ROUTES.has(path);
|
|
75
|
+
}
|
|
76
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
77
|
+
function captureRequestContext(span, req) {
|
|
78
|
+
try {
|
|
79
|
+
const attrs = {};
|
|
80
|
+
const fields = {};
|
|
81
|
+
for (const key of constants_1.REQUEST_CONTEXT_FIELDS) {
|
|
82
|
+
try {
|
|
83
|
+
const val = req[key];
|
|
84
|
+
if (val !== undefined && val !== null) {
|
|
85
|
+
fields[key] = val;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* skip inaccessible properties */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [key, val] of Object.entries(fields)) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = typeof val === 'string' ? val : JSON.stringify(val);
|
|
95
|
+
if (key === 'body') {
|
|
96
|
+
attrs[key] = truncate(redact(raw), constants_1.BODY_TRUNCATION_LIMIT);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
attrs[key] = redact(typeof val === 'object' ? raw : String(val));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* skip unserializable values */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (Object.keys(attrs).length > 0) {
|
|
107
|
+
span.addEvent('request.context', attrs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* never crash the request */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-export for @foam/node-cliengo.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists:
|
|
5
|
+
* Single entry point so consumers import from '@foam/node-cliengo' directly.
|
|
6
|
+
* Re-exports init() (the main API), individual helpers for advanced usage
|
|
7
|
+
* (buildTraceparent, injectSnsAttributes, etc.), and all public types.
|
|
8
|
+
*
|
|
9
|
+
* Most services only need: import { init } from '@foam/node-cliengo'
|
|
10
|
+
* Everything else is accessed via the returned OtelBridge object.
|
|
11
|
+
*
|
|
12
|
+
* The individual function exports exist for tree-shaking and for cases where
|
|
13
|
+
* a service needs to call e.g. injectJobData() from a file that doesn't have
|
|
14
|
+
* access to the bridge instance.
|
|
15
|
+
*/
|
|
16
|
+
export { init } from './init';
|
|
17
|
+
export { buildTraceparent, extractParentContext, getTraceContext } from './trace-bridge';
|
|
18
|
+
export { injectSnsAttributes } from './sns';
|
|
19
|
+
export { injectJobData, wrapJobConsumer } from './job';
|
|
20
|
+
export { wrapSqsConsumer } from './sqs';
|
|
21
|
+
export { createExpressMiddleware, createExpressErrorHandler } from './http/express';
|
|
22
|
+
export { createFastifyPlugin } from './http/fastify';
|
|
23
|
+
export { createWinstonFormat } from './logs/winston-format';
|
|
24
|
+
export { createPinoMixin } from './logs/pino-mixin';
|
|
25
|
+
export { FOAM_OTEL_ENDPOINT } from './constants';
|
|
26
|
+
export type { BridgeMetrics, Counter, ExpressErrorHandler, ExpressRequestHandler, FastifyPluginAsync, Histogram, InitOptions, NewRelicAgent, ObservableGauge, OtelBridge, SnsMessageAttributeValue, SqsMessage, TraceContext, WinstonFormat, WinstonLogger, WinstonTransport, } from './types';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Barrel re-export for @foam/node-cliengo.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists:
|
|
6
|
+
* Single entry point so consumers import from '@foam/node-cliengo' directly.
|
|
7
|
+
* Re-exports init() (the main API), individual helpers for advanced usage
|
|
8
|
+
* (buildTraceparent, injectSnsAttributes, etc.), and all public types.
|
|
9
|
+
*
|
|
10
|
+
* Most services only need: import { init } from '@foam/node-cliengo'
|
|
11
|
+
* Everything else is accessed via the returned OtelBridge object.
|
|
12
|
+
*
|
|
13
|
+
* The individual function exports exist for tree-shaking and for cases where
|
|
14
|
+
* a service needs to call e.g. injectJobData() from a file that doesn't have
|
|
15
|
+
* access to the bridge instance.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.FOAM_OTEL_ENDPOINT = exports.createPinoMixin = exports.createWinstonFormat = exports.createFastifyPlugin = exports.createExpressErrorHandler = exports.createExpressMiddleware = exports.wrapSqsConsumer = exports.wrapJobConsumer = exports.injectJobData = exports.injectSnsAttributes = exports.getTraceContext = exports.extractParentContext = exports.buildTraceparent = exports.init = void 0;
|
|
19
|
+
var init_1 = require("./init");
|
|
20
|
+
Object.defineProperty(exports, "init", { enumerable: true, get: function () { return init_1.init; } });
|
|
21
|
+
var trace_bridge_1 = require("./trace-bridge");
|
|
22
|
+
Object.defineProperty(exports, "buildTraceparent", { enumerable: true, get: function () { return trace_bridge_1.buildTraceparent; } });
|
|
23
|
+
Object.defineProperty(exports, "extractParentContext", { enumerable: true, get: function () { return trace_bridge_1.extractParentContext; } });
|
|
24
|
+
Object.defineProperty(exports, "getTraceContext", { enumerable: true, get: function () { return trace_bridge_1.getTraceContext; } });
|
|
25
|
+
var sns_1 = require("./sns");
|
|
26
|
+
Object.defineProperty(exports, "injectSnsAttributes", { enumerable: true, get: function () { return sns_1.injectSnsAttributes; } });
|
|
27
|
+
var job_1 = require("./job");
|
|
28
|
+
Object.defineProperty(exports, "injectJobData", { enumerable: true, get: function () { return job_1.injectJobData; } });
|
|
29
|
+
Object.defineProperty(exports, "wrapJobConsumer", { enumerable: true, get: function () { return job_1.wrapJobConsumer; } });
|
|
30
|
+
var sqs_1 = require("./sqs");
|
|
31
|
+
Object.defineProperty(exports, "wrapSqsConsumer", { enumerable: true, get: function () { return sqs_1.wrapSqsConsumer; } });
|
|
32
|
+
var express_1 = require("./http/express");
|
|
33
|
+
Object.defineProperty(exports, "createExpressMiddleware", { enumerable: true, get: function () { return express_1.createExpressMiddleware; } });
|
|
34
|
+
Object.defineProperty(exports, "createExpressErrorHandler", { enumerable: true, get: function () { return express_1.createExpressErrorHandler; } });
|
|
35
|
+
var fastify_1 = require("./http/fastify");
|
|
36
|
+
Object.defineProperty(exports, "createFastifyPlugin", { enumerable: true, get: function () { return fastify_1.createFastifyPlugin; } });
|
|
37
|
+
var winston_format_1 = require("./logs/winston-format");
|
|
38
|
+
Object.defineProperty(exports, "createWinstonFormat", { enumerable: true, get: function () { return winston_format_1.createWinstonFormat; } });
|
|
39
|
+
var pino_mixin_1 = require("./logs/pino-mixin");
|
|
40
|
+
Object.defineProperty(exports, "createPinoMixin", { enumerable: true, get: function () { return pino_mixin_1.createPinoMixin; } });
|
|
41
|
+
var constants_1 = require("./constants");
|
|
42
|
+
Object.defineProperty(exports, "FOAM_OTEL_ENDPOINT", { enumerable: true, get: function () { return constants_1.FOAM_OTEL_ENDPOINT; } });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main entry point — init() creates the OtelBridge singleton for a service.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists:
|
|
5
|
+
* Orchestrates all three OTel signals (traces, logs, metrics) into a single
|
|
6
|
+
* init() call that returns an OtelBridge object. Services call init() once at
|
|
7
|
+
* startup and use the returned bridge for everything else. The bridge is
|
|
8
|
+
* designed to coexist with New Relic APM without touching NR's globals.
|
|
9
|
+
*
|
|
10
|
+
* Two-gate system:
|
|
11
|
+
* 1. Kill switch (FOAM_OTEL_ENABLED): if false, returns a fully inert bridge.
|
|
12
|
+
* Every API exists but does nothing — zero overhead, zero network, zero risk.
|
|
13
|
+
* This is the master emergency shutoff.
|
|
14
|
+
* 2. Production gate (NODE_ENV): exporters are only attached when NODE_ENV is
|
|
15
|
+
* "production" (or forceExport is true). In dev/test, the bridge is fully
|
|
16
|
+
* functional (trace propagation, log enrichment work) but nothing is sent
|
|
17
|
+
* to Foam. Developers without FOAM_OTEL_TOKEN get no crashes.
|
|
18
|
+
*
|
|
19
|
+
* Edge cases covered:
|
|
20
|
+
* - Double init(): logs a warning, returns the existing bridge (singleton
|
|
21
|
+
* per service name). Prevents duplicate providers/processors/handlers.
|
|
22
|
+
* - Token undefined in dev: silently accepted — no OTLP export anyway.
|
|
23
|
+
* - Token undefined in prod: logs warning, disables exporters. All other
|
|
24
|
+
* features (trace propagation, log enrichment, NR bridging) still work.
|
|
25
|
+
* - Token undefined + forceExport: throws (fail-fast — you asked to force
|
|
26
|
+
* export but provided no credentials).
|
|
27
|
+
* - Auto-shutdown (SIGTERM/SIGINT): flushes all providers with 5s timeout,
|
|
28
|
+
* then calls process.exit(0). Without the explicit exit, registering
|
|
29
|
+
* process.on('SIGTERM') replaces Node's default terminate behavior — services
|
|
30
|
+
* without their own handler (gpt-intentions, cb-proxy) would hang forever.
|
|
31
|
+
* - Manual shutdown: services with ordered cleanup (hsm-backend, kori, combee)
|
|
32
|
+
* pass autoShutdown: false and call bridge.shutdown() in their own handler.
|
|
33
|
+
* - shutdown() is idempotent — safe to call multiple times.
|
|
34
|
+
* - Process exception handlers use process.on() (not .once()) so they don't
|
|
35
|
+
* replace NR's or Winston's existing handlers. All handlers fire independently.
|
|
36
|
+
* - uncaughtException handler flushes via shutdown() — best effort to get the
|
|
37
|
+
* fatal log to Foam before the process dies. Never calls process.exit().
|
|
38
|
+
* - Winston auto-wiring: init({ winston: baseLogger }) calls logger.add() for
|
|
39
|
+
* the OTLP transport and rewrites logger.format to prepend trace enrichment.
|
|
40
|
+
* Both work post-construction. Avoids circular deps (main.ts imports logger,
|
|
41
|
+
* not the other way around).
|
|
42
|
+
* - First-export health check: monkey-patches the BatchSpanProcessor's exporter
|
|
43
|
+
* to log success/failure on the first OTLP export. Non-critical — wrapped in
|
|
44
|
+
* try/catch, uses internal OTel SDK APIs that may change.
|
|
45
|
+
* - Non-global providers: BasicTracerProvider, LoggerProvider, MeterProvider
|
|
46
|
+
* are NEVER registered globally (.register() never called). NR owns the
|
|
47
|
+
* global registrations.
|
|
48
|
+
*/
|
|
49
|
+
import type { InitOptions, OtelBridge } from './types';
|
|
50
|
+
export declare function init(serviceName: string, token?: string, options?: InitOptions): OtelBridge;
|