@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 +21 -0
- package/lib/logger/index.d.ts +1 -0
- package/lib/logger/index.js +67 -5
- package/lib/logger/log-context.d.ts +56 -0
- package/lib/logger/log-context.js +56 -0
- package/package.json +1 -1
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
|
package/lib/logger/index.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/logger/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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