@contrail/telemetry 2.0.0-dev-telemetry-own-otel-2 → 2.0.1-alpha-log-serialization-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.1] - 2026-04-14
11
+
12
+ ### Fixed
13
+
14
+ - **Error serialization** — `logger.error({ error }, 'msg')` and any other arbitrary key holding an `Error` object now serialize correctly (type, message, stack, enumerable properties) instead of logging `{}`. Handled via a `logMethod` hook that detects `Error` instances in the mergeObject and applies `pino.stdSerializers.err` before pino serializes. The `err` key (pino's built-in) and bare Error passed as the first argument were already working and continue to work unchanged.
15
+
16
+ ## [2.0.0] - 2026-04-06
17
+
18
+ ### Added
19
+
20
+ - **Request context on every log** — `logger` is now context-aware. During a request, all logs (including from library code like `@contrail/dynamodb` or `@contrail/auth`) automatically include `user.id`, `org.slug`, `url.path`, and other request fields. No import changes needed.
21
+ - **`withLogContext(fields, fn)`** — Wrap any block of code with extra log context. Context stacks on top of any existing request context and is removed when the callback exits.
22
+ - **`addStream(streamEntry)`** — Hook a custom pino stream into the logger to tap into all log records (e.g. collect logs in memory for a UI panel).
23
+ - **`flushLogs()`** — Flush pending logs to the OTLP collector before a Lambda execution environment freezes.
24
+ - **OTel log export** — Logs are sent to an OTLP HTTP endpoint (when `OTEL_EXPORTER_OTLP_ENDPOINT` is set) with Lambda attributes, span context, and flattened structured fields.
25
+ - **Structured stdout** — CloudWatch-friendly tab-delimited format with `pino-pretty` support in local dev.
26
+
27
+ ### Breaking Changes
28
+
29
+ - **`logger` is now a Proxy, not a raw Pino instance.** Behavior is identical when no request context is active (falls back to the base logger). This only matters if you were comparing `logger === baseLogger` — they are no longer the same reference. Use `baseLogger` if you need the raw Pino instance.
30
+
10
31
  ## [1.0.2] - 2026-03-25
11
32
 
12
33
  ### Added
@@ -3,6 +3,7 @@ export { parseOtelResourceAttributes } from './parse-otel-resource-attributes';
3
3
  export { PINO_LEVEL_TO_OTEL_SEVERITY, PINO_LEVEL_TO_NAME } from './logger-config';
4
4
  export { ATTR_LOG_MESSAGE, ATTR_LOG_PAYLOAD } from './semantic-conventions';
5
5
  export { loggerStorage, withLogContext } from './log-context';
6
+ export declare const pinoErrorSerializationHooks: pino.LoggerOptions['hooks'];
6
7
  export declare const baseLogger: pino.Logger<never, boolean>;
7
8
  export declare const logger: pino.Logger;
8
9
  /**
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  var _a, _b, _c, _d, _e, _f, _g;
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.logger = exports.baseLogger = exports.withLogContext = exports.loggerStorage = exports.ATTR_LOG_PAYLOAD = exports.ATTR_LOG_MESSAGE = exports.PINO_LEVEL_TO_NAME = exports.PINO_LEVEL_TO_OTEL_SEVERITY = exports.parseOtelResourceAttributes = void 0;
7
+ exports.logger = exports.baseLogger = exports.pinoErrorSerializationHooks = exports.withLogContext = exports.loggerStorage = exports.ATTR_LOG_PAYLOAD = exports.ATTR_LOG_MESSAGE = exports.PINO_LEVEL_TO_NAME = exports.PINO_LEVEL_TO_OTEL_SEVERITY = exports.parseOtelResourceAttributes = void 0;
8
8
  exports.addStream = addStream;
9
9
  exports.flushLogs = flushLogs;
10
10
  const pino_1 = __importDefault(require("pino"));
@@ -111,7 +111,7 @@ function createOtelStream() {
111
111
  var _a, _b;
112
112
  try {
113
113
  const logRecord = JSON.parse(chunk.toString());
114
- const { level, ...attributes } = logRecord;
114
+ const { level, [semantic_conventions_2.ATTR_LOG_MESSAGE]: message, ...attributes } = logRecord;
115
115
  const activeSpan = api_1.trace.getSpan(api_1.context.active());
116
116
  const spanContext = activeSpan === null || activeSpan === void 0 ? void 0 : activeSpan.spanContext();
117
117
  const spanAttributes = getSpanAttributes(activeSpan);
@@ -129,7 +129,7 @@ function createOtelStream() {
129
129
  otelLogger.emit({
130
130
  severityNumber: (_a = logger_config_1.PINO_LEVEL_TO_OTEL_SEVERITY[level]) !== null && _a !== void 0 ? _a : api_logs_1.SeverityNumber.INFO,
131
131
  severityText: (_b = logger_config_1.PINO_LEVEL_TO_NAME[level]) !== null && _b !== void 0 ? _b : 'INFO',
132
- body: attributes[semantic_conventions_2.ATTR_LOG_MESSAGE],
132
+ body: message,
133
133
  // Attribute precedence (last-spread-wins):
134
134
  // 1. safeAttributes — flattened user/log-context attributes (lowest priority)
135
135
  // 2. lambdaEnvAttributes — static Lambda env attributes (override user attrs)
@@ -222,11 +222,71 @@ function getLambdaRequestId() {
222
222
  }
223
223
  }
224
224
  // --- Base Pino Logger ---
225
+ // --- Error Serialization Hooks ---
226
+ /**
227
+ * Exported so tests can create a standalone pino instance with the same hooks.
228
+ *
229
+ * Fixes: Error objects passed under arbitrary keys (e.g. `{ error }`, `{ cause }`)
230
+ * serialize as `{}` because Error properties are non-enumerable.
231
+ *
232
+ * Pino already handles:
233
+ * - `{ err }` — via its default `serializers.err` (stdSerializers.err)
234
+ * - `logger.error(error, 'msg')` — pino wraps bare Error as `{ err: error }` internally
235
+ *
236
+ * This hook covers the remaining cases: any key whose value is an Error instance.
237
+ * It shallow-copies the mergeObject to avoid mutating the caller's object.
238
+ */
239
+ /**
240
+ * Walks a pino mergeObject and returns a new object with any Error-valued keys
241
+ * serialized via pino.stdSerializers.err. Returns null if no Errors are found
242
+ * (caller should use the original object unchanged).
243
+ *
244
+ * Skips the 'err' key — pino serializes it natively via its default serializers.err.
245
+ * Pre-serializing it here would cause double-serialization (plain object → type: "Object").
246
+ */
247
+ function serializeErrorsInMergeObject(mergeObject) {
248
+ let transformed = null;
249
+ for (const [key, value] of Object.entries(mergeObject)) {
250
+ if (!(value instanceof Error))
251
+ continue;
252
+ if (key === 'err')
253
+ continue;
254
+ if (!transformed)
255
+ transformed = { ...mergeObject };
256
+ transformed[key] = pino_1.default.stdSerializers.err(value);
257
+ }
258
+ return transformed;
259
+ }
260
+ exports.pinoErrorSerializationHooks = {
261
+ logMethod(inputArgs, method) {
262
+ if (inputArgs.length === 0)
263
+ return method.apply(this, inputArgs);
264
+ const [firstArg, ...rest] = inputArgs;
265
+ if (firstArg === null || firstArg === undefined) {
266
+ return method.apply(this, inputArgs);
267
+ }
268
+ if (firstArg instanceof Error) {
269
+ // Pino handles this internally: it wraps the bare Error as { err: error }
270
+ // before calling logMethod, so this branch is never actually reached.
271
+ // Guard is here for clarity and safety.
272
+ return method.apply(this, inputArgs);
273
+ }
274
+ if (typeof firstArg !== 'object') {
275
+ return method.apply(this, inputArgs);
276
+ }
277
+ const transformed = serializeErrorsInMergeObject(firstArg);
278
+ if (transformed) {
279
+ return method.apply(this, [transformed, ...rest]);
280
+ }
281
+ return method.apply(this, inputArgs);
282
+ },
283
+ };
225
284
  exports.baseLogger = (0, pino_1.default)({
226
285
  level: (_g = process.env.LOG_LEVEL) !== null && _g !== void 0 ? _g : 'debug',
227
286
  messageKey: semantic_conventions_2.ATTR_LOG_MESSAGE,
228
287
  timestamp: pino_1.default.stdTimeFunctions.isoTime,
229
288
  nestedKey: semantic_conventions_2.ATTR_LOG_PAYLOAD,
289
+ hooks: exports.pinoErrorSerializationHooks,
230
290
  base: {
231
291
  [semantic_conventions_1.ATTR_SERVICE_NAME]: serviceName,
232
292
  [semantic_conventions_4.ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: deploymentEnvironment,
@@ -261,10 +321,12 @@ async function flushLogs(options) {
261
321
  ]);
262
322
  }
263
323
  catch (error) {
264
- console.error('[OTel Log Flush Failed]', JSON.stringify({
324
+ // Write directly to stderr to avoid routing through pino → OTel during an OTel failure
325
+ // (same pattern as setGlobalErrorHandler above).
326
+ process.stderr.write(`[OTel Log Flush Failed] ${JSON.stringify({
265
327
  error: error instanceof Error ? error.message : String(error),
266
328
  timeoutMs,
267
329
  timestamp: new Date().toISOString(),
268
- }));
330
+ })}\n`);
269
331
  }
270
332
  }
@@ -1,5 +1,61 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
2
  import type { Logger } from 'pino';
3
+ /**
4
+ * Stores the current request-scoped child logger per async context.
5
+ * Used by `createContextAwareLogger` to make the `logger` export context-aware,
6
+ * and by `withLogContext` to set context for a block of code.
7
+ */
3
8
  export declare const loggerStorage: AsyncLocalStorage<Logger>;
9
+ /**
10
+ * Wraps a base Pino logger in a Proxy that is aware of AsyncLocalStorage context.
11
+ * Called once at module init — the returned Proxy becomes the `logger` export.
12
+ *
13
+ * When code calls `logger.info(...)`, the Proxy intercepts the property access:
14
+ * 1. Checks `loggerStorage.getStore()` for a request-scoped child logger
15
+ * 2. If found, delegates to the child (which has user/org/request fields baked in)
16
+ * 3. If not found (e.g. during startup), falls back to the base logger
17
+ *
18
+ * This means all 39+ library files that `import { logger } from '@contrail/telemetry'`
19
+ * automatically get request-scoped context without any import changes.
20
+ *
21
+ * @example
22
+ * // In telemetry/src/logger/index.ts (called once):
23
+ * export const logger = createContextAwareLogger(baseLogger);
24
+ *
25
+ * // In any library file:
26
+ * import { logger } from '@contrail/telemetry';
27
+ * logger.info('query executed'); // includes user.id, org.slug, etc. during a request
28
+ *
29
+ * ## How Proxy and Reflect work
30
+ *
31
+ * **Proxy** — wraps an object and intercepts operations (get, set, has, etc.).
32
+ * We only intercept `get` so that property reads (like `.info`, `.warn`, `.level`)
33
+ * are redirected to the context-appropriate logger.
34
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
35
+ *
36
+ * **Reflect.get(target, prop, receiver)** — performs the default property read.
37
+ * Using `Reflect.get` instead of `target[prop]` preserves correct `this` binding,
38
+ * which matters for Pino's internal method calls.
39
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get
40
+ */
4
41
  export declare function createContextAwareLogger(baseLogger: Logger): Logger;
42
+ /**
43
+ * Runs `fn` with a child logger active in AsyncLocalStorage.
44
+ * Any code that calls `logger.info(...)` inside `fn` (including library code)
45
+ * will see the provided `fields` on its log output.
46
+ *
47
+ * Creates a child of the current context logger (or base logger if none),
48
+ * so nested calls stack correctly.
49
+ *
50
+ * @example
51
+ * // In a NestJS interceptor:
52
+ * withLogContext({ user: { id: 'abc' }, url: { path: '/api/items' } }, () => {
53
+ * next.handle().subscribe(...);
54
+ * });
55
+ *
56
+ * // In a Lambda handler:
57
+ * withLogContext({ 'batch.id': batchId }, async () => {
58
+ * await processBatch(items);
59
+ * });
60
+ */
5
61
  export declare function withLogContext<T>(fields: Record<string, unknown>, fn: () => T): T;
@@ -4,8 +4,45 @@ exports.loggerStorage = void 0;
4
4
  exports.createContextAwareLogger = createContextAwareLogger;
5
5
  exports.withLogContext = withLogContext;
6
6
  const async_hooks_1 = require("async_hooks");
7
+ /**
8
+ * Stores the current request-scoped child logger per async context.
9
+ * Used by `createContextAwareLogger` to make the `logger` export context-aware,
10
+ * and by `withLogContext` to set context for a block of code.
11
+ */
7
12
  exports.loggerStorage = new async_hooks_1.AsyncLocalStorage();
8
13
  let _baseLogger;
14
+ /**
15
+ * Wraps a base Pino logger in a Proxy that is aware of AsyncLocalStorage context.
16
+ * Called once at module init — the returned Proxy becomes the `logger` export.
17
+ *
18
+ * When code calls `logger.info(...)`, the Proxy intercepts the property access:
19
+ * 1. Checks `loggerStorage.getStore()` for a request-scoped child logger
20
+ * 2. If found, delegates to the child (which has user/org/request fields baked in)
21
+ * 3. If not found (e.g. during startup), falls back to the base logger
22
+ *
23
+ * This means all 39+ library files that `import { logger } from '@contrail/telemetry'`
24
+ * automatically get request-scoped context without any import changes.
25
+ *
26
+ * @example
27
+ * // In telemetry/src/logger/index.ts (called once):
28
+ * export const logger = createContextAwareLogger(baseLogger);
29
+ *
30
+ * // In any library file:
31
+ * import { logger } from '@contrail/telemetry';
32
+ * logger.info('query executed'); // includes user.id, org.slug, etc. during a request
33
+ *
34
+ * ## How Proxy and Reflect work
35
+ *
36
+ * **Proxy** — wraps an object and intercepts operations (get, set, has, etc.).
37
+ * We only intercept `get` so that property reads (like `.info`, `.warn`, `.level`)
38
+ * are redirected to the context-appropriate logger.
39
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
40
+ *
41
+ * **Reflect.get(target, prop, receiver)** — performs the default property read.
42
+ * Using `Reflect.get` instead of `target[prop]` preserves correct `this` binding,
43
+ * which matters for Pino's internal method calls.
44
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get
45
+ */
9
46
  function createContextAwareLogger(baseLogger) {
10
47
  _baseLogger = baseLogger;
11
48
  return new Proxy(baseLogger, {
@@ -16,6 +53,25 @@ function createContextAwareLogger(baseLogger) {
16
53
  },
17
54
  });
18
55
  }
56
+ /**
57
+ * Runs `fn` with a child logger active in AsyncLocalStorage.
58
+ * Any code that calls `logger.info(...)` inside `fn` (including library code)
59
+ * will see the provided `fields` on its log output.
60
+ *
61
+ * Creates a child of the current context logger (or base logger if none),
62
+ * so nested calls stack correctly.
63
+ *
64
+ * @example
65
+ * // In a NestJS interceptor:
66
+ * withLogContext({ user: { id: 'abc' }, url: { path: '/api/items' } }, () => {
67
+ * next.handle().subscribe(...);
68
+ * });
69
+ *
70
+ * // In a Lambda handler:
71
+ * withLogContext({ 'batch.id': batchId }, async () => {
72
+ * await processBatch(items);
73
+ * });
74
+ */
19
75
  function withLogContext(fields, fn) {
20
76
  var _a;
21
77
  const parent = (_a = exports.loggerStorage.getStore()) !== null && _a !== void 0 ? _a : _baseLogger;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/telemetry",
3
- "version": "2.0.0-dev-telemetry-own-otel-2",
3
+ "version": "2.0.1-alpha-log-serialization-1",
4
4
  "description": "Telemetry and monitoring utilities for contrail services",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",