@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,28 @@
1
+ /**
2
+ * Winston format for trace context enrichment (metadata injection only).
3
+ *
4
+ * Why this file exists:
5
+ * NR correlates logs with traces when log lines contain `trace.id` / `span.id`.
6
+ * NR gets logs via stdout forwarding, so the trace context must be in the log
7
+ * output. This format injects { traceId, spanId, traceparent } into every
8
+ * Winston log entry's metadata. It does NOT send logs anywhere — that's the
9
+ * transport's job. It does NOT change the output format (JSON vs text is still
10
+ * the service's choice).
11
+ *
12
+ * Used by combee, gpt-intentions, and hsm-backend (all Winston).
13
+ *
14
+ * Edge cases covered:
15
+ * - NR not loaded: getTraceContext returns empty strings. Fields are still
16
+ * added (as empty strings) — consistent schema for log parsers.
17
+ * - No active NR transaction: same as above.
18
+ * - Combined with format.json(): traceId/spanId appear as top-level JSON keys.
19
+ * - Combined with format.printf(): fields available on info.traceId etc.
20
+ * - Placed first in format.combine() chain so all subsequent formatters have
21
+ * access to trace context.
22
+ * - getTraceContext is lazy (called at log-write time, not at format creation
23
+ * time) so the trace context is always current for the active transaction.
24
+ * - If getTraceContext throws for any reason, the log entry passes through
25
+ * unmodified — never crashes logging.
26
+ */
27
+ import type { WinstonFormat } from '../types';
28
+ export declare function createWinstonFormat(): WinstonFormat;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * Winston format for trace context enrichment (metadata injection only).
4
+ *
5
+ * Why this file exists:
6
+ * NR correlates logs with traces when log lines contain `trace.id` / `span.id`.
7
+ * NR gets logs via stdout forwarding, so the trace context must be in the log
8
+ * output. This format injects { traceId, spanId, traceparent } into every
9
+ * Winston log entry's metadata. It does NOT send logs anywhere — that's the
10
+ * transport's job. It does NOT change the output format (JSON vs text is still
11
+ * the service's choice).
12
+ *
13
+ * Used by combee, gpt-intentions, and hsm-backend (all Winston).
14
+ *
15
+ * Edge cases covered:
16
+ * - NR not loaded: getTraceContext returns empty strings. Fields are still
17
+ * added (as empty strings) — consistent schema for log parsers.
18
+ * - No active NR transaction: same as above.
19
+ * - Combined with format.json(): traceId/spanId appear as top-level JSON keys.
20
+ * - Combined with format.printf(): fields available on info.traceId etc.
21
+ * - Placed first in format.combine() chain so all subsequent formatters have
22
+ * access to trace context.
23
+ * - getTraceContext is lazy (called at log-write time, not at format creation
24
+ * time) so the trace context is always current for the active transaction.
25
+ * - If getTraceContext throws for any reason, the log entry passes through
26
+ * unmodified — never crashes logging.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.createWinstonFormat = createWinstonFormat;
30
+ const trace_bridge_1 = require("../trace-bridge");
31
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
32
+ function createWinstonFormat() {
33
+ return {
34
+ transform(info) {
35
+ try {
36
+ const ctx = (0, trace_bridge_1.getTraceContext)();
37
+ info.traceId = ctx.traceId;
38
+ info.spanId = ctx.spanId;
39
+ info.traceparent = ctx.traceparent;
40
+ }
41
+ catch {
42
+ /* never crash logging */
43
+ }
44
+ return info;
45
+ },
46
+ };
47
+ }
48
+ /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Winston transport that delivers every log entry to Foam via the OTLP logs
3
+ * pipeline (non-global LoggerProvider → BatchLogRecordProcessor → OTLPLogExporter).
4
+ *
5
+ * Why this file exists:
6
+ * NR gets logs via stdout forwarding (unchanged). Foam needs a separate
7
+ * delivery path. This transport converts each Winston log entry into an OTel
8
+ * log record with severity mapping and trace context, then emits it through
9
+ * our non-global LoggerProvider. No double-logging to the same destination —
10
+ * NR gets stdout, Foam gets OTLP.
11
+ *
12
+ * Used by combee, gpt-intentions, and hsm-backend.
13
+ *
14
+ * Edge cases covered:
15
+ * - handleExceptions: false, handleRejections: false — critical. combee
16
+ * creates 3-5 Winston loggers, each with existing transports that already
17
+ * listen for uncaught events. Adding more would trigger Node's
18
+ * MaxListenersExceededWarning. Process exceptions are captured separately
19
+ * by init()'s own handlers.
20
+ * - objectMode: true — Winston transports receive structured info objects,
21
+ * not string chunks.
22
+ * - Severity mapping: error→ERROR, warn→WARN, info/http→INFO, verbose/debug→DEBUG,
23
+ * silly→TRACE. Unknown levels default to INFO.
24
+ * - Unserializable attribute values (circular refs, symbols): silently skipped
25
+ * per-key — other attributes still flow.
26
+ * - Winston's internal Symbol(level) key is skipped in attribute iteration.
27
+ * - Transport callback always called — never blocks the Winston pipeline.
28
+ */
29
+ import type { LoggerProvider } from '@opentelemetry/sdk-logs';
30
+ import type { WinstonTransport } from '../types';
31
+ export declare function createWinstonTransport(loggerProvider: LoggerProvider, serviceName: string): WinstonTransport;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * Winston transport that delivers every log entry to Foam via the OTLP logs
4
+ * pipeline (non-global LoggerProvider → BatchLogRecordProcessor → OTLPLogExporter).
5
+ *
6
+ * Why this file exists:
7
+ * NR gets logs via stdout forwarding (unchanged). Foam needs a separate
8
+ * delivery path. This transport converts each Winston log entry into an OTel
9
+ * log record with severity mapping and trace context, then emits it through
10
+ * our non-global LoggerProvider. No double-logging to the same destination —
11
+ * NR gets stdout, Foam gets OTLP.
12
+ *
13
+ * Used by combee, gpt-intentions, and hsm-backend.
14
+ *
15
+ * Edge cases covered:
16
+ * - handleExceptions: false, handleRejections: false — critical. combee
17
+ * creates 3-5 Winston loggers, each with existing transports that already
18
+ * listen for uncaught events. Adding more would trigger Node's
19
+ * MaxListenersExceededWarning. Process exceptions are captured separately
20
+ * by init()'s own handlers.
21
+ * - objectMode: true — Winston transports receive structured info objects,
22
+ * not string chunks.
23
+ * - Severity mapping: error→ERROR, warn→WARN, info/http→INFO, verbose/debug→DEBUG,
24
+ * silly→TRACE. Unknown levels default to INFO.
25
+ * - Unserializable attribute values (circular refs, symbols): silently skipped
26
+ * per-key — other attributes still flow.
27
+ * - Winston's internal Symbol(level) key is skipped in attribute iteration.
28
+ * - Transport callback always called — never blocks the Winston pipeline.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.createWinstonTransport = createWinstonTransport;
32
+ const api_logs_1 = require("@opentelemetry/api-logs");
33
+ const node_stream_1 = require("node:stream");
34
+ const trace_bridge_1 = require("../trace-bridge");
35
+ const SEVERITY_MAP = {
36
+ error: api_logs_1.SeverityNumber.ERROR,
37
+ warn: api_logs_1.SeverityNumber.WARN,
38
+ info: api_logs_1.SeverityNumber.INFO,
39
+ http: api_logs_1.SeverityNumber.INFO,
40
+ verbose: api_logs_1.SeverityNumber.DEBUG,
41
+ debug: api_logs_1.SeverityNumber.DEBUG,
42
+ silly: api_logs_1.SeverityNumber.TRACE,
43
+ };
44
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
45
+ function createWinstonTransport(loggerProvider, serviceName) {
46
+ const otelLogger = loggerProvider.getLogger(serviceName);
47
+ const stream = new node_stream_1.Writable({
48
+ objectMode: true,
49
+ write(info, _encoding, callback) {
50
+ try {
51
+ const level = info.level ?? 'info';
52
+ const message = info.message ?? '';
53
+ const severity = SEVERITY_MAP[level] ?? api_logs_1.SeverityNumber.INFO;
54
+ const ctx = (0, trace_bridge_1.getTraceContext)();
55
+ const attributes = {};
56
+ if (ctx.traceId) {
57
+ attributes['trace.id'] = ctx.traceId;
58
+ }
59
+ if (ctx.spanId) {
60
+ attributes['span.id'] = ctx.spanId;
61
+ }
62
+ for (const [key, val] of Object.entries(info)) {
63
+ if (key !== 'level' && key !== 'message' && key !== 'timestamp' && key !== 'Symbol(level)') {
64
+ try {
65
+ attributes[key] = typeof val === 'string' ? val : JSON.stringify(val);
66
+ }
67
+ catch {
68
+ /* skip unserializable */
69
+ }
70
+ }
71
+ }
72
+ otelLogger.emit({
73
+ body: message,
74
+ severityNumber: severity,
75
+ severityText: level.toUpperCase(),
76
+ attributes,
77
+ });
78
+ }
79
+ catch {
80
+ /* never crash logging */
81
+ }
82
+ callback();
83
+ },
84
+ });
85
+ // Winston transport contract: the stream with transport metadata
86
+ const transport = stream;
87
+ transport.name = 'foam-otel';
88
+ transport.level = undefined; // Accept all levels
89
+ transport.handleExceptions = false;
90
+ transport.handleRejections = false;
91
+ return transport;
92
+ }
93
+ /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pre-built OTel metric instruments for queue processing, plus factories
3
+ * for service-specific custom metrics.
4
+ *
5
+ * Why this file exists:
6
+ * Provides three auto-recorded instruments that wrapSqsConsumer and
7
+ * wrapJobConsumer populate automatically:
8
+ * - messageProcessingDuration (histogram, ms): how long each message takes
9
+ * - messageProcessingErrors (counter): how many processing attempts fail
10
+ * - messagesProcessed (counter): total messages processed (success + error)
11
+ *
12
+ * Also provides customHistogram/customCounter/customGauge factories for
13
+ * service-specific domain metrics (LLM duration in gpt-intentions, webhook
14
+ * processing time in cb-proxy, automation duration in kori).
15
+ *
16
+ * Edge cases covered:
17
+ * - Custom instrument idempotency: calling customHistogram('llm.duration', ...)
18
+ * twice returns the same Histogram instance (cached by name). OTel SDKs
19
+ * already handle duplicate instrument creation, but caching avoids the
20
+ * overhead of repeated createHistogram calls in hot paths.
21
+ * - MeterProvider is non-global — our metrics never conflict with NR's
22
+ * internal metrics or any other OTel MeterProvider in the process.
23
+ * - PeriodicExportingMetricReader (configured in init.ts) exports every 60s
24
+ * by default. If no exporter is configured (non-production), no reader is
25
+ * attached and no timer runs.
26
+ */
27
+ import type { MeterProvider } from '@opentelemetry/sdk-metrics';
28
+ import type { BridgeMetrics } from '../types';
29
+ export declare function createBridgeMetrics(meterProvider: MeterProvider, serviceName: string): BridgeMetrics;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Pre-built OTel metric instruments for queue processing, plus factories
4
+ * for service-specific custom metrics.
5
+ *
6
+ * Why this file exists:
7
+ * Provides three auto-recorded instruments that wrapSqsConsumer and
8
+ * wrapJobConsumer populate automatically:
9
+ * - messageProcessingDuration (histogram, ms): how long each message takes
10
+ * - messageProcessingErrors (counter): how many processing attempts fail
11
+ * - messagesProcessed (counter): total messages processed (success + error)
12
+ *
13
+ * Also provides customHistogram/customCounter/customGauge factories for
14
+ * service-specific domain metrics (LLM duration in gpt-intentions, webhook
15
+ * processing time in cb-proxy, automation duration in kori).
16
+ *
17
+ * Edge cases covered:
18
+ * - Custom instrument idempotency: calling customHistogram('llm.duration', ...)
19
+ * twice returns the same Histogram instance (cached by name). OTel SDKs
20
+ * already handle duplicate instrument creation, but caching avoids the
21
+ * overhead of repeated createHistogram calls in hot paths.
22
+ * - MeterProvider is non-global — our metrics never conflict with NR's
23
+ * internal metrics or any other OTel MeterProvider in the process.
24
+ * - PeriodicExportingMetricReader (configured in init.ts) exports every 60s
25
+ * by default. If no exporter is configured (non-production), no reader is
26
+ * attached and no timer runs.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.createBridgeMetrics = createBridgeMetrics;
30
+ function createBridgeMetrics(meterProvider, serviceName) {
31
+ const meter = meterProvider.getMeter(serviceName);
32
+ const messageProcessingDuration = meter.createHistogram('messaging.process.duration', {
33
+ description: 'Time to process a queue message',
34
+ unit: 'ms',
35
+ });
36
+ const messageProcessingErrors = meter.createCounter('messaging.process.errors', {
37
+ description: 'Number of queue message processing errors',
38
+ });
39
+ const messagesProcessed = meter.createCounter('messaging.process.count', {
40
+ description: 'Number of queue messages processed',
41
+ });
42
+ const instrumentCache = new Map();
43
+ return {
44
+ messageProcessingDuration,
45
+ messageProcessingErrors,
46
+ messagesProcessed,
47
+ customHistogram(name, description, unit) {
48
+ const key = `histogram:${name}`;
49
+ const existing = instrumentCache.get(key);
50
+ if (existing) {
51
+ return existing;
52
+ }
53
+ const h = meter.createHistogram(name, { description, unit });
54
+ instrumentCache.set(key, h);
55
+ return h;
56
+ },
57
+ customCounter(name, description, unit) {
58
+ const key = `counter:${name}`;
59
+ const existing = instrumentCache.get(key);
60
+ if (existing) {
61
+ return existing;
62
+ }
63
+ const c = meter.createCounter(name, { description, unit });
64
+ instrumentCache.set(key, c);
65
+ return c;
66
+ },
67
+ customGauge(name, description, unit) {
68
+ const key = `gauge:${name}`;
69
+ const existing = instrumentCache.get(key);
70
+ if (existing) {
71
+ return existing;
72
+ }
73
+ const g = meter.createObservableGauge(name, { description, unit });
74
+ instrumentCache.set(key, g);
75
+ return g;
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * New Relic agent detection and lazy resolution.
3
+ *
4
+ * Why this file exists:
5
+ * The library must NEVER call `require('newrelic')` directly — doing so would
6
+ * start the NR agent in services that explicitly disable it (hsm-backend uses
7
+ * a conditional `NEW_RELIC_LOG_ENABLED` check). Instead, we detect NR via
8
+ * `require.cache` — if the service already loaded NR, we reuse that instance.
9
+ * If not, all NR calls silently no-op via the NOOP_NR stub.
10
+ *
11
+ * Resolution strategy:
12
+ * 1. Explicit: `init('svc', token, { newrelic: nrInstance })` — used by
13
+ * kori (ESM + pnpm, where require.cache detection is unreliable) and
14
+ * hsm-backend (conditional NR loading).
15
+ * 2. Module cache: `require.cache[require.resolve('newrelic')]` — works for
16
+ * CJS services (combee, gpt-intentions, cb-proxy) where NR is loaded at
17
+ * the top of the entry file before the bridge initializes.
18
+ * 3. Fallback: NOOP_NR stub that returns empty trace metadata and calls
19
+ * handler functions directly without creating background transactions.
20
+ *
21
+ * Edge cases covered:
22
+ * - `require.resolve('newrelic')` throws if newrelic is not installed at all.
23
+ * Caught silently — returns NOOP_NR.
24
+ * - NR's stub API (agent disabled via config) returns `{ traceId: '', spanId: '' }`
25
+ * from `getTraceMetadata()` — our code treats empty strings as "no trace,"
26
+ * so buildTraceparent() returns undefined. No crash.
27
+ * - `startBackgroundTransaction` in the stub just calls the handler directly
28
+ * and returns its result, preserving async/await flow.
29
+ * - Once resolved, the NR instance is cached so subsequent calls to `getNr()`
30
+ * don't re-probe the module cache.
31
+ */
32
+ import type { NewRelicAgent } from './types';
33
+ export declare function resolveNewRelic(explicit?: NewRelicAgent): NewRelicAgent;
34
+ export declare function getNr(): NewRelicAgent;
35
+ export declare function isNrLoaded(): boolean;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * New Relic agent detection and lazy resolution.
4
+ *
5
+ * Why this file exists:
6
+ * The library must NEVER call `require('newrelic')` directly — doing so would
7
+ * start the NR agent in services that explicitly disable it (hsm-backend uses
8
+ * a conditional `NEW_RELIC_LOG_ENABLED` check). Instead, we detect NR via
9
+ * `require.cache` — if the service already loaded NR, we reuse that instance.
10
+ * If not, all NR calls silently no-op via the NOOP_NR stub.
11
+ *
12
+ * Resolution strategy:
13
+ * 1. Explicit: `init('svc', token, { newrelic: nrInstance })` — used by
14
+ * kori (ESM + pnpm, where require.cache detection is unreliable) and
15
+ * hsm-backend (conditional NR loading).
16
+ * 2. Module cache: `require.cache[require.resolve('newrelic')]` — works for
17
+ * CJS services (combee, gpt-intentions, cb-proxy) where NR is loaded at
18
+ * the top of the entry file before the bridge initializes.
19
+ * 3. Fallback: NOOP_NR stub that returns empty trace metadata and calls
20
+ * handler functions directly without creating background transactions.
21
+ *
22
+ * Edge cases covered:
23
+ * - `require.resolve('newrelic')` throws if newrelic is not installed at all.
24
+ * Caught silently — returns NOOP_NR.
25
+ * - NR's stub API (agent disabled via config) returns `{ traceId: '', spanId: '' }`
26
+ * from `getTraceMetadata()` — our code treats empty strings as "no trace,"
27
+ * so buildTraceparent() returns undefined. No crash.
28
+ * - `startBackgroundTransaction` in the stub just calls the handler directly
29
+ * and returns its result, preserving async/await flow.
30
+ * - Once resolved, the NR instance is cached so subsequent calls to `getNr()`
31
+ * don't re-probe the module cache.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.resolveNewRelic = resolveNewRelic;
35
+ exports.getNr = getNr;
36
+ exports.isNrLoaded = isNrLoaded;
37
+ const NOOP_NR = {
38
+ getTransaction: () => null,
39
+ startBackgroundTransaction: (_name, _group, handler) => typeof handler === 'function' ? handler() : undefined,
40
+ getTraceMetadata: () => ({ traceId: '', spanId: '' }),
41
+ acceptDistributedTraceHeaders: () => undefined,
42
+ };
43
+ let cachedNr = null;
44
+ function resolveNewRelic(explicit) {
45
+ if (explicit) {
46
+ cachedNr = explicit;
47
+ return explicit;
48
+ }
49
+ if (cachedNr) {
50
+ return cachedNr;
51
+ }
52
+ try {
53
+ /* eslint-disable @typescript-eslint/no-require-imports */
54
+ const resolved = require.resolve('newrelic');
55
+ if (require.cache[resolved]) {
56
+ cachedNr = require('newrelic');
57
+ return cachedNr;
58
+ }
59
+ /* eslint-enable @typescript-eslint/no-require-imports */
60
+ }
61
+ catch {
62
+ // NR not available
63
+ }
64
+ return NOOP_NR;
65
+ }
66
+ function getNr() {
67
+ return cachedNr ?? NOOP_NR;
68
+ }
69
+ function isNrLoaded() {
70
+ return cachedNr !== null && cachedNr !== NOOP_NR;
71
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SNS message attribute injection for trace propagation.
3
+ *
4
+ * Why this file exists:
5
+ * combee publishes to the `cliengo-events` SNS topic with MessageAttributes
6
+ * (triggerType, category, companyId, channel). To propagate trace context
7
+ * across the SNS → SQS boundary, we need to add a `traceparent` attribute
8
+ * alongside the existing ones. This function spread-merges it in.
9
+ *
10
+ * Edge cases covered:
11
+ * - No active NR transaction (buildTraceparent returns undefined): the
12
+ * original attributes are returned unchanged — no traceparent key added.
13
+ * - Empty attrs {}: returns just { traceparent: ... } if NR is active,
14
+ * or {} if not. Never crashes.
15
+ * - Existing traceparent in attrs: overwritten with the current value
16
+ * (spread puts our key last).
17
+ */
18
+ import type { SnsMessageAttributeValue } from './types';
19
+ export declare function injectSnsAttributes(attrs: Record<string, SnsMessageAttributeValue>): Record<string, SnsMessageAttributeValue>;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * SNS message attribute injection for trace propagation.
4
+ *
5
+ * Why this file exists:
6
+ * combee publishes to the `cliengo-events` SNS topic with MessageAttributes
7
+ * (triggerType, category, companyId, channel). To propagate trace context
8
+ * across the SNS → SQS boundary, we need to add a `traceparent` attribute
9
+ * alongside the existing ones. This function spread-merges it in.
10
+ *
11
+ * Edge cases covered:
12
+ * - No active NR transaction (buildTraceparent returns undefined): the
13
+ * original attributes are returned unchanged — no traceparent key added.
14
+ * - Empty attrs {}: returns just { traceparent: ... } if NR is active,
15
+ * or {} if not. Never crashes.
16
+ * - Existing traceparent in attrs: overwritten with the current value
17
+ * (spread puts our key last).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.injectSnsAttributes = injectSnsAttributes;
21
+ const trace_bridge_1 = require("./trace-bridge");
22
+ function injectSnsAttributes(attrs) {
23
+ const tp = (0, trace_bridge_1.buildTraceparent)();
24
+ if (!tp) {
25
+ return attrs;
26
+ }
27
+ return {
28
+ ...attrs,
29
+ traceparent: { DataType: 'String', StringValue: tp },
30
+ };
31
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * SQS consumer wrapper — creates OTel spans and NR background transactions
3
+ * for every message processed from an SQS queue.
4
+ *
5
+ * Why this file exists:
6
+ * SQS consumers (combee whatsapp-status, kori automation-events, hsm-backend
7
+ * wa-meta) poll queues in a loop with no NR transaction context. This wrapper
8
+ * creates both an NR background transaction (so NR sees the work) and an OTel
9
+ * span on our non-global provider (so Foam sees the work). It also records
10
+ * metrics (duration, error count, processed count) automatically.
11
+ *
12
+ * Dual delivery mode extraction:
13
+ * SNS→SQS subscriptions have two modes that put traceparent in different places:
14
+ * - RawMessageDelivery=true (kori): traceparent is in the SQS-level
15
+ * `msg.MessageAttributes.traceparent.StringValue`
16
+ * - RawMessageDelivery=false (combee, hsm-backend): traceparent is inside the
17
+ * SNS envelope in `JSON.parse(msg.Body).MessageAttributes.traceparent.Value`
18
+ * (note: `Value` not `StringValue` — different key format in SNS envelopes)
19
+ * extractTraceparentFromMessage tries both locations.
20
+ *
21
+ * Edge cases covered:
22
+ * - No traceparent anywhere: consumer creates a fresh trace (new traceId).
23
+ * This is expected in Phase 1 when producers haven't been updated yet.
24
+ * - Body is not valid JSON (non-SNS message): JSON.parse catch silently
25
+ * falls through to return empty string.
26
+ * - fn() throws: exception is recorded on the span (exception.type, message,
27
+ * stacktrace), error metrics incremented, then error is re-thrown so the
28
+ * service's own retry/DLQ logic is unaffected.
29
+ * - NR not loaded: execute() runs without startBackgroundTransaction — OTel
30
+ * span still created, metrics still recorded.
31
+ * - metrics is undefined (inert bridge): all metrics?.xxx calls are no-ops.
32
+ */
33
+ import { type Tracer } from '@opentelemetry/api';
34
+ import type { BridgeMetrics, SqsMessage } from './types';
35
+ export declare function wrapSqsConsumer(tracer: Tracer, spanName: string, msg: SqsMessage, fn: () => Promise<void>, metrics?: BridgeMetrics): Promise<void>;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ /**
3
+ * SQS consumer wrapper — creates OTel spans and NR background transactions
4
+ * for every message processed from an SQS queue.
5
+ *
6
+ * Why this file exists:
7
+ * SQS consumers (combee whatsapp-status, kori automation-events, hsm-backend
8
+ * wa-meta) poll queues in a loop with no NR transaction context. This wrapper
9
+ * creates both an NR background transaction (so NR sees the work) and an OTel
10
+ * span on our non-global provider (so Foam sees the work). It also records
11
+ * metrics (duration, error count, processed count) automatically.
12
+ *
13
+ * Dual delivery mode extraction:
14
+ * SNS→SQS subscriptions have two modes that put traceparent in different places:
15
+ * - RawMessageDelivery=true (kori): traceparent is in the SQS-level
16
+ * `msg.MessageAttributes.traceparent.StringValue`
17
+ * - RawMessageDelivery=false (combee, hsm-backend): traceparent is inside the
18
+ * SNS envelope in `JSON.parse(msg.Body).MessageAttributes.traceparent.Value`
19
+ * (note: `Value` not `StringValue` — different key format in SNS envelopes)
20
+ * extractTraceparentFromMessage tries both locations.
21
+ *
22
+ * Edge cases covered:
23
+ * - No traceparent anywhere: consumer creates a fresh trace (new traceId).
24
+ * This is expected in Phase 1 when producers haven't been updated yet.
25
+ * - Body is not valid JSON (non-SNS message): JSON.parse catch silently
26
+ * falls through to return empty string.
27
+ * - fn() throws: exception is recorded on the span (exception.type, message,
28
+ * stacktrace), error metrics incremented, then error is re-thrown so the
29
+ * service's own retry/DLQ logic is unaffected.
30
+ * - NR not loaded: execute() runs without startBackgroundTransaction — OTel
31
+ * span still created, metrics still recorded.
32
+ * - metrics is undefined (inert bridge): all metrics?.xxx calls are no-ops.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.wrapSqsConsumer = wrapSqsConsumer;
36
+ const api_1 = require("@opentelemetry/api");
37
+ const nr_1 = require("./nr");
38
+ const trace_bridge_1 = require("./trace-bridge");
39
+ function extractTraceparentFromMessage(msg) {
40
+ // Raw delivery — traceparent in SQS-level MessageAttributes
41
+ const raw = msg.MessageAttributes?.traceparent?.StringValue;
42
+ if (raw) {
43
+ return raw;
44
+ }
45
+ // Non-raw (SNS-wrapped) — traceparent inside the SNS envelope in Body
46
+ if (msg.Body) {
47
+ try {
48
+ const body = JSON.parse(msg.Body);
49
+ const envelope = body.MessageAttributes?.traceparent?.Value;
50
+ if (envelope) {
51
+ return envelope;
52
+ }
53
+ }
54
+ catch {
55
+ // Body is not JSON or doesn't have SNS envelope — no traceparent
56
+ }
57
+ }
58
+ return '';
59
+ }
60
+ async function wrapSqsConsumer(tracer, spanName, msg, fn, metrics) {
61
+ const nr = (0, nr_1.getNr)();
62
+ const traceparent = extractTraceparentFromMessage(msg);
63
+ const parentCtx = traceparent ? (0, trace_bridge_1.extractParentContext)(traceparent) : undefined;
64
+ const headers = {};
65
+ if (traceparent) {
66
+ headers.traceparent = traceparent;
67
+ }
68
+ const execute = async () => {
69
+ nr.acceptDistributedTraceHeaders?.('Queue', headers);
70
+ const span = tracer.startSpan(spanName, {
71
+ attributes: {
72
+ 'messaging.system': 'aws.sqs',
73
+ 'messaging.operation': 'process',
74
+ 'messaging.message.id': msg.MessageId ?? '',
75
+ },
76
+ }, parentCtx);
77
+ const start = Date.now();
78
+ try {
79
+ await fn();
80
+ span.setStatus({ code: api_1.SpanStatusCode.OK });
81
+ metrics?.messagesProcessed.add(1, { queue: spanName, status: 'success' });
82
+ }
83
+ catch (err) {
84
+ const error = err instanceof Error ? err : new Error(String(err));
85
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
86
+ span.recordException({
87
+ name: error.constructor.name,
88
+ message: error.message,
89
+ stack: error.stack,
90
+ });
91
+ metrics?.messageProcessingErrors.add(1, {
92
+ queue: spanName,
93
+ 'error.type': error.constructor.name,
94
+ });
95
+ metrics?.messagesProcessed.add(1, { queue: spanName, status: 'error' });
96
+ throw err;
97
+ }
98
+ finally {
99
+ const duration = Date.now() - start;
100
+ metrics?.messageProcessingDuration.record(duration, { queue: spanName });
101
+ span.end();
102
+ }
103
+ };
104
+ if (nr.startBackgroundTransaction) {
105
+ await nr.startBackgroundTransaction(spanName, 'QueueConsumer', execute);
106
+ }
107
+ else {
108
+ await execute();
109
+ }
110
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * W3C trace context bridge between New Relic and OpenTelemetry.
3
+ *
4
+ * Why this file exists:
5
+ * NR owns the active trace (it creates transactions via http monkey-patching),
6
+ * but our OTel spans need to share the same traceId so Foam and NR traces
7
+ * correlate. This module reads NR's trace metadata and converts it to/from
8
+ * W3C traceparent format (`00-{traceId}-{spanId}-{flags}`).
9
+ *
10
+ * Functions:
11
+ * - buildTraceparent(): reads NR's active traceId/spanId, returns a W3C
12
+ * traceparent string. Used by producers to inject into SNS attributes or
13
+ * BullMQ/Bull job data. Returns undefined when NR is not loaded or no
14
+ * active transaction exists.
15
+ * - extractParentContext(): parses a traceparent string into an OTel Context
16
+ * with a remote SpanContext. Used by consumers to link their OTel spans
17
+ * back to the producer's trace. Returns ROOT_CONTEXT on malformed input.
18
+ * - getTraceContext(): returns { traceId, spanId, traceparent } for log
19
+ * enrichment. Called lazily at log-write time (not at format creation time)
20
+ * so the trace context is always current.
21
+ *
22
+ * Edge cases covered:
23
+ * - NR not loaded: getTraceMetadata() returns undefined — we default to
24
+ * empty strings, buildTraceparent returns undefined, getTraceContext
25
+ * returns empty strings. No crash.
26
+ * - NR loaded but no active transaction: traceId/spanId are empty strings.
27
+ * buildTraceparent returns undefined. Consumers create a fresh trace.
28
+ * - Malformed traceparent (wrong number of parts, wrong hex lengths):
29
+ * extractParentContext returns ROOT_CONTEXT — consumer creates a fresh
30
+ * trace instead of crashing.
31
+ * - Flags field always set to 01 (sampled) on buildTraceparent because we
32
+ * want all async boundary crossings traced.
33
+ */
34
+ import { type Context } from '@opentelemetry/api';
35
+ import type { TraceContext } from './types';
36
+ export declare function buildTraceparent(): string | undefined;
37
+ export declare function extractParentContext(traceparent: string): Context;
38
+ export declare function getTraceContext(): TraceContext;
39
+ export declare function getActiveContext(): Context;