@foam-ai/node-cliengo 0.1.0-alpha.14 → 0.1.0-alpha.16

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.
@@ -8,8 +8,7 @@
8
8
  * through individual files.
9
9
  *
10
10
  * Edge cases covered:
11
- * - FOAM_OTEL_ENDPOINT is hardcoded (not read from env) to prevent
12
- * misconfiguration; services override via `options.endpoint` only.
11
+ * - FOAM_OTEL_ENDPOINT is hardcoded not configurable via env or options.
13
12
  * - REDACTED_KEYS uses lowercase comparison so "Authorization", "PASSWORD",
14
13
  * "Token" all match. Includes both "creditcard" and "credit_card" variants.
15
14
  * - HEALTH_ROUTES covers the four most common patterns across all 5 Cliengo
@@ -9,8 +9,7 @@
9
9
  * through individual files.
10
10
  *
11
11
  * Edge cases covered:
12
- * - FOAM_OTEL_ENDPOINT is hardcoded (not read from env) to prevent
13
- * misconfiguration; services override via `options.endpoint` only.
12
+ * - FOAM_OTEL_ENDPOINT is hardcoded not configurable via env or options.
14
13
  * - REDACTED_KEYS uses lowercase comparison so "Authorization", "PASSWORD",
15
14
  * "Token" all match. Includes both "creditcard" and "credit_card" variants.
16
15
  * - HEALTH_ROUTES covers the four most common patterns across all 5 Cliengo
@@ -31,6 +30,10 @@ exports.REDACTED_KEYS = new Set([
31
30
  'secret',
32
31
  'creditcard',
33
32
  'credit_card',
33
+ 'api_key',
34
+ 'apikey',
35
+ 'access_token',
36
+ 'accesstoken',
34
37
  ]);
35
38
  exports.HEALTH_ROUTES = new Set(['/health', '/healthz', '/ready', '/status']);
36
39
  exports.REQUEST_CONTEXT_FIELDS = [
@@ -44,6 +44,7 @@ const trace_bridge_1 = require("../trace-bridge");
44
44
  const request_context_1 = require("./request-context");
45
45
  const SPAN_KEY = Symbol.for('foam.shadow.span');
46
46
  const TRACE_CTX_KEY = Symbol.for('foam.trace.context');
47
+ const ERROR_RECORDED_KEY = Symbol.for('foam.shadow.errorRecorded');
47
48
  /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
48
49
  function createExpressMiddleware(tracer) {
49
50
  let errorHandlerRegistered = false;
@@ -94,7 +95,10 @@ function createExpressMiddleware(tracer) {
94
95
  span.setAttribute('http.route', route);
95
96
  span.setAttribute('http.response_content_length', String(contentLength));
96
97
  span.setAttribute('http.duration_ms', Date.now() - startTime);
97
- if (statusCode >= 500) {
98
+ if (req[ERROR_RECORDED_KEY]) {
99
+ // Error handler already set status — don't overwrite
100
+ }
101
+ else if (statusCode >= 500) {
98
102
  span.setStatus({ code: api_1.SpanStatusCode.ERROR });
99
103
  if (!errorHandlerRegistered && !warnedMissingErrorHandler) {
100
104
  warnedMissingErrorHandler = true;
@@ -171,6 +175,7 @@ function createExpressErrorHandler(middlewareRef) {
171
175
  stack: error.stack,
172
176
  });
173
177
  span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
178
+ req[ERROR_RECORDED_KEY] = true;
174
179
  }
175
180
  }
176
181
  catch {
@@ -5,7 +5,8 @@
5
5
  * Why this file exists:
6
6
  * Same rationale as the Express middleware (see express.ts). Fastify's hook
7
7
  * system is different — we use three hooks instead of middleware + error handler:
8
- * - onRequest: creates the shadow span, reads NR's traceId
8
+ * - onRequest: creates the shadow span, reads NR's traceId, sets the span as
9
+ * the active OTel context so route handlers inherit it via AsyncLocalStorage
9
10
  * - onError: records exception details (fires BEFORE errorHandler, so we see
10
11
  * every error including ones mapped to non-500 responses like AppError.badRequest)
11
12
  * - onResponse: records status/route/duration, captures request context, ends span
@@ -15,13 +16,13 @@
15
16
  * Edge cases covered:
16
17
  * - Fastify's onError fires for ALL errors including 4xx AppError variants.
17
18
  * 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).
19
+ * even for 400s. Once onError sets ERROR status, onResponse never overwrites it.
20
20
  * - request.routeOptions?.url may not exist in older Fastify versions — falls
21
21
  * back to request.url.
22
22
  * - Health routes skipped for request.context capture (same as Express).
23
23
  * - All hooks wrapped in try/catch — never crashes the request pipeline.
24
- * - done() always called even if our code throws — Fastify hook contract.
24
+ * - onError/onResponse use async hooks (Fastify 5 forward-compat).
25
+ * onRequest uses callback style intentionally for context.with() propagation.
25
26
  */
26
27
  import { type Tracer } from '@opentelemetry/api';
27
28
  import type { FastifyPluginAsync } from '../types';
@@ -6,7 +6,8 @@
6
6
  * Why this file exists:
7
7
  * Same rationale as the Express middleware (see express.ts). Fastify's hook
8
8
  * system is different — we use three hooks instead of middleware + error handler:
9
- * - onRequest: creates the shadow span, reads NR's traceId
9
+ * - onRequest: creates the shadow span, reads NR's traceId, sets the span as
10
+ * the active OTel context so route handlers inherit it via AsyncLocalStorage
10
11
  * - onError: records exception details (fires BEFORE errorHandler, so we see
11
12
  * every error including ones mapped to non-500 responses like AppError.badRequest)
12
13
  * - onResponse: records status/route/duration, captures request context, ends span
@@ -16,13 +17,13 @@
16
17
  * Edge cases covered:
17
18
  * - Fastify's onError fires for ALL errors including 4xx AppError variants.
18
19
  * 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).
20
+ * even for 400s. Once onError sets ERROR status, onResponse never overwrites it.
21
21
  * - request.routeOptions?.url may not exist in older Fastify versions — falls
22
22
  * back to request.url.
23
23
  * - Health routes skipped for request.context capture (same as Express).
24
24
  * - All hooks wrapped in try/catch — never crashes the request pipeline.
25
- * - done() always called even if our code throws — Fastify hook contract.
25
+ * - onError/onResponse use async hooks (Fastify 5 forward-compat).
26
+ * onRequest uses callback style intentionally for context.with() propagation.
26
27
  */
27
28
  Object.defineProperty(exports, "__esModule", { value: true });
28
29
  exports.getRequestTraceContext = getRequestTraceContext;
@@ -33,6 +34,7 @@ const request_context_1 = require("./request-context");
33
34
  const SPAN_KEY = Symbol.for('foam.shadow.span');
34
35
  const START_KEY = Symbol.for('foam.shadow.start');
35
36
  const TRACE_CTX_KEY = Symbol.for('foam.trace.context');
37
+ const ERROR_RECORDED_KEY = Symbol.for('foam.shadow.errorRecorded');
36
38
  /**
37
39
  * Reads the trace context that was captured on the request at hook time.
38
40
  * Use this in Fastify's logger mixin or serializers to get trace IDs even
@@ -54,6 +56,10 @@ function getRequestTraceContext(request) {
54
56
  /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
55
57
  function createFastifyPlugin(tracer) {
56
58
  return async (fastify) => {
59
+ // onRequest uses callback style intentionally — otelContext.with(ctx, done)
60
+ // propagates the shadow span as the active OTel context via AsyncLocalStorage
61
+ // to all downstream hooks and the route handler. Async hooks would lose the
62
+ // context after the awaited promise resolves.
57
63
  fastify.addHook('onRequest', (request, _reply, done) => {
58
64
  try {
59
65
  const method = request.method ?? 'UNKNOWN';
@@ -82,25 +88,16 @@ function createFastifyPlugin(tracer) {
82
88
  spanId: sc.spanId,
83
89
  traceparent: `00-${sc.traceId}-${sc.spanId}-01`,
84
90
  };
91
+ const activeCtx = api_1.trace.setSpan(parentCtx ?? api_1.ROOT_CONTEXT, span);
92
+ api_1.context.with(activeCtx, () => done());
93
+ return;
85
94
  }
86
95
  catch {
87
96
  /* never crash */
88
97
  }
89
98
  done();
90
99
  });
91
- // Set the shadow span as the active context for route handlers so
92
- // buildTraceparent() returns the same traceId — even without NR.
93
- fastify.addHook('preHandler', (request, _reply, done) => {
94
- const span = request[SPAN_KEY];
95
- if (span) {
96
- const activeCtx = api_1.trace.setSpan(api_1.ROOT_CONTEXT, span);
97
- api_1.context.with(activeCtx, () => done());
98
- }
99
- else {
100
- done();
101
- }
102
- });
103
- fastify.addHook('onError', (request, _reply, error, done) => {
100
+ fastify.addHook('onError', async (request, _reply, error) => {
104
101
  try {
105
102
  const span = request[SPAN_KEY];
106
103
  if (span) {
@@ -110,14 +107,14 @@ function createFastifyPlugin(tracer) {
110
107
  stack: error.stack,
111
108
  });
112
109
  span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: error.message });
110
+ request[ERROR_RECORDED_KEY] = true;
113
111
  }
114
112
  }
115
113
  catch {
116
114
  /* never crash */
117
115
  }
118
- done();
119
116
  });
120
- fastify.addHook('onResponse', (request, reply, done) => {
117
+ fastify.addHook('onResponse', async (request, reply) => {
121
118
  try {
122
119
  const span = request[SPAN_KEY];
123
120
  if (span) {
@@ -127,8 +124,13 @@ function createFastifyPlugin(tracer) {
127
124
  span.setAttribute('http.status_code', statusCode);
128
125
  span.setAttribute('http.route', route);
129
126
  span.setAttribute('http.duration_ms', Date.now() - start);
130
- if (statusCode < 500) {
131
- span.setStatus({ code: api_1.SpanStatusCode.OK });
127
+ if (!request[ERROR_RECORDED_KEY]) {
128
+ if (statusCode >= 500) {
129
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
130
+ }
131
+ else {
132
+ span.setStatus({ code: api_1.SpanStatusCode.OK });
133
+ }
132
134
  }
133
135
  if (!(0, request_context_1.isHealthRoute)(route) && !(0, request_context_1.isHealthRoute)(request.url ?? '/')) {
134
136
  (0, request_context_1.captureRequestContext)(span, request);
@@ -139,7 +141,6 @@ function createFastifyPlugin(tracer) {
139
141
  catch {
140
142
  /* never crash */
141
143
  }
142
- done();
143
144
  });
144
145
  };
145
146
  }
@@ -11,7 +11,9 @@
11
11
  *
12
12
  * The individual function exports exist for tree-shaking and for cases where
13
13
  * a service needs to call e.g. injectJobData() from a file that doesn't have
14
- * access to the foam instance.
14
+ * access to the foam instance. createFastifyPlugin is NOT exported standalone
15
+ * because the raw function takes a tracer argument — use foam.createFastifyPlugin()
16
+ * instead.
15
17
  */
16
18
  export { init, getFoam } from './init';
17
19
  export { buildTraceparent, extractParentContext, getTraceContext } from './trace-bridge';
@@ -19,8 +21,9 @@ export { injectSnsAttributes } from './sns';
19
21
  export { injectJobData, wrapJobConsumer } from './job';
20
22
  export { wrapSqsConsumer } from './sqs';
21
23
  export { createExpressMiddleware, createExpressErrorHandler, getRequestTraceContext, createPinoHttpOptions } from './http/express';
22
- export { createFastifyPlugin, getRequestTraceContext as getFastifyRequestTraceContext } from './http/fastify';
24
+ export { getRequestTraceContext as getFastifyRequestTraceContext } from './http/fastify';
23
25
  export { createWinstonFormat } from './logs/winston-format';
24
26
  export { createPinoMixin } from './logs/pino-mixin';
27
+ export { loadNewRelic } from './nr';
25
28
  export { FOAM_OTEL_ENDPOINT } from './constants';
26
29
  export type { FoamMetrics, Counter, ExpressErrorHandler, ExpressRequestHandler, FastifyPluginAsync, Histogram, InitOptions, NewRelicAgent, ObservableGauge, FoamInstance, SnsMessageAttributeValue, SqsMessage, TraceContext, WinstonFormat, WinstonLogger, WinstonTransport, } from './types';
@@ -12,10 +12,12 @@
12
12
  *
13
13
  * The individual function exports exist for tree-shaking and for cases where
14
14
  * a service needs to call e.g. injectJobData() from a file that doesn't have
15
- * access to the foam instance.
15
+ * access to the foam instance. createFastifyPlugin is NOT exported standalone
16
+ * because the raw function takes a tracer argument — use foam.createFastifyPlugin()
17
+ * instead.
16
18
  */
17
19
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.FOAM_OTEL_ENDPOINT = exports.createPinoMixin = exports.createWinstonFormat = exports.getFastifyRequestTraceContext = exports.createFastifyPlugin = exports.createPinoHttpOptions = exports.getRequestTraceContext = exports.createExpressErrorHandler = exports.createExpressMiddleware = exports.wrapSqsConsumer = exports.wrapJobConsumer = exports.injectJobData = exports.injectSnsAttributes = exports.getTraceContext = exports.extractParentContext = exports.buildTraceparent = exports.getFoam = exports.init = void 0;
20
+ exports.FOAM_OTEL_ENDPOINT = exports.loadNewRelic = exports.createPinoMixin = exports.createWinstonFormat = exports.getFastifyRequestTraceContext = exports.createPinoHttpOptions = exports.getRequestTraceContext = exports.createExpressErrorHandler = exports.createExpressMiddleware = exports.wrapSqsConsumer = exports.wrapJobConsumer = exports.injectJobData = exports.injectSnsAttributes = exports.getTraceContext = exports.extractParentContext = exports.buildTraceparent = exports.getFoam = exports.init = void 0;
19
21
  var init_1 = require("./init");
20
22
  Object.defineProperty(exports, "init", { enumerable: true, get: function () { return init_1.init; } });
21
23
  Object.defineProperty(exports, "getFoam", { enumerable: true, get: function () { return init_1.getFoam; } });
@@ -36,11 +38,12 @@ Object.defineProperty(exports, "createExpressErrorHandler", { enumerable: true,
36
38
  Object.defineProperty(exports, "getRequestTraceContext", { enumerable: true, get: function () { return express_1.getRequestTraceContext; } });
37
39
  Object.defineProperty(exports, "createPinoHttpOptions", { enumerable: true, get: function () { return express_1.createPinoHttpOptions; } });
38
40
  var fastify_1 = require("./http/fastify");
39
- Object.defineProperty(exports, "createFastifyPlugin", { enumerable: true, get: function () { return fastify_1.createFastifyPlugin; } });
40
41
  Object.defineProperty(exports, "getFastifyRequestTraceContext", { enumerable: true, get: function () { return fastify_1.getRequestTraceContext; } });
41
42
  var winston_format_1 = require("./logs/winston-format");
42
43
  Object.defineProperty(exports, "createWinstonFormat", { enumerable: true, get: function () { return winston_format_1.createWinstonFormat; } });
43
44
  var pino_mixin_1 = require("./logs/pino-mixin");
44
45
  Object.defineProperty(exports, "createPinoMixin", { enumerable: true, get: function () { return pino_mixin_1.createPinoMixin; } });
46
+ var nr_1 = require("./nr");
47
+ Object.defineProperty(exports, "loadNewRelic", { enumerable: true, get: function () { return nr_1.loadNewRelic; } });
45
48
  var constants_1 = require("./constants");
46
49
  Object.defineProperty(exports, "FOAM_OTEL_ENDPOINT", { enumerable: true, get: function () { return constants_1.FOAM_OTEL_ENDPOINT; } });
@@ -75,6 +75,7 @@ const sns_1 = require("./sns");
75
75
  const sqs_1 = require("./sqs");
76
76
  const trace_bridge_1 = require("./trace-bridge");
77
77
  const instances = new Map();
78
+ let _processHandlersRegistered = false;
78
79
  function createInertInstance(serviceName) {
79
80
  const resource = (0, resources_1.resourceFromAttributes)({ 'service.name': serviceName });
80
81
  const traceProvider = new sdk_trace_base_1.BasicTracerProvider({ resource });
@@ -86,7 +87,6 @@ function createInertInstance(serviceName) {
86
87
  return {
87
88
  tracer,
88
89
  meter,
89
- logger: loggerProvider,
90
90
  traceProvider,
91
91
  meterProvider,
92
92
  loggerProvider,
@@ -104,16 +104,14 @@ function createInertInstance(serviceName) {
104
104
  createPinoDestination: () => new node_stream_1.Writable({
105
105
  write: (_c, _e, cb) => cb(),
106
106
  }),
107
- createPinoLogger: (opts = {}) => {
108
- const pino = require('pino');
109
- return pino({ ...opts });
110
- },
111
- createPinoHttpConfig: () => ({}),
107
+ createPinoLogger: () => console,
108
+ createPinoHttpConfig: (opts) => (opts?.logger ? { logger: opts.logger } : {}),
109
+ createFastifyLoggerConfig: (opts) => ({ level: opts?.level ?? 'info' }),
112
110
  buildTraceparent: trace_bridge_1.buildTraceparent,
113
111
  injectSnsAttributes: sns_1.injectSnsAttributes,
114
112
  injectJobData: job_1.injectJobData,
115
- wrapSqsConsumer: async (_t, _s, _m, fn) => fn(),
116
- wrapJobConsumer: async (_t, _s, _j, fn) => fn(),
113
+ wrapSqsConsumer: async (_s, _m, fn) => fn(),
114
+ wrapJobConsumer: async (_s, _j, fn) => fn(),
117
115
  extractParentContext: () => {
118
116
  const { ROOT_CONTEXT } = require('@opentelemetry/api');
119
117
  return ROOT_CONTEXT;
@@ -227,46 +225,54 @@ function init(serviceName, options = {}) {
227
225
  process.on('SIGTERM', () => void handler());
228
226
  process.on('SIGINT', () => void handler());
229
227
  }
230
- // Process-level exception capture — recorded as span exceptions so they
231
- // land in otel_traces (not otel_logs). Each creates a short-lived span,
232
- // records the exception, ends it, then force-flushes the trace provider.
233
- process.on('uncaughtException', (err) => {
234
- try {
235
- const span = tracer.startSpan('uncaughtException', {
236
- attributes: { 'process.event': 'uncaughtException' },
237
- });
238
- span.recordException({
239
- name: err.constructor.name,
240
- message: err.message,
241
- stack: err.stack,
242
- });
243
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
244
- span.end();
245
- void traceProvider.forceFlush().then(() => shutdown());
246
- }
247
- catch {
248
- void shutdown();
249
- }
250
- });
251
- process.on('unhandledRejection', (reason) => {
252
- try {
253
- const err = reason instanceof Error ? reason : new Error(String(reason));
254
- const span = tracer.startSpan('unhandledRejection', {
255
- attributes: { 'process.event': 'unhandledRejection' },
256
- });
257
- span.recordException({
258
- name: err.constructor.name,
259
- message: err.message,
260
- stack: err.stack,
261
- });
262
- span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
263
- span.end();
264
- void traceProvider.forceFlush();
265
- }
266
- catch {
267
- /* best effort */
268
- }
269
- });
228
+ else {
229
+ process.once('beforeExit', () => {
230
+ if (!_isShutdown) {
231
+ // eslint-disable-next-line no-console
232
+ console.warn('[foam] autoShutdown is false but shutdown() was never called — OTel data may be lost');
233
+ }
234
+ });
235
+ }
236
+ if (!_processHandlersRegistered) {
237
+ _processHandlersRegistered = true;
238
+ process.on('uncaughtException', (err) => {
239
+ try {
240
+ const span = tracer.startSpan('uncaughtException', {
241
+ attributes: { 'process.event': 'uncaughtException' },
242
+ });
243
+ span.recordException({
244
+ name: err.constructor.name,
245
+ message: err.message,
246
+ stack: err.stack,
247
+ });
248
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
249
+ span.end();
250
+ void traceProvider.forceFlush().then(() => shutdown());
251
+ }
252
+ catch {
253
+ void shutdown();
254
+ }
255
+ });
256
+ process.on('unhandledRejection', (reason) => {
257
+ try {
258
+ const err = reason instanceof Error ? reason : new Error(String(reason));
259
+ const span = tracer.startSpan('unhandledRejection', {
260
+ attributes: { 'process.event': 'unhandledRejection' },
261
+ });
262
+ span.recordException({
263
+ name: err.constructor.name,
264
+ message: err.message,
265
+ stack: err.stack,
266
+ });
267
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: err.message });
268
+ span.end();
269
+ void traceProvider.forceFlush();
270
+ }
271
+ catch {
272
+ /* best effort */
273
+ }
274
+ });
275
+ }
270
276
  // Winston auto-wiring
271
277
  if (options.winston) {
272
278
  const winstonLogger = options.winston;
@@ -331,7 +337,6 @@ function init(serviceName, options = {}) {
331
337
  const foam = {
332
338
  tracer,
333
339
  meter,
334
- logger: loggerProvider,
335
340
  traceProvider,
336
341
  meterProvider,
337
342
  loggerProvider,
@@ -349,20 +354,35 @@ function init(serviceName, options = {}) {
349
354
  const pino = require('pino');
350
355
  const mixin = (0, pino_mixin_1.createPinoMixin)();
351
356
  const dest = (0, pino_destination_1.createPinoDestination)(loggerProvider, serviceName);
352
- return pino({ ...opts, mixin }, pino.multistream([
357
+ const { streams: userStreams, ...pinoOpts } = opts;
358
+ const outputStreams = userStreams ?? [
353
359
  { stream: process.stdout },
354
- { stream: dest },
355
- ]));
360
+ ];
361
+ return pino({ ...pinoOpts, mixin }, pino.multistream([...outputStreams, { stream: dest }]));
356
362
  },
357
- createPinoHttpConfig: () => {
363
+ createPinoHttpConfig: (opts) => {
358
364
  const pinoHttpOpts = (0, express_1.createPinoHttpOptions)();
359
- return { ...pinoHttpOpts };
365
+ return { ...pinoHttpOpts, ...(opts?.logger && { logger: opts.logger }) };
366
+ },
367
+ createFastifyLoggerConfig: (opts = {}) => {
368
+ const pino = require('pino');
369
+ const mixin = (0, pino_mixin_1.createPinoMixin)();
370
+ const dest = (0, pino_destination_1.createPinoDestination)(loggerProvider, serviceName);
371
+ const { streams: userStreams, ...pinoOpts } = opts;
372
+ const outputStreams = userStreams ?? [
373
+ { stream: process.stdout },
374
+ ];
375
+ return {
376
+ ...pinoOpts,
377
+ mixin,
378
+ stream: pino.multistream([...outputStreams, { stream: dest }]),
379
+ };
360
380
  },
361
381
  buildTraceparent: trace_bridge_1.buildTraceparent,
362
382
  injectSnsAttributes: sns_1.injectSnsAttributes,
363
383
  injectJobData: job_1.injectJobData,
364
- wrapSqsConsumer: (t, s, m, fn) => (0, sqs_1.wrapSqsConsumer)(t, s, m, fn, metrics),
365
- wrapJobConsumer: (t, s, j, fn) => (0, job_1.wrapJobConsumer)(t, s, j, fn, metrics),
384
+ wrapSqsConsumer: (s, m, fn) => (0, sqs_1.wrapSqsConsumer)(tracer, s, m, fn, metrics),
385
+ wrapJobConsumer: (s, j, fn) => (0, job_1.wrapJobConsumer)(tracer, s, j, fn, metrics),
366
386
  extractParentContext: trace_bridge_1.extractParentContext,
367
387
  shutdown,
368
388
  };
@@ -33,3 +33,20 @@ import type { NewRelicAgent } from './types';
33
33
  export declare function resolveNewRelic(explicit?: NewRelicAgent): NewRelicAgent;
34
34
  export declare function getNr(): NewRelicAgent;
35
35
  export declare function isNrLoaded(): boolean;
36
+ /**
37
+ * Loads the New Relic agent via CJS require — safe to call from ESM services.
38
+ * Eliminates the createRequire boilerplate:
39
+ *
40
+ * // Before (ESM service):
41
+ * import { createRequire } from 'node:module';
42
+ * const require = createRequire(import.meta.url);
43
+ * let nr; try { nr = require('newrelic'); } catch {}
44
+ * init('kori', { newrelic: nr });
45
+ *
46
+ * // After:
47
+ * import { init, loadNewRelic } from '@foam-ai/node-cliengo';
48
+ * init('kori', { newrelic: loadNewRelic() });
49
+ *
50
+ * Returns undefined if the newrelic package is not installed.
51
+ */
52
+ export declare function loadNewRelic(): NewRelicAgent | undefined;
@@ -34,6 +34,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.resolveNewRelic = resolveNewRelic;
35
35
  exports.getNr = getNr;
36
36
  exports.isNrLoaded = isNrLoaded;
37
+ exports.loadNewRelic = loadNewRelic;
37
38
  const NOOP_NR = {
38
39
  getTransaction: () => null,
39
40
  startBackgroundTransaction: (_name, _group, handler) => typeof handler === 'function' ? handler() : undefined,
@@ -69,3 +70,31 @@ function getNr() {
69
70
  function isNrLoaded() {
70
71
  return cachedNr !== null && cachedNr !== NOOP_NR;
71
72
  }
73
+ /**
74
+ * Loads the New Relic agent via CJS require — safe to call from ESM services.
75
+ * Eliminates the createRequire boilerplate:
76
+ *
77
+ * // Before (ESM service):
78
+ * import { createRequire } from 'node:module';
79
+ * const require = createRequire(import.meta.url);
80
+ * let nr; try { nr = require('newrelic'); } catch {}
81
+ * init('kori', { newrelic: nr });
82
+ *
83
+ * // After:
84
+ * import { init, loadNewRelic } from '@foam-ai/node-cliengo';
85
+ * init('kori', { newrelic: loadNewRelic() });
86
+ *
87
+ * Returns undefined if the newrelic package is not installed.
88
+ */
89
+ function loadNewRelic() {
90
+ try {
91
+ /* eslint-disable @typescript-eslint/no-require-imports */
92
+ const nr = require('newrelic');
93
+ /* eslint-enable @typescript-eslint/no-require-imports */
94
+ cachedNr = nr;
95
+ return nr;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
@@ -49,7 +49,6 @@ export interface FoamMetrics {
49
49
  export interface FoamInstance {
50
50
  tracer: Tracer;
51
51
  meter: Meter;
52
- logger: LoggerProvider;
53
52
  traceProvider: BasicTracerProvider;
54
53
  meterProvider: MeterProvider;
55
54
  loggerProvider: LoggerProvider;
@@ -65,15 +64,40 @@ export interface FoamInstance {
65
64
  customProps: (req: any) => Record<string, string>;
66
65
  };
67
66
  createPinoDestination(): NodeJS.WritableStream;
67
+ /**
68
+ * Creates a Pino logger with trace-context mixin and OTLP destination pre-wired.
69
+ * Pass `streams` to replace the default stdout output (e.g., for pino-pretty):
70
+ *
71
+ * foam.createPinoLogger({ streams: [{ stream: pinoPretty() }] })
72
+ *
73
+ * The OTLP destination is always appended automatically.
74
+ */
68
75
  createPinoLogger(options?: Record<string, any>): any;
69
- createPinoHttpConfig(): Record<string, any>;
76
+ createPinoHttpConfig(opts?: {
77
+ logger?: any;
78
+ }): Record<string, any>;
79
+ /**
80
+ * Returns a Pino config object for Fastify's `logger` constructor option.
81
+ * Fastify constructs its own Pino instance from this, so request.log gets
82
+ * child loggers with request context automatically.
83
+ *
84
+ * Fastify({ logger: foam.createFastifyLoggerConfig({ level: 'info' }) })
85
+ *
86
+ * Pass `streams` to replace stdout (e.g., pino-pretty in dev):
87
+ *
88
+ * foam.createFastifyLoggerConfig({
89
+ * level: 'debug',
90
+ * streams: [{ stream: pinoPretty() }],
91
+ * })
92
+ */
93
+ createFastifyLoggerConfig(opts?: Record<string, any>): Record<string, any>;
70
94
  buildTraceparent(): string;
71
95
  injectSnsAttributes(attrs: Record<string, SnsMessageAttributeValue>): Record<string, SnsMessageAttributeValue>;
72
96
  injectJobData<T extends Record<string, unknown>>(data: T): T & {
73
97
  traceparent: string;
74
98
  };
75
- wrapSqsConsumer(tracer: Tracer, spanName: string, msg: SqsMessage, fn: () => Promise<void>): Promise<void>;
76
- wrapJobConsumer(tracer: Tracer, spanName: string, jobData: Record<string, unknown>, fn: () => Promise<void>): Promise<void>;
99
+ wrapSqsConsumer(spanName: string, msg: SqsMessage, fn: () => Promise<void>): Promise<void>;
100
+ wrapJobConsumer(spanName: string, jobData: Record<string, unknown>, fn: () => Promise<void>): Promise<void>;
77
101
  extractParentContext(traceparent: string): Context;
78
102
  shutdown(): Promise<void>;
79
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foam-ai/node-cliengo",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "Unified observability (traces, logs, metrics) for Cliengo Node.js services, connecting New Relic APM with Foam's OTel collector.",
5
5
  "main": "dist/node-cliengo/src/index.js",
6
6
  "types": "dist/node-cliengo/src/index.d.ts",