@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.
Files changed (39) hide show
  1. package/dist/node-cliengo/src/constants.d.ts +26 -0
  2. package/dist/node-cliengo/src/constants.js +49 -0
  3. package/dist/node-cliengo/src/http/express.d.ts +41 -0
  4. package/dist/node-cliengo/src/http/express.js +123 -0
  5. package/dist/node-cliengo/src/http/fastify.d.ts +28 -0
  6. package/dist/node-cliengo/src/http/fastify.js +101 -0
  7. package/dist/node-cliengo/src/http/request-context.d.ts +32 -0
  8. package/dist/node-cliengo/src/http/request-context.js +114 -0
  9. package/dist/node-cliengo/src/index.d.ts +26 -0
  10. package/dist/node-cliengo/src/index.js +42 -0
  11. package/dist/node-cliengo/src/init.d.ts +50 -0
  12. package/dist/node-cliengo/src/init.js +348 -0
  13. package/dist/node-cliengo/src/job.d.ts +31 -0
  14. package/dist/node-cliengo/src/job.js +86 -0
  15. package/dist/node-cliengo/src/logs/pino-destination.d.ts +31 -0
  16. package/dist/node-cliengo/src/logs/pino-destination.js +100 -0
  17. package/dist/node-cliengo/src/logs/pino-mixin.d.ts +22 -0
  18. package/dist/node-cliengo/src/logs/pino-mixin.js +40 -0
  19. package/dist/node-cliengo/src/logs/winston-format.d.ts +28 -0
  20. package/dist/node-cliengo/src/logs/winston-format.js +48 -0
  21. package/dist/node-cliengo/src/logs/winston-transport.d.ts +31 -0
  22. package/dist/node-cliengo/src/logs/winston-transport.js +93 -0
  23. package/dist/node-cliengo/src/metrics/instruments.d.ts +29 -0
  24. package/dist/node-cliengo/src/metrics/instruments.js +78 -0
  25. package/dist/node-cliengo/src/nr.d.ts +35 -0
  26. package/dist/node-cliengo/src/nr.js +71 -0
  27. package/dist/node-cliengo/src/sns.d.ts +19 -0
  28. package/dist/node-cliengo/src/sns.js +31 -0
  29. package/dist/node-cliengo/src/sqs.d.ts +35 -0
  30. package/dist/node-cliengo/src/sqs.js +110 -0
  31. package/dist/node-cliengo/src/trace-bridge.d.ts +39 -0
  32. package/dist/node-cliengo/src/trace-bridge.js +79 -0
  33. package/dist/node-cliengo/src/types.d.ts +115 -0
  34. package/dist/node-cliengo/src/types.js +26 -0
  35. package/dist/shared/constants.d.ts +1 -0
  36. package/dist/shared/constants.js +4 -0
  37. package/dist/shared/util.d.ts +9 -0
  38. package/dist/shared/util.js +21 -0
  39. package/package.json +54 -0
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ /**
3
+ * Main entry point — init() creates the OtelBridge singleton for a service.
4
+ *
5
+ * Why this file exists:
6
+ * Orchestrates all three OTel signals (traces, logs, metrics) into a single
7
+ * init() call that returns an OtelBridge object. Services call init() once at
8
+ * startup and use the returned bridge for everything else. The bridge is
9
+ * designed to coexist with New Relic APM without touching NR's globals.
10
+ *
11
+ * Two-gate system:
12
+ * 1. Kill switch (FOAM_OTEL_ENABLED): if false, returns a fully inert bridge.
13
+ * Every API exists but does nothing — zero overhead, zero network, zero risk.
14
+ * This is the master emergency shutoff.
15
+ * 2. Production gate (NODE_ENV): exporters are only attached when NODE_ENV is
16
+ * "production" (or forceExport is true). In dev/test, the bridge is fully
17
+ * functional (trace propagation, log enrichment work) but nothing is sent
18
+ * to Foam. Developers without FOAM_OTEL_TOKEN get no crashes.
19
+ *
20
+ * Edge cases covered:
21
+ * - Double init(): logs a warning, returns the existing bridge (singleton
22
+ * per service name). Prevents duplicate providers/processors/handlers.
23
+ * - Token undefined in dev: silently accepted — no OTLP export anyway.
24
+ * - Token undefined in prod: logs warning, disables exporters. All other
25
+ * features (trace propagation, log enrichment, NR bridging) still work.
26
+ * - Token undefined + forceExport: throws (fail-fast — you asked to force
27
+ * export but provided no credentials).
28
+ * - Auto-shutdown (SIGTERM/SIGINT): flushes all providers with 5s timeout,
29
+ * then calls process.exit(0). Without the explicit exit, registering
30
+ * process.on('SIGTERM') replaces Node's default terminate behavior — services
31
+ * without their own handler (gpt-intentions, cb-proxy) would hang forever.
32
+ * - Manual shutdown: services with ordered cleanup (hsm-backend, kori, combee)
33
+ * pass autoShutdown: false and call bridge.shutdown() in their own handler.
34
+ * - shutdown() is idempotent — safe to call multiple times.
35
+ * - Process exception handlers use process.on() (not .once()) so they don't
36
+ * replace NR's or Winston's existing handlers. All handlers fire independently.
37
+ * - uncaughtException handler flushes via shutdown() — best effort to get the
38
+ * fatal log to Foam before the process dies. Never calls process.exit().
39
+ * - Winston auto-wiring: init({ winston: baseLogger }) calls logger.add() for
40
+ * the OTLP transport and rewrites logger.format to prepend trace enrichment.
41
+ * Both work post-construction. Avoids circular deps (main.ts imports logger,
42
+ * not the other way around).
43
+ * - First-export health check: monkey-patches the BatchSpanProcessor's exporter
44
+ * to log success/failure on the first OTLP export. Non-critical — wrapped in
45
+ * try/catch, uses internal OTel SDK APIs that may change.
46
+ * - Non-global providers: BasicTracerProvider, LoggerProvider, MeterProvider
47
+ * are NEVER registered globally (.register() never called). NR owns the
48
+ * global registrations.
49
+ */
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.init = init;
52
+ const api_logs_1 = require("@opentelemetry/api-logs");
53
+ const exporter_logs_otlp_http_1 = require("@opentelemetry/exporter-logs-otlp-http");
54
+ const exporter_metrics_otlp_http_1 = require("@opentelemetry/exporter-metrics-otlp-http");
55
+ const exporter_trace_otlp_http_1 = require("@opentelemetry/exporter-trace-otlp-http");
56
+ const resources_1 = require("@opentelemetry/resources");
57
+ const sdk_logs_1 = require("@opentelemetry/sdk-logs");
58
+ const sdk_metrics_1 = require("@opentelemetry/sdk-metrics");
59
+ const sdk_trace_base_1 = require("@opentelemetry/sdk-trace-base");
60
+ const node_stream_1 = require("node:stream");
61
+ const constants_1 = require("./constants");
62
+ const express_1 = require("./http/express");
63
+ const fastify_1 = require("./http/fastify");
64
+ const job_1 = require("./job");
65
+ const pino_destination_1 = require("./logs/pino-destination");
66
+ const pino_mixin_1 = require("./logs/pino-mixin");
67
+ const winston_format_1 = require("./logs/winston-format");
68
+ const winston_transport_1 = require("./logs/winston-transport");
69
+ const instruments_1 = require("./metrics/instruments");
70
+ const nr_1 = require("./nr");
71
+ const sns_1 = require("./sns");
72
+ const sqs_1 = require("./sqs");
73
+ const trace_bridge_1 = require("./trace-bridge");
74
+ const bridges = new Map();
75
+ function createInertBridge(serviceName) {
76
+ const resource = (0, resources_1.resourceFromAttributes)({ 'service.name': serviceName });
77
+ const traceProvider = new sdk_trace_base_1.BasicTracerProvider({ resource });
78
+ const loggerProvider = new sdk_logs_1.LoggerProvider({ resource });
79
+ const meterProvider = new sdk_metrics_1.MeterProvider({ resource });
80
+ const tracer = traceProvider.getTracer(serviceName);
81
+ const meter = meterProvider.getMeter(serviceName);
82
+ const metrics = (0, instruments_1.createBridgeMetrics)(meterProvider, serviceName);
83
+ return {
84
+ tracer,
85
+ meter,
86
+ logger: loggerProvider,
87
+ traceProvider,
88
+ meterProvider,
89
+ loggerProvider,
90
+ metrics,
91
+ getTraceContext: () => ({ traceId: '', spanId: '', traceparent: '' }),
92
+ createExpressMiddleware: () => (_req, _res, next) => next(),
93
+ createExpressErrorHandler: () => (_err, _req, _res, next) => next(_err),
94
+ createFastifyPlugin: () => async () => { },
95
+ createWinstonFormat: () => ({
96
+ transform: (info) => info,
97
+ }),
98
+ createWinstonTransport: () => ({}),
99
+ createPinoMixin: () => () => ({}),
100
+ createPinoDestination: () => new node_stream_1.Writable({
101
+ write: (_c, _e, cb) => cb(),
102
+ }),
103
+ buildTraceparent: () => undefined,
104
+ injectSnsAttributes: (attrs) => attrs,
105
+ injectJobData: (data) => data,
106
+ wrapSqsConsumer: async (_t, _s, _m, fn) => fn(),
107
+ wrapJobConsumer: async (_t, _s, _j, fn) => fn(),
108
+ extractParentContext: () => {
109
+ const { ROOT_CONTEXT } = require('@opentelemetry/api');
110
+ return ROOT_CONTEXT;
111
+ },
112
+ shutdown: async () => { },
113
+ };
114
+ }
115
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
116
+ function init(serviceName, token, options = {}) {
117
+ const existing = bridges.get(serviceName);
118
+ if (existing) {
119
+ // eslint-disable-next-line no-console
120
+ console.warn(`[foam] init() already called for "${serviceName}" — returning existing bridge`);
121
+ return existing;
122
+ }
123
+ // Kill switch
124
+ const isEnabled = (options.enabled ??
125
+ ((process.env.FOAM_OTEL_ENABLED ?? 'true').toLowerCase() === 'true')) === true;
126
+ if (!isEnabled) {
127
+ const bridge = createInertBridge(serviceName);
128
+ bridges.set(serviceName, bridge);
129
+ return bridge;
130
+ }
131
+ // Resolve NR
132
+ (0, nr_1.resolveNewRelic)(options.newrelic);
133
+ // Production gate
134
+ const isProduction = process.env.NODE_ENV === 'production';
135
+ const shouldExport = isProduction || (options.forceExport === true);
136
+ // Token validation
137
+ if (shouldExport && !token) {
138
+ if (options.forceExport) {
139
+ throw new Error('[foam] forceExport is true but no FOAM_OTEL_TOKEN provided — cannot export telemetry');
140
+ }
141
+ // eslint-disable-next-line no-console
142
+ console.warn('[foam] no FOAM_OTEL_TOKEN — OTLP export disabled');
143
+ }
144
+ const hasToken = Boolean(token);
145
+ const canExport = shouldExport && hasToken;
146
+ const endpoint = options.endpoint ?? constants_1.FOAM_OTEL_ENDPOINT;
147
+ const resource = (0, resources_1.resourceFromAttributes)({ 'service.name': serviceName });
148
+ const authHeaders = token
149
+ ? { Authorization: `Bearer ${token}` }
150
+ : {};
151
+ // --- Traces ---
152
+ const spanProcessors = [];
153
+ if (canExport && (options.enableTraces !== false)) {
154
+ const traceExporter = new exporter_trace_otlp_http_1.OTLPTraceExporter({
155
+ url: `${endpoint}/v1/traces`,
156
+ headers: authHeaders,
157
+ });
158
+ spanProcessors.push(new sdk_trace_base_1.BatchSpanProcessor(traceExporter));
159
+ }
160
+ const traceProvider = new sdk_trace_base_1.BasicTracerProvider({
161
+ resource,
162
+ spanProcessors,
163
+ });
164
+ // --- Logs ---
165
+ const logProcessors = [];
166
+ if (canExport && (options.enableLogs !== false)) {
167
+ const logExporter = new exporter_logs_otlp_http_1.OTLPLogExporter({
168
+ url: `${endpoint}/v1/logs`,
169
+ headers: authHeaders,
170
+ });
171
+ logProcessors.push(new sdk_logs_1.BatchLogRecordProcessor(logExporter));
172
+ }
173
+ const loggerProvider = new sdk_logs_1.LoggerProvider({
174
+ resource,
175
+ processors: logProcessors,
176
+ });
177
+ // --- Metrics ---
178
+ const readers = [];
179
+ if (canExport && (options.enableMetrics !== false)) {
180
+ const metricExporter = new exporter_metrics_otlp_http_1.OTLPMetricExporter({
181
+ url: `${endpoint}/v1/metrics`,
182
+ headers: authHeaders,
183
+ });
184
+ const exportIntervalMs = parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL_MS ?? '60000', 10);
185
+ readers.push(new sdk_metrics_1.PeriodicExportingMetricReader({
186
+ exporter: metricExporter,
187
+ exportIntervalMillis: exportIntervalMs,
188
+ }));
189
+ }
190
+ const meterProvider = new sdk_metrics_1.MeterProvider({
191
+ resource,
192
+ readers,
193
+ });
194
+ const tracer = traceProvider.getTracer(serviceName);
195
+ const meter = meterProvider.getMeter(serviceName);
196
+ const metrics = (0, instruments_1.createBridgeMetrics)(meterProvider, serviceName);
197
+ let _isShutdown = false;
198
+ const shutdown = async () => {
199
+ if (_isShutdown) {
200
+ return;
201
+ }
202
+ _isShutdown = true;
203
+ const timeout = new Promise((resolve) => setTimeout(resolve, constants_1.SHUTDOWN_TIMEOUT_MS));
204
+ const flush = Promise.allSettled([
205
+ traceProvider.shutdown(),
206
+ loggerProvider.shutdown(),
207
+ meterProvider.shutdown(),
208
+ ]);
209
+ await Promise.race([flush, timeout]);
210
+ };
211
+ // Auto-shutdown on SIGTERM/SIGINT
212
+ if (options.autoShutdown !== false) {
213
+ const handler = async () => {
214
+ await shutdown();
215
+ process.exit(0);
216
+ };
217
+ process.on('SIGTERM', () => void handler());
218
+ process.on('SIGINT', () => void handler());
219
+ }
220
+ // Process-level exception capture
221
+ const otelLogger = loggerProvider.getLogger(serviceName);
222
+ process.on('uncaughtException', (err) => {
223
+ try {
224
+ otelLogger.emit({
225
+ body: err.message,
226
+ severityNumber: api_logs_1.SeverityNumber.FATAL,
227
+ severityText: 'FATAL',
228
+ attributes: {
229
+ 'exception.type': err.constructor.name,
230
+ 'exception.message': err.message,
231
+ 'exception.stacktrace': err.stack ?? '',
232
+ 'process.event': 'uncaughtException',
233
+ },
234
+ });
235
+ void shutdown();
236
+ }
237
+ catch {
238
+ /* best effort */
239
+ }
240
+ });
241
+ process.on('unhandledRejection', (reason) => {
242
+ try {
243
+ const err = reason instanceof Error ? reason : new Error(String(reason));
244
+ otelLogger.emit({
245
+ body: err.message,
246
+ severityNumber: api_logs_1.SeverityNumber.ERROR,
247
+ severityText: 'ERROR',
248
+ attributes: {
249
+ 'exception.type': err.constructor.name,
250
+ 'exception.message': err.message,
251
+ 'exception.stacktrace': err.stack ?? '',
252
+ 'process.event': 'unhandledRejection',
253
+ },
254
+ });
255
+ }
256
+ catch {
257
+ /* best effort */
258
+ }
259
+ });
260
+ // Winston auto-wiring
261
+ if (options.winston) {
262
+ const winstonLogger = options.winston;
263
+ try {
264
+ const transport = (0, winston_transport_1.createWinstonTransport)(loggerProvider, serviceName);
265
+ if (typeof winstonLogger.add === 'function') {
266
+ winstonLogger.add(transport);
267
+ }
268
+ const format = (0, winston_format_1.createWinstonFormat)();
269
+ if (winstonLogger.format) {
270
+ const originalFormat = winstonLogger.format;
271
+ winstonLogger.format = {
272
+ transform: (info) => {
273
+ const enriched = format.transform(info);
274
+ if (typeof originalFormat.transform === 'function') {
275
+ return originalFormat.transform(enriched);
276
+ }
277
+ return enriched;
278
+ },
279
+ };
280
+ }
281
+ }
282
+ catch {
283
+ /* non-critical */
284
+ }
285
+ }
286
+ // First-export health check
287
+ if (canExport && spanProcessors.length > 0) {
288
+ let firstExportLogged = false;
289
+ try {
290
+ const proc = spanProcessors[0];
291
+ const exporter = proc._exporter;
292
+ if (exporter && typeof exporter.send === 'function') {
293
+ const origSend = exporter.send;
294
+ exporter.send = function (...args) {
295
+ const cb = args[args.length - 1];
296
+ if (typeof cb === 'function') {
297
+ args[args.length - 1] = (result) => {
298
+ if (!firstExportLogged) {
299
+ firstExportLogged = true;
300
+ if (result?.error) {
301
+ // eslint-disable-next-line no-console
302
+ console.warn(`[foam] OTLP export failed: ${String(result.error)} — check FOAM_OTEL_TOKEN and endpoint`);
303
+ }
304
+ else {
305
+ // eslint-disable-next-line no-console
306
+ console.info('[foam] first OTLP export succeeded — traces flowing to Foam');
307
+ }
308
+ }
309
+ return cb(result);
310
+ };
311
+ }
312
+ return origSend.apply(this, args);
313
+ };
314
+ }
315
+ }
316
+ catch {
317
+ /* non-critical */
318
+ }
319
+ }
320
+ const expressMiddleware = (0, express_1.createExpressMiddleware)(tracer);
321
+ const bridge = {
322
+ tracer,
323
+ meter,
324
+ logger: loggerProvider,
325
+ traceProvider,
326
+ meterProvider,
327
+ loggerProvider,
328
+ metrics,
329
+ getTraceContext: trace_bridge_1.getTraceContext,
330
+ createExpressMiddleware: () => expressMiddleware,
331
+ createExpressErrorHandler: () => (0, express_1.createExpressErrorHandler)(expressMiddleware),
332
+ createFastifyPlugin: () => (0, fastify_1.createFastifyPlugin)(tracer),
333
+ createWinstonFormat: () => (0, winston_format_1.createWinstonFormat)(),
334
+ createWinstonTransport: () => (0, winston_transport_1.createWinstonTransport)(loggerProvider, serviceName),
335
+ createPinoMixin: () => (0, pino_mixin_1.createPinoMixin)(),
336
+ createPinoDestination: () => (0, pino_destination_1.createPinoDestination)(loggerProvider, serviceName),
337
+ buildTraceparent: trace_bridge_1.buildTraceparent,
338
+ injectSnsAttributes: sns_1.injectSnsAttributes,
339
+ injectJobData: job_1.injectJobData,
340
+ wrapSqsConsumer: (t, s, m, fn) => (0, sqs_1.wrapSqsConsumer)(t, s, m, fn, metrics),
341
+ wrapJobConsumer: (t, s, j, fn) => (0, job_1.wrapJobConsumer)(t, s, j, fn, metrics),
342
+ extractParentContext: trace_bridge_1.extractParentContext,
343
+ shutdown,
344
+ };
345
+ bridges.set(serviceName, bridge);
346
+ return bridge;
347
+ }
348
+ /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * BullMQ / Bull job data injection and consumer wrapper.
3
+ *
4
+ * Why this file exists:
5
+ * Provides two functions for queue-based trace propagation:
6
+ * - injectJobData(): adds a `traceparent` field to job data before enqueueing.
7
+ * Works identically with both BullMQ and Bull because both expose job.data
8
+ * as a plain object. Used by gpt-intentions (BullMQ), hsm-backend (BullMQ),
9
+ * and cb-proxy (Bull).
10
+ * - wrapJobConsumer(): wraps the job processing function in an OTel span and
11
+ * NR background transaction, extracting traceparent from job.data. Used by
12
+ * hsm-backend (BullMQ worker) and cb-proxy (Bull processor).
13
+ *
14
+ * Edge cases covered:
15
+ * - No active NR transaction when injecting (producer side): buildTraceparent
16
+ * returns undefined, original data returned unchanged — no traceparent key.
17
+ * - Job data already has traceparent: overwritten with current value (spread).
18
+ * - Job data has no traceparent (consumer side, Phase 1): creates a fresh
19
+ * trace — no parent context, no error.
20
+ * - fn() throws: exception recorded on span, error metrics incremented, error
21
+ * re-thrown so BullMQ/Bull retry logic (attempts, backoff, DLQ) is unaffected.
22
+ * - NR not loaded: skips startBackgroundTransaction, calls execute() directly.
23
+ * - Non-Error thrown (e.g. string): wrapped in new Error(String(err)) for
24
+ * consistent exception recording.
25
+ */
26
+ import { type Tracer } from '@opentelemetry/api';
27
+ import type { BridgeMetrics } from './types';
28
+ export declare function injectJobData<T extends Record<string, unknown>>(data: T): T & {
29
+ traceparent?: string;
30
+ };
31
+ export declare function wrapJobConsumer(tracer: Tracer, spanName: string, jobData: Record<string, unknown>, fn: () => Promise<void>, metrics?: BridgeMetrics): Promise<void>;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * BullMQ / Bull job data injection and consumer wrapper.
4
+ *
5
+ * Why this file exists:
6
+ * Provides two functions for queue-based trace propagation:
7
+ * - injectJobData(): adds a `traceparent` field to job data before enqueueing.
8
+ * Works identically with both BullMQ and Bull because both expose job.data
9
+ * as a plain object. Used by gpt-intentions (BullMQ), hsm-backend (BullMQ),
10
+ * and cb-proxy (Bull).
11
+ * - wrapJobConsumer(): wraps the job processing function in an OTel span and
12
+ * NR background transaction, extracting traceparent from job.data. Used by
13
+ * hsm-backend (BullMQ worker) and cb-proxy (Bull processor).
14
+ *
15
+ * Edge cases covered:
16
+ * - No active NR transaction when injecting (producer side): buildTraceparent
17
+ * returns undefined, original data returned unchanged — no traceparent key.
18
+ * - Job data already has traceparent: overwritten with current value (spread).
19
+ * - Job data has no traceparent (consumer side, Phase 1): creates a fresh
20
+ * trace — no parent context, no error.
21
+ * - fn() throws: exception recorded on span, error metrics incremented, error
22
+ * re-thrown so BullMQ/Bull retry logic (attempts, backoff, DLQ) is unaffected.
23
+ * - NR not loaded: skips startBackgroundTransaction, calls execute() directly.
24
+ * - Non-Error thrown (e.g. string): wrapped in new Error(String(err)) for
25
+ * consistent exception recording.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.injectJobData = injectJobData;
29
+ exports.wrapJobConsumer = wrapJobConsumer;
30
+ const api_1 = require("@opentelemetry/api");
31
+ const nr_1 = require("./nr");
32
+ const trace_bridge_1 = require("./trace-bridge");
33
+ function injectJobData(data) {
34
+ const tp = (0, trace_bridge_1.buildTraceparent)();
35
+ if (!tp) {
36
+ return data;
37
+ }
38
+ return { ...data, traceparent: tp };
39
+ }
40
+ async function wrapJobConsumer(tracer, spanName, jobData, fn, metrics) {
41
+ const nr = (0, nr_1.getNr)();
42
+ const traceparent = jobData.traceparent ?? '';
43
+ const parentCtx = traceparent ? (0, trace_bridge_1.extractParentContext)(traceparent) : undefined;
44
+ const headers = {};
45
+ if (traceparent) {
46
+ headers.traceparent = traceparent;
47
+ }
48
+ const execute = async () => {
49
+ nr.acceptDistributedTraceHeaders?.('Queue', headers);
50
+ const span = tracer.startSpan(spanName, {
51
+ attributes: { 'messaging.system': 'bullmq', 'messaging.operation': 'process' },
52
+ }, parentCtx);
53
+ const start = Date.now();
54
+ try {
55
+ await fn();
56
+ span.setStatus({ code: api_1.SpanStatusCode.OK });
57
+ metrics?.messagesProcessed.add(1, { queue: spanName, status: 'success' });
58
+ }
59
+ catch (err) {
60
+ const error = err instanceof Error ? err : new Error(String(err));
61
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
62
+ span.recordException({
63
+ name: error.constructor.name,
64
+ message: error.message,
65
+ stack: error.stack,
66
+ });
67
+ metrics?.messageProcessingErrors.add(1, {
68
+ queue: spanName,
69
+ 'error.type': error.constructor.name,
70
+ });
71
+ metrics?.messagesProcessed.add(1, { queue: spanName, status: 'error' });
72
+ throw err;
73
+ }
74
+ finally {
75
+ const duration = Date.now() - start;
76
+ metrics?.messageProcessingDuration.record(duration, { queue: spanName });
77
+ span.end();
78
+ }
79
+ };
80
+ if (nr.startBackgroundTransaction) {
81
+ await nr.startBackgroundTransaction(spanName, 'QueueConsumer', execute);
82
+ }
83
+ else {
84
+ await execute();
85
+ }
86
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pino writable destination that delivers every JSON log line to Foam via the
3
+ * OTLP logs pipeline (non-global LoggerProvider → BatchLogRecordProcessor →
4
+ * OTLPLogExporter).
5
+ *
6
+ * Why this file exists:
7
+ * Pino's destination stream is immutable after construction — there's no
8
+ * `logger.addDestination()` API like Winston's `logger.add()`. Services must
9
+ * create the logger with `pino.multistream()` from the start, sending to both
10
+ * stdout (NR) and this destination (Foam).
11
+ *
12
+ * Used by kori (via Fastify) and cb-proxy (via pino + pino-http).
13
+ *
14
+ * Edge cases covered:
15
+ * - Non-JSON lines (e.g. pino-pretty colored output in development): silently
16
+ * skipped. The check is `trimmed.startsWith('{')` — fast rejection of
17
+ * pretty-printed or binary lines. No crash, no error.
18
+ * - Malformed JSON (truncated log line, encoding issue): JSON.parse catch
19
+ * silently skips the line. Never crashes.
20
+ * - Pino level mapping: 10→TRACE, 20→DEBUG, 30→INFO, 40→WARN, 50→ERROR,
21
+ * 60→FATAL. Unknown numeric levels default to INFO.
22
+ * - Pino uses `msg` for the message field (not `message` like Winston). We
23
+ * check both for compatibility with custom Pino configs.
24
+ * - Internal Pino fields (pid, hostname, time) are excluded from attributes
25
+ * to avoid noise. All other fields are forwarded.
26
+ * - Callback always called — never blocks the Pino stream pipeline.
27
+ * - Buffer chunks: converted to string before parsing (handles both Buffer
28
+ * and string inputs from Node streams).
29
+ */
30
+ import type { LoggerProvider } from '@opentelemetry/sdk-logs';
31
+ export declare function createPinoDestination(loggerProvider: LoggerProvider, serviceName: string): NodeJS.WritableStream;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ /**
3
+ * Pino writable destination that delivers every JSON log line to Foam via the
4
+ * OTLP logs pipeline (non-global LoggerProvider → BatchLogRecordProcessor →
5
+ * OTLPLogExporter).
6
+ *
7
+ * Why this file exists:
8
+ * Pino's destination stream is immutable after construction — there's no
9
+ * `logger.addDestination()` API like Winston's `logger.add()`. Services must
10
+ * create the logger with `pino.multistream()` from the start, sending to both
11
+ * stdout (NR) and this destination (Foam).
12
+ *
13
+ * Used by kori (via Fastify) and cb-proxy (via pino + pino-http).
14
+ *
15
+ * Edge cases covered:
16
+ * - Non-JSON lines (e.g. pino-pretty colored output in development): silently
17
+ * skipped. The check is `trimmed.startsWith('{')` — fast rejection of
18
+ * pretty-printed or binary lines. No crash, no error.
19
+ * - Malformed JSON (truncated log line, encoding issue): JSON.parse catch
20
+ * silently skips the line. Never crashes.
21
+ * - Pino level mapping: 10→TRACE, 20→DEBUG, 30→INFO, 40→WARN, 50→ERROR,
22
+ * 60→FATAL. Unknown numeric levels default to INFO.
23
+ * - Pino uses `msg` for the message field (not `message` like Winston). We
24
+ * check both for compatibility with custom Pino configs.
25
+ * - Internal Pino fields (pid, hostname, time) are excluded from attributes
26
+ * to avoid noise. All other fields are forwarded.
27
+ * - Callback always called — never blocks the Pino stream pipeline.
28
+ * - Buffer chunks: converted to string before parsing (handles both Buffer
29
+ * and string inputs from Node streams).
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.createPinoDestination = createPinoDestination;
33
+ const api_logs_1 = require("@opentelemetry/api-logs");
34
+ const node_stream_1 = require("node:stream");
35
+ const trace_bridge_1 = require("../trace-bridge");
36
+ const PINO_LEVEL_MAP = {
37
+ 10: api_logs_1.SeverityNumber.TRACE,
38
+ 20: api_logs_1.SeverityNumber.DEBUG,
39
+ 30: api_logs_1.SeverityNumber.INFO,
40
+ 40: api_logs_1.SeverityNumber.WARN,
41
+ 50: api_logs_1.SeverityNumber.ERROR,
42
+ 60: api_logs_1.SeverityNumber.FATAL,
43
+ };
44
+ const PINO_LEVEL_TEXT = {
45
+ 10: 'TRACE',
46
+ 20: 'DEBUG',
47
+ 30: 'INFO',
48
+ 40: 'WARN',
49
+ 50: 'ERROR',
50
+ 60: 'FATAL',
51
+ };
52
+ function createPinoDestination(loggerProvider, serviceName) {
53
+ const otelLogger = loggerProvider.getLogger(serviceName);
54
+ return new node_stream_1.Writable({
55
+ write(chunk, _encoding, callback) {
56
+ try {
57
+ const line = typeof chunk === 'string' ? chunk : chunk.toString();
58
+ const trimmed = line.trim();
59
+ if (!trimmed.startsWith('{')) {
60
+ // Non-JSON line (e.g. pino-pretty output) — silently skip
61
+ callback();
62
+ return;
63
+ }
64
+ const parsed = JSON.parse(trimmed);
65
+ const level = typeof parsed.level === 'number' ? parsed.level : 30;
66
+ const msg = parsed.msg ?? parsed.message ?? '';
67
+ const severity = PINO_LEVEL_MAP[level] ?? api_logs_1.SeverityNumber.INFO;
68
+ const severityText = PINO_LEVEL_TEXT[level] ?? 'INFO';
69
+ const ctx = (0, trace_bridge_1.getTraceContext)();
70
+ const attributes = {};
71
+ if (ctx.traceId) {
72
+ attributes['trace.id'] = ctx.traceId;
73
+ }
74
+ if (ctx.spanId) {
75
+ attributes['span.id'] = ctx.spanId;
76
+ }
77
+ for (const [key, val] of Object.entries(parsed)) {
78
+ if (key !== 'level' && key !== 'msg' && key !== 'message' && key !== 'time' && key !== 'pid' && key !== 'hostname') {
79
+ try {
80
+ attributes[key] = typeof val === 'string' ? val : JSON.stringify(val);
81
+ }
82
+ catch {
83
+ /* skip */
84
+ }
85
+ }
86
+ }
87
+ otelLogger.emit({
88
+ body: msg,
89
+ severityNumber: severity,
90
+ severityText,
91
+ attributes,
92
+ });
93
+ }
94
+ catch {
95
+ // Malformed JSON or unexpected structure — silently skip
96
+ }
97
+ callback();
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pino mixin for trace context enrichment.
3
+ *
4
+ * Why this file exists:
5
+ * Pino's `mixin` option is a function called on every log that returns extra
6
+ * fields to merge into the log entry. This mixin injects { traceId, spanId,
7
+ * traceparent } into every Pino log line so NR's stdout-based log forwarding
8
+ * can correlate logs with traces. Like the Winston format, it does NOT send
9
+ * logs anywhere — that's the destination's job.
10
+ *
11
+ * Used by kori (Fastify built-in Pino) and cb-proxy (Pino + pino-http).
12
+ *
13
+ * Edge cases covered:
14
+ * - NR not loaded or no active transaction: returns { traceId: '', spanId: '',
15
+ * traceparent: '' }. Consistent schema — log parsers always see the fields.
16
+ * - getTraceContext throws: catch returns the same empty-string object.
17
+ * Never crashes logging.
18
+ * - Lazy evaluation: the mixin is called at log-write time, not at Pino
19
+ * construction time, so trace context is always current.
20
+ * - Works with pino-pretty in development — fields appear in pretty output.
21
+ */
22
+ export declare function createPinoMixin(): () => Record<string, string>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Pino mixin for trace context enrichment.
4
+ *
5
+ * Why this file exists:
6
+ * Pino's `mixin` option is a function called on every log that returns extra
7
+ * fields to merge into the log entry. This mixin injects { traceId, spanId,
8
+ * traceparent } into every Pino log line so NR's stdout-based log forwarding
9
+ * can correlate logs with traces. Like the Winston format, it does NOT send
10
+ * logs anywhere — that's the destination's job.
11
+ *
12
+ * Used by kori (Fastify built-in Pino) and cb-proxy (Pino + pino-http).
13
+ *
14
+ * Edge cases covered:
15
+ * - NR not loaded or no active transaction: returns { traceId: '', spanId: '',
16
+ * traceparent: '' }. Consistent schema — log parsers always see the fields.
17
+ * - getTraceContext throws: catch returns the same empty-string object.
18
+ * Never crashes logging.
19
+ * - Lazy evaluation: the mixin is called at log-write time, not at Pino
20
+ * construction time, so trace context is always current.
21
+ * - Works with pino-pretty in development — fields appear in pretty output.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.createPinoMixin = createPinoMixin;
25
+ const trace_bridge_1 = require("../trace-bridge");
26
+ function createPinoMixin() {
27
+ return () => {
28
+ try {
29
+ const ctx = (0, trace_bridge_1.getTraceContext)();
30
+ return {
31
+ traceId: ctx.traceId,
32
+ spanId: ctx.spanId,
33
+ traceparent: ctx.traceparent,
34
+ };
35
+ }
36
+ catch {
37
+ return { traceId: '', spanId: '', traceparent: '' };
38
+ }
39
+ };
40
+ }