From 3f95bf863b68b8a3e9f36b03e50905df3e5800a0 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 14:56:37 +0800 Subject: [PATCH 01/13] feat: otelAttributesFormat maps audit, redacts requestBodyValues, compacts retries and caps strings Adds mapAuditLevel, redactKeys, redactedValue, compactErrors and maxAttributeLength options, all on by default. Adds compactErrorSerializer and defaultOtelRedactKeys exports. --- src/audit-level.ts | 15 +++ src/compacted-error-keys.ts | 25 ++++ src/map-audit-level-for-otel.ts | 35 +++--- src/otel-attributes-format.spec.ts | 181 ++++++++++++++++++++++++++++- src/otel-attributes-format.ts | 170 +++++++++++++++++++++++---- src/serialize-error.spec.ts | 57 ++++++++- src/serialize-error.ts | 20 ++++ 7 files changed, 460 insertions(+), 43 deletions(-) create mode 100644 src/audit-level.ts create mode 100644 src/compacted-error-keys.ts diff --git a/src/audit-level.ts b/src/audit-level.ts new file mode 100644 index 0000000..4a28fa1 --- /dev/null +++ b/src/audit-level.ts @@ -0,0 +1,15 @@ +// Internal: deliberately not re-exported from index.ts. +import { type TransformableInfo } from 'logform' +import { LEVEL } from 'triple-beam' + +/** + * Copies the record's level onto `logLevel`, and exports an `audit` record at `info` severity. + * + * The string `level` is never changed, so a second pass writes the same `logLevel` again. + * See `mapAuditLevelForOtel` for why the mapping exists. + */ +export const applyAuditLevelMapping = (info: TransformableInfo): TransformableInfo => { + info.logLevel = info.level + if (info.level === 'audit') info[LEVEL] = 'info' + return info +} diff --git a/src/compacted-error-keys.ts b/src/compacted-error-keys.ts new file mode 100644 index 0000000..d949ea6 --- /dev/null +++ b/src/compacted-error-keys.ts @@ -0,0 +1,25 @@ +// Internal: deliberately not re-exported from index.ts. + +const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null + +/** An error as `serializeError` writes one, or a provider's own error object: it has a string `name` and `message`. */ +const isErrorShaped = (value: unknown): value is Record => + isObject(value) && typeof value.name === 'string' && typeof value.message === 'string' + +/** + * Whether `key` is left out when a serialized error is compacted. + * + * An AI SDK `RetryError` holds every attempt's `APICallError` under `errors`, and the last attempt + * once more under `lastError`. Each attempt carries its response headers. Together they overrun the + * 8,192-character limit of an App Insights custom dimension, so the dimension is cut off and no longer + * parses as JSON. `responseHeaders` is dropped from every error, and `lastError` from an error whose + * `errors` already holds it. + * + * Only an error is compacted: a caller's own `responseHeaders` on a plain object is kept. + */ +export const isCompactedErrorKey = (holder: unknown, key: string): boolean => { + if (key !== 'responseHeaders' && key !== 'lastError') return false + if (!isErrorShaped(holder)) return false + if (key === 'responseHeaders') return true + return Array.isArray(holder.errors) && holder.errors.length > 0 +} diff --git a/src/map-audit-level-for-otel.ts b/src/map-audit-level-for-otel.ts index 21acbed..1b4ec2d 100644 --- a/src/map-audit-level-for-otel.ts +++ b/src/map-audit-level-for-otel.ts @@ -1,32 +1,27 @@ -import { LEVEL } from 'triple-beam' import { format } from 'winston' +import { applyAuditLevelMapping } from './audit-level' /** * OTEL's log spec defines a fixed severity enumeration (trace/debug/info/warn/error/fatal). - * `@opentelemetry/winston-transport` (auto-installed by `@opentelemetry/instrumentation-winston`) - * derives `severityText` / `severityNumber` from Winston's triple-beam `LEVEL` symbol and strips - * the string `level` property before building attributes — so Winston's custom `audit` level - * arrives at any OTEL backend with `severityNumber: undefined` and no queryable record of the - * original level. This is most visible on Azure Monitor / Log Analytics (which ignores records - * without a mapped severity), but applies to any OTEL backend that relies on the spec-defined - * severity. + * `@opentelemetry/winston-transport` derives `severityText` / `severityNumber` from Winston's + * triple-beam `LEVEL` symbol and strips the string `level` property before building attributes, + * so Winston's custom `audit` level arrives at an OTEL backend with no severity and no queryable + * record of the original level. Azure Monitor / Log Analytics ignores records without a mapped + * severity, and a transport `logSeverity` filter drops them. * * This format: * - rewrites `info[LEVEL]` from `audit` to `info` so OTEL maps to a known severity tier * - copies the original level onto `logLevel` on every record so it survives as an OTEL * attribute (e.g. queryable as `customDimensions.logLevel == "audit"` in Azure Log Analytics) * - * The string `info.level` is left as-is — it's only consumed by other winston transports (e.g. - * our Console transport's JSON output), so leaving it alone keeps local output readable. + * The string `info.level` is left as it is, so other transports still show `audit`. * - * Caveat: because the `LEVEL` symbol also drives transport-level filtering, a logger/transport - * explicitly set to `level: 'audit'` would drop these records after rewrite (info is more - * verbose than audit). `createLogger` guards against that by throwing when the option is - * enabled alongside an `audit`-level logger/console/transport; when using the format - * standalone, apply the same discipline. Only enable this when shipping logs via OTEL. + * `otelAttributesFormat` already does this, so most loggers need only that. Use this format on + * its own in a chain you build yourself, for example beside `jsonStringifyValuesFormat`. + * + * Caveat: the `LEVEL` symbol also drives transport-level filtering. On a transport's own format + * the mapping runs after that transport's filter, so it is always safe there. At the logger + * level it runs before every transport's filter, so a logger or transport set to `level: 'audit'` + * would drop these records (`info` is more verbose than `audit`). Use `'info'` there instead. */ -export const mapAuditLevelForOtel = format((info) => { - info.logLevel = info.level - if (info.level === 'audit') info[LEVEL] = 'info' - return info -}) +export const mapAuditLevelForOtel = format((info) => applyAuditLevelMapping(info)) diff --git a/src/otel-attributes-format.spec.ts b/src/otel-attributes-format.spec.ts index 456efe4..4effc91 100644 --- a/src/otel-attributes-format.spec.ts +++ b/src/otel-attributes-format.spec.ts @@ -1,7 +1,7 @@ import { TransformableInfo } from 'logform' import { LEVEL } from 'triple-beam' import { describe, expect, it } from 'vitest' -import { otelAttributesFormat, OtelAttributesFormatOptions } from './otel-attributes-format' +import { defaultOtelRedactKeys, otelAttributesFormat, OtelAttributesFormatOptions } from './otel-attributes-format' const run = (info: Record, opts?: OtelAttributesFormatOptions) => { const input = { [LEVEL]: 'error', level: 'error', message: 'failed', ...info } as TransformableInfo @@ -101,3 +101,182 @@ describe('otelAttributesFormat — other values', () => { expect(run({ obj: { a: 1 } })[LEVEL]).toBe('error') }) }) + +describe('otelAttributesFormat — level', () => { + const at = (level: string, opts?: OtelAttributesFormatOptions) => run({ [LEVEL]: level, level }, opts) + + it('exports an audit record at info severity and keeps the original on logLevel', () => { + const result = at('audit') + expect(result[LEVEL]).toBe('info') + expect(result.level).toBe('audit') + expect(result.logLevel).toBe('audit') + }) + + it('records logLevel on every other level and leaves its LEVEL alone', () => { + const result = at('warn') + expect(result[LEVEL]).toBe('warn') + expect(result.logLevel).toBe('warn') + }) + + it('maps nothing with mapAuditLevel: false', () => { + const result = at('audit', { mapAuditLevel: false }) + expect(result[LEVEL]).toBe('audit') + expect(result).not.toHaveProperty('logLevel') + }) +}) + +/** An AI SDK `APICallError` as `serializeError` writes it. */ +const apiCallError = (attempt: number) => ({ + name: 'AI_APICallError', + message: `attempt ${attempt} failed`, + stack: `AI_APICallError: attempt ${attempt} failed\n at x`, + statusCode: 429, + requestBodyValues: { messages: [{ role: 'user', content: 'the whole prompt' }] }, + responseHeaders: { 'retry-after': '1', 'x-request-id': `req-${attempt}` }, + responseBody: '{"error":"rate limited"}', +}) + +/** An AI SDK `RetryError`: every attempt under `errors`, and the last once more under `lastError`. */ +const retryError = () => ({ + name: 'AI_RetryError', + message: 'Failed after 2 attempts', + stack: 'AI_RetryError: Failed after 2 attempts\n at x', + reason: 'maxRetriesExceeded', + errors: [apiCallError(1), apiCallError(2)], + lastError: apiCallError(2), +}) + +describe('otelAttributesFormat — redaction', () => { + it('redacts requestBodyValues in errorDetail', () => { + const detail = JSON.parse(run({ error: apiCallError(1) }).errorDetail as string) as Record + expect(detail.requestBodyValues).toBe('') + expect(detail.statusCode).toBe(429) + }) + + it('redacts it inside every attempt of a retry', () => { + const detail = JSON.parse(run({ error: retryError() }).errorDetail as string) as { errors: Record[] } + expect(detail.errors.map((attempt) => attempt.requestBodyValues)).toEqual(['', '']) + }) + + it('redacts it at the top of the record and nested in other values', () => { + const result = run({ requestBodyValues: { prompt: 'p' }, ctx: { call: { requestBodyValues: 'p' } } }) + expect(result.requestBodyValues).toBe('') + expect(JSON.parse(result.ctx as string)).toEqual({ call: { requestBodyValues: '' } }) + }) + + it('leaves a nil value as it is', () => { + expect(JSON.parse(run({ ctx: { requestBodyValues: null } }).ctx as string)).toEqual({ requestBodyValues: null }) + }) + + it('redacts nothing with redactKeys: []', () => { + const detail = JSON.parse(run({ error: apiCallError(1) }, { redactKeys: [] }).errorDetail as string) as Record + expect(detail.requestBodyValues).toEqual(apiCallError(1).requestBodyValues) + }) + + it('redacts added keys beside the defaults, with a custom redactedValue', () => { + const result = run( + { error: serialized({ prompt: 'p', requestBodyValues: 'v' }) }, + { redactKeys: [...defaultOtelRedactKeys, 'prompt'], redactedValue: '***' }, + ) + expect(JSON.parse(result.errorDetail as string)).toEqual({ prompt: '***', requestBodyValues: '***' }) + }) + + it('runs before the replacer, which sees the redacted value', () => { + const seen: unknown[] = [] + const replacer = (key: string, value: unknown) => { + if (key === 'requestBodyValues') seen.push(value) + return value + } + run({ ctx: { requestBodyValues: 'p' } }, { replacer }) + expect(seen).toEqual(['']) + }) +}) + +describe('otelAttributesFormat — retry compaction', () => { + it('drops responseHeaders from the error and from every attempt, and lastError beside errors', () => { + const result = run({ error: retryError() }) + const detail = JSON.parse(result.errorDetail as string) as { errors: Record[] } + expect(detail).not.toHaveProperty('lastError') + expect(detail.errors).toHaveLength(2) + expect(detail.errors.every((attempt) => !('responseHeaders' in attempt))).toBe(true) + expect(detail.errors[1].responseBody).toBe('{"error":"rate limited"}') + expect(result.error).toEqual({ name: 'AI_RetryError', message: 'Failed after 2 attempts', stack: retryError().stack }) + }) + + it('drops responseHeaders from an error logged on its own', () => { + expect(JSON.parse(run({ error: apiCallError(1) }).errorDetail as string)).not.toHaveProperty('responseHeaders') + }) + + it('keeps lastError when there is no errors list to hold it', () => { + const detail = JSON.parse(run({ error: serialized({ lastError: apiCallError(1), errors: [] }) }).errorDetail as string) + expect(detail.lastError).toMatchObject({ message: 'attempt 1 failed' }) + }) + + it("keeps a caller's own responseHeaders on a value that is not an error", () => { + expect(JSON.parse(run({ response: { status: 200, responseHeaders: { etag: 'x' } } }).response as string)).toEqual({ + status: 200, + responseHeaders: { etag: 'x' }, + }) + }) + + it('keeps everything with compactErrors: false', () => { + const detail = JSON.parse(run({ error: retryError() }, { compactErrors: false }).errorDetail as string) as Record + expect(detail).toHaveProperty('lastError') + expect((detail.errors as Record[])[0]).toHaveProperty('responseHeaders') + }) +}) + +describe('otelAttributesFormat — length cap', () => { + const long = 'x'.repeat(20_000) + + it('cuts errorDetail to 8,192 characters, ending in [Truncated]', () => { + const detail = run({ error: serialized({ body: long }) }).errorDetail as string + expect(detail).toHaveLength(8192) + expect(detail.endsWith('[Truncated]')).toBe(true) + }) + + it('cuts other stringified values too', () => { + const value = run({ ctx: { body: long } }).ctx as string + expect(value).toHaveLength(8192) + expect(value.endsWith('[Truncated]')).toBe(true) + }) + + it('leaves strings the caller wrote, the message and the exception whole', () => { + const result = run({ message: long, text: long, error: serialized({ message: long, stack: long }) }) + expect(result.message).toBe(long) + expect(result.text).toBe(long) + expect(result.error).toMatchObject({ message: long, stack: long }) + }) + + it('honours a custom maxAttributeLength, and Infinity writes every string whole', () => { + expect(run({ ctx: { body: long } }, { maxAttributeLength: 100 }).ctx).toHaveLength(100) + expect(JSON.parse(run({ ctx: { body: long } }, { maxAttributeLength: Infinity }).ctx as string)).toEqual({ body: long }) + }) + + it('does not split a surrogate pair', () => { + const value = run({ ctx: { body: '😀'.repeat(100) } }, { maxAttributeLength: 30 }).ctx as string + expect(value.endsWith('[Truncated]')).toBe(true) + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(value)).toBe(false) + }) +}) + +describe('otelAttributesFormat — shared state', () => { + it('changes no nested value, so a transport placement leaves its siblings their own objects', () => { + const error = retryError() + const ctx = { requestBodyValues: 'p', inner: { a: 1 } } + const before = structuredClone({ error, ctx }) + run({ error, ctx }) + expect({ error, ctx }).toEqual(before) + }) + + it('applied twice writes the same record as applied once', () => { + const fmt = otelAttributesFormat() + const input = { [LEVEL]: 'audit', level: 'audit', message: 'm', error: retryError(), ctx: { a: 1 } } as unknown as TransformableInfo + const once = fmt.transform(structuredClone(input), fmt.options) as TransformableInfo + const onceCopy = { ...once } + const twice = fmt.transform(once, fmt.options) as TransformableInfo + expect(twice).toEqual(onceCopy) + expect(twice[LEVEL]).toBe('info') + expect(twice.logLevel).toBe('audit') + }) +}) diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 5b7f311..1ee6d6e 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -1,13 +1,95 @@ import { format } from 'winston' +import { applyAuditLevelMapping } from './audit-level' +import { isCompactedErrorKey } from './compacted-error-keys' import { serializeError } from './serialize-error' import { serializesItself } from './serializes-itself' // eslint-disable-next-line @typescript-eslint/no-explicit-any type Replacer = (this: any, key: string, value: any) => any +/** + * Keys `otelAttributesFormat` redacts by default, wherever they appear in a record. + * + * `requestBodyValues` is the whole request an AI SDK `APICallError` carries as an own property: the + * prompt, and everything the prompt was built from. + */ +export const defaultOtelRedactKeys: readonly string[] = Object.freeze(['requestBodyValues']) + +/** The longest string attribute an App Insights custom dimension keeps. */ +const DEFAULT_MAX_ATTRIBUTE_LENGTH = 8192 + +const TRUNCATED = '[Truncated]' + export interface OtelAttributesFormatOptions { - /** `JSON.stringify` replacer applied to every value this format stringifies, `errorDetail` included. */ + /** + * `JSON.stringify` replacer applied to every value this format stringifies, `errorDetail` + * included. It runs after redaction and compaction, so it sees `''` in place of a + * redacted value. + */ replacer?: Replacer + + /** + * Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. + * Defaults to `true`. See `mapAuditLevelForOtel` for why. Turn it off for a level map with no + * `audit`, or when the transport maps severity itself (`severityMapping`, from transport 0.34). + */ + mapAuditLevel?: boolean + + /** + * Keys whose values are replaced with `redactedValue`, matched at every depth. Defaults to + * {@link defaultOtelRedactKeys}. The list replaces the default: spread it to add a key, or pass `[]` + * to redact nothing. + */ + redactKeys?: readonly string[] + + /** Replacement value for `redactKeys`. Defaults to `''`. */ + redactedValue?: string + + /** + * Leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` + * already holds it. Defaults to `true`. These are what make a failed AI SDK retry overrun an App + * Insights custom dimension. + */ + compactErrors?: boolean + + /** + * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`. + * Defaults to 8,192, the App Insights custom-dimension limit. Pass `Infinity` to write every + * string whole. Values that were already strings are never cut. + */ + maxAttributeLength?: number +} + +interface Settings { + replacer: Replacer | undefined + redactKeys: ReadonlySet + redactedValue: string + compactErrors: boolean + maxAttributeLength: number + mapAuditLevel: boolean +} + +const resolveSettings = (opts: OtelAttributesFormatOptions): Settings => ({ + replacer: opts.replacer, + redactKeys: new Set(opts.redactKeys ?? defaultOtelRedactKeys), + redactedValue: opts.redactedValue ?? '', + compactErrors: opts.compactErrors ?? true, + maxAttributeLength: opts.maxAttributeLength ?? DEFAULT_MAX_ATTRIBUTE_LENGTH, + mapAuditLevel: opts.mapAuditLevel ?? true, +}) + +/** A format instance keeps one options object for its lifetime, so its settings are resolved once. */ +const settingsCache = new WeakMap() +const defaultSettings = resolveSettings({}) + +const settingsFor = (opts: OtelAttributesFormatOptions | undefined): Settings => { + if (!opts) return defaultSettings + let settings = settingsCache.get(opts) + if (!settings) { + settings = resolveSettings(opts) + settingsCache.set(opts, settings) + } + return settings } /** The keys `@opentelemetry/winston-transport` reads an exception from, in the order it tries them. */ @@ -35,43 +117,72 @@ const isDescribableCause = (cause: unknown): boolean => typeof cause.code === 'string' || typeof cause.code === 'number')) -/** Plain JSON for any value: errors serialized, BigInts written as `n`, and never a throw out of the log call. */ -const stringify = (value: unknown, replacer: Replacer | undefined): string => { +const isRedacted = (settings: Settings, key: string, value: unknown): boolean => value != null && settings.redactKeys.has(key) + +/** Cuts a string to `max`, ending in `[Truncated]`, without splitting a surrogate pair. */ +const cap = (text: string, max: number): string => { + if (text.length <= max) return text + let end = Math.max(0, max - TRUNCATED.length) + const last = text.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + return text.slice(0, end) + TRUNCATED +} + +/** + * Plain JSON for any value: compacted keys left out, redacted keys replaced, errors serialized, + * BigInts written as `n`, the result capped, and never a throw out of the log call. + * + * A JSON replacer is called with the holding object as `this`, so a `lastError` is judged against + * its own siblings without the value being cloned or changed. + */ +const stringify = (value: unknown, settings: Settings): string => { + const { replacer } = settings const plain = function (this: unknown, key: string, entry: unknown): unknown { - const converted = entry instanceof Error ? serializeError(entry) : typeof entry === 'bigint' ? `${entry}n` : entry + let converted: unknown + if (key !== '' && settings.compactErrors && isCompactedErrorKey(this, key)) return undefined + if (key !== '' && isRedacted(settings, key, entry)) converted = settings.redactedValue + else converted = entry instanceof Error ? serializeError(entry) : typeof entry === 'bigint' ? `${entry}n` : entry return replacer ? (replacer.call(this, key, converted) as unknown) : converted } + let json: string try { - return JSON.stringify(value, plain) ?? String(value) + json = JSON.stringify(value, plain) ?? String(value) } catch { - return '[Unserializable]' + json = '[Unserializable]' } + return cap(json, settings.maxAttributeLength) } /** A value that writes itself as a primitive (a `Date`'s ISO string) is kept unquoted, so a query reads it as it is. */ -const flatValue = (value: object, replacer: Replacer | undefined): unknown => { - if (!replacer && serializesItself(value)) { +const flatValue = (value: object, settings: Settings): unknown => { + if (!settings.replacer && serializesItself(value)) { try { const json: unknown = (value as { toJSON: () => unknown }).toJSON() - if (json === null || typeof json === 'string' || typeof json === 'number' || typeof json === 'boolean') return json + if (typeof json === 'string') return cap(json, settings.maxAttributeLength) + if (json === null || typeof json === 'number' || typeof json === 'boolean') return json } catch { // fall through to the JSON of the value itself } } - return stringify(value, replacer) + return stringify(value, settings) } /** * Leaves the exception what the logs SDK reads — `name`, `message`, `stack`, and a cause it can * describe — and moves the rest of the error to `Detail` as JSON. The SDK takes a truthy `code` * over `name` for `exception.type`, so `code` moves too, unless there is no `name` to take its place. + * + * A `Detail` that is already a string is kept: the error was split by an earlier pass, and + * splitting what that pass left would replace the detail with the cause alone. */ -const splitException = (record: Record, key: string, error: Record, replacer: Replacer | undefined) => { +const splitException = (record: Record, key: string, error: Record, settings: Settings) => { const exception: Record = {} const detail: Record = {} for (const [field, value] of Object.entries(error)) { - if (EXCEPTION_FIELDS.has(field)) exception[field] = value - else detail[field] = value + if (settings.compactErrors && isCompactedErrorKey(error, field)) continue + const written = isRedacted(settings, field, value) ? settings.redactedValue : value + if (EXCEPTION_FIELDS.has(field)) exception[field] = written + else detail[field] = written } if (isDescribableCause(error.cause)) exception.cause = error.cause if (exception.name == null && error.code != null) { @@ -79,7 +190,9 @@ const splitException = (record: Record, key: string, error: Rec delete detail.code } record[key] = exception - if (Object.keys(detail).length > 0) record[`${key}Detail`] = stringify(detail, replacer) + const detailKey = `${key}Detail` + if (typeof record[detailKey] === 'string') return + if (Object.keys(detail).length > 0) record[detailKey] = stringify(detail, settings) } /** @@ -98,21 +211,36 @@ const splitException = (record: Record, key: string, error: Rec * turn them into nested map attributes, which most backends — Azure Log Analytics included — do not * index. Scalars are left alone. A `Date` becomes its ISO string, unquoted. * - * Use it on the transport to keep other transports' output nested, or through - * `createLogger({ flatten: 'otel' })` when the transport is added for you, as `WinstonInstrumentation` - * does. For a transport before 0.29, which has no exception handling, use `jsonStringifyValuesFormat`. + * By default it also: + * - exports an `audit` record at `info` severity, with every record's level on `logLevel` + * - redacts {@link defaultOtelRedactKeys} at every depth + * - leaves out what makes a failed AI SDK retry too large (`responseHeaders`, a repeated `lastError`) + * - caps every string it writes at 8,192 characters, ending in `[Truncated]` * - * Mutates `info` in place, so winston's Symbol-keyed routing properties are preserved. + * See {@link OtelAttributesFormatOptions} to change any of these. + * + * Put it on the OpenTelemetry transport to keep other transports' output nested, or use + * `createLogger({ otel: true })` when the transport is added for you, as `WinstonInstrumentation` + * does. On the transport the audit mapping runs after the transport's level filter, so it can never + * drop a record. + * + * Mutates `info` in place, so winston's Symbol-keyed routing properties are preserved. Nested + * values are never changed. */ export const otelAttributesFormat = format((info, opts) => { - const { replacer } = (opts ?? {}) as OtelAttributesFormatOptions + const settings = settingsFor(opts as OtelAttributesFormatOptions | undefined) + if (settings.mapAuditLevel) applyAuditLevelMapping(info) const record = info as unknown as Record const lifted = EXCEPTION_KEYS.find((key) => isExceptionCandidate(record[key])) for (const key of Object.keys(record)) { const value = record[key] + if (isRedacted(settings, key, value)) { + record[key] = settings.redactedValue + continue + } if (!isObject(value)) continue - if (key === lifted) splitException(record, key, value instanceof Error ? serializeError(value) : value, replacer) - else record[key] = flatValue(value, replacer) + if (key === lifted) splitException(record, key, value instanceof Error ? serializeError(value) : value, settings) + else record[key] = flatValue(value, settings) } return info }) diff --git a/src/serialize-error.spec.ts b/src/serialize-error.spec.ts index 05e000d..a72b8ff 100644 --- a/src/serialize-error.spec.ts +++ b/src/serialize-error.spec.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' -import { createSerializableErrorReplacer, serializableErrorReplacer, serializeError } from './serialize-error' +import { compactErrorSerializer, createSerializableErrorReplacer, serializableErrorReplacer, serializeError } from './serialize-error' describe('serializeError', () => { it('message and stack are not enumerable (and not serialized) by default', () => { @@ -211,3 +211,58 @@ describe('createSerializableErrorReplacer', () => { expect(out.summary).toBeUndefined() }) }) + +describe('compactErrorSerializer', () => { + /** An AI SDK `APICallError`: its response headers are own properties. */ + const apiCallError = (attempt: number) => + Object.assign(new Error(`attempt ${attempt} failed`), { + name: 'AI_APICallError', + statusCode: 429, + responseHeaders: { 'x-request-id': `req-${attempt}` }, + responseBody: 'rate limited', + }) + + /** An AI SDK `RetryError`: every attempt under `errors`, and the last once more under `lastError`. */ + const retryError = () => { + const errors = [apiCallError(1), apiCallError(2)] + return Object.assign(new Error('Failed after 2 attempts'), { name: 'AI_RetryError', errors, lastError: errors[1] }) + } + + it('drops responseHeaders from every error and lastError beside errors', () => { + const out = compactErrorSerializer(retryError()) as { errors: Record[] } + expect(out).not.toHaveProperty('lastError') + expect(out.errors).toHaveLength(2) + expect(out.errors.every((attempt) => !('responseHeaders' in attempt))).toBe(true) + expect(out.errors[1]).toMatchObject({ name: 'AI_APICallError', statusCode: 429, responseBody: 'rate limited' }) + }) + + it('drops responseHeaders from a cause too', () => { + const out = compactErrorSerializer(new Error('wrapped', { cause: apiCallError(1) })) as { cause: Record } + expect(out.cause).toMatchObject({ statusCode: 429 }) + expect(out.cause).not.toHaveProperty('responseHeaders') + }) + + it('keeps lastError when there is no errors list', () => { + const error = Object.assign(new Error('x'), { lastError: apiCallError(1) }) + expect(compactErrorSerializer(error)).toHaveProperty('lastError') + }) + + it('writes everything else as serializeError does', () => { + const error = Object.assign(new Error('plain'), { code: 'E_X', detail: { a: [1, 2] } }) + expect(compactErrorSerializer(error)).toEqual(serializeError(error)) + }) + + it('compacts what a transport receives when passed to createLogger as errorSerializer', async () => { + const { CallbackTransport, createLogger } = await import('./index') + const received: Record[] = [] + const transport = new CallbackTransport( + async ({ meta }) => void received.push(meta), + () => undefined, + ) + const logger = createLogger({ consoleOptions: { silent: true }, errorSerializer: compactErrorSerializer, transports: [transport] }) + logger.error('chat failed', { error: retryError() }) + await transport.close!() + expect(received[0].error).not.toHaveProperty('lastError') + expect((received[0].error as { errors: object[] }).errors[0]).not.toHaveProperty('responseHeaders') + }) +}) diff --git a/src/serialize-error.ts b/src/serialize-error.ts index 1e73fbd..5213547 100644 --- a/src/serialize-error.ts +++ b/src/serialize-error.ts @@ -1,4 +1,5 @@ import { JsonOptions } from 'logform' +import { isCompactedErrorKey } from './compacted-error-keys' import { serializesItself } from './serializes-itself' /** @@ -105,6 +106,25 @@ export const serializeError: ErrorSerializer = (error) => { return walk(error, new WeakSet(), 0) as Record } +const compact = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(compact) + if (value === null || typeof value !== 'object') return value + const out: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (!isCompactedErrorKey(value, key)) out[key] = compact(entry) + } + return out +} + +/** + * {@link serializeError}, without what makes a failed AI SDK retry too large to log: `responseHeaders` + * on every error, and `lastError` on an error whose `errors` already holds it. + * + * `otelAttributesFormat` does this to OpenTelemetry output by default. Pass this as `createLogger`'s + * `errorSerializer` to compact every transport's output, the console included. + */ +export const compactErrorSerializer: ErrorSerializer = (error) => compact(serializeError(error)) as Record + /** * Builds a JSON replacer that substitutes `Error` instances with the output of the supplied * `serializer` (defaults to {@link serializeError}). The `serializableErrorReplacer` export is From e24d75127ad2ae50fdbb00c89a2465f2d2d01c80 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 14:56:37 +0800 Subject: [PATCH 02/13] feat!: replace flatten, flattenReplacer and mapAuditLevelForOtel with otel createLogger throws when a removed option is passed. The level: 'audit' guard now applies only to the logger-level otel placement. --- src/callback-transport.spec.ts | 8 +- src/callback-transport.ts | 10 +-- src/index.spec.ts | 135 +++++++++++++++++++++++++++------ src/index.ts | 97 +++++++++++------------ 4 files changed, 167 insertions(+), 83 deletions(-) diff --git a/src/callback-transport.spec.ts b/src/callback-transport.spec.ts index 14fa16f..8c80c82 100644 --- a/src/callback-transport.spec.ts +++ b/src/callback-transport.spec.ts @@ -122,7 +122,7 @@ describe('CallbackTransport', () => { ), ) - // Simulate `mapAuditLevelForOtel`: LEVEL symbol rewritten from 'audit' to 'info', string + // Simulate the `otel` audit mapping: LEVEL symbol rewritten from 'audit' to 'info', string // `level` left untouched. winston-transport's stock filter would drop this; ours must not. const audit = { [LEVEL]: 'info', level: 'audit', message: 'audit-event' } as TransformableInfo @@ -139,12 +139,12 @@ describe('CallbackTransport', () => { () => {}, { level: 'audit' }, ) - // We intercept `level` in our constructor so `createLogger`'s mapAuditLevelForOtel guard + // We intercept `level` in our constructor so `createLogger`'s `otel` audit guard // (which reads `transport.level`) does not trip when the transport is configured for audit. expect(transport.level).toBeUndefined() }) - it('routes audit records through createLogger with mapAuditLevelForOtel and level: audit', async () => { + it('routes audit records through createLogger with otel: true and level: audit', async () => { const { createLogger } = await import('./index') const received: TransformedInfo[] = [] const transport = new CallbackTransport( @@ -159,7 +159,7 @@ describe('CallbackTransport', () => { // (createLogger guard) or silently drop the record (LEVEL rewrite + symbol-based filter). const logger = createLogger({ consoleOptions: { silent: true }, - mapAuditLevelForOtel: true, + otel: true, transports: [transport], }) diff --git a/src/callback-transport.ts b/src/callback-transport.ts index b841791..268ed2a 100644 --- a/src/callback-transport.ts +++ b/src/callback-transport.ts @@ -24,15 +24,15 @@ export type LogHandler = (info: TransformedInfo) => Promise * * Unlike the stock winston-transport `level` filter — which compares the triple-beam `LEVEL` * symbol — this transport filters on the string `info.level` instead. That makes the `level` - * option safe to combine with `mapAuditLevelForOtel`, which rewrites the `LEVEL` symbol but - * leaves `info.level` alone. The option is intercepted in the constructor (not forwarded to - * the base class), so `createLogger`'s `mapAuditLevelForOtel` + `level: 'audit'` guard does not - * trip on it. + * option safe to combine with `createLogger({ otel: true })`, whose audit mapping rewrites the + * `LEVEL` symbol but leaves `info.level` alone. The option is intercepted in the constructor (not + * forwarded to the base class), so `createLogger`'s `otel` + `level: 'audit'` guard does not trip + * on it. * * @example * ```ts * const logger = createLogger({ - * mapAuditLevelForOtel: true, + * otel: true, * transports: [ * new CallbackTransport( * ({ level, message, meta }) => sendToAuditEndpoint({ level, message, ...meta }), diff --git a/src/index.spec.ts b/src/index.spec.ts index 417381e..ff79dfc 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -2,7 +2,14 @@ import { LEVEL, SPLAT } from 'triple-beam' import { config, format } from 'winston' import TransportStream from 'winston-transport' import { describe, expect, it } from 'vitest' -import { createLogger, defaultLevelColors, defaultLevels, winstonDefaultLevelColors, winstonDefaultLevels } from './index' +import { + createLogger, + defaultLevelColors, + defaultLevels, + otelAttributesFormat, + winstonDefaultLevelColors, + winstonDefaultLevels, +} from './index' describe('logger', () => { it('verbose logs are screened out when level is debug', () => { @@ -161,13 +168,13 @@ describe('createLogger Date metadata', () => { expect(JSON.stringify(parameters)).toBe('["2026-01-02T03:04:05.000Z",1]') }) - it('flatten writes a Date in metadata as its ISO string', () => { + it('otel writes a Date in metadata as its ISO string, unquoted', () => { const transport = new InMemoryTransport({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], flatten: true }) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true }) logger.info('db query', { parameters: [when, 1], at: when }) const info = transport.logs[0] as Record expect(info.parameters).toBe('["2026-01-02T03:04:05.000Z",1]') - expect(info.at).toBe('"2026-01-02T03:04:05.000Z"') + expect(info.at).toBe('2026-01-02T03:04:05.000Z') }) it('a Date survives redaction beside a redacted key', () => { @@ -304,13 +311,13 @@ describe('createLogger child loggers', () => { }) }) -describe('createLogger flatten', () => { +describe('createLogger otel', () => { it('stringifies object and array values and leaves scalars alone', () => { const transport = new InMemoryTransport({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], - flatten: true, + otel: true, }) logger.info('hello', { obj: { a: 1 }, arr: [1, 2], count: 3, flag: true, text: 'literal' }) const info = transport.logs[0] as Record @@ -321,19 +328,32 @@ describe('createLogger flatten', () => { expect(info.text).toBe('literal') }) - it('forwards flattenReplacer to JSON.stringify for each value', () => { + it('keeps an error an object holding what the transport records as the exception', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true }) + logger.error('failed', { error: Object.assign(new Error('boom'), { statusCode: 500 }) }) + const info = transport.logs[0] as Record + expect(info.error).toMatchObject({ name: 'Error', message: 'boom' }) + expect(JSON.parse(info.errorDetail as string)).toEqual({ statusCode: 500 }) + }) + + it('passes its options to the format, replacer included', () => { const transport = new InMemoryTransport({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], - flatten: true, - flattenReplacer: (key, value) => (key === 'secret' ? '' : value), + otel: { replacer: (key, value: unknown) => (key === 'secret' ? '' : value) }, }) logger.info('hello', { data: { secret: 'abc', other: 'def' } }) const { data } = transport.logs[0] as { data: string } - const parsed = JSON.parse(data) as { secret: string; other: string } - expect(parsed.secret).toBe('') - expect(parsed.other).toBe('def') + expect(JSON.parse(data)).toEqual({ secret: '', other: 'def' }) + }) + + it("redacts requestBodyValues with the logger's redactedValue", () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true, redactedValue: '***' }) + logger.error('failed', { error: Object.assign(new Error('boom'), { requestBodyValues: { prompt: 'secret' } }) }) + expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ requestBodyValues: '***' }) }) it('runs after loggerOptions.format so injected object values are stringified', () => { @@ -345,21 +365,28 @@ describe('createLogger flatten', () => { const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], - flatten: true, + otel: true, loggerOptions: { format: injectFormat() }, }) logger.info('hello') expect((transport.logs[0] as { injected: unknown }).injected).toBe('{"a":1}') }) + + it('leaves records nested without it', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) + logger.info('hello', { obj: { a: 1 } }) + expect(transport.logs[0].obj).toEqual({ a: 1 }) + }) }) -describe('createLogger mapAuditLevelForOtel', () => { +describe('createLogger otel audit level', () => { it('rewrites the LEVEL symbol from audit to info, preserves the original on logLevel, and leaves the string level alone', () => { const transport = new InMemoryTransport({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], - mapAuditLevelForOtel: true, + otel: true, }) logger.audit('audit-event', { actor: 'user-1' }) const info = transport.logs[0] as Record @@ -374,7 +401,7 @@ describe('createLogger mapAuditLevelForOtel', () => { const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], - mapAuditLevelForOtel: true, + otel: true, }) logger.warn('heads-up') const info = transport.logs[0] as Record @@ -383,7 +410,7 @@ describe('createLogger mapAuditLevelForOtel', () => { expect(info.logLevel).toBe('warn') }) - it('is opt-in — when disabled, audit records keep their level and no logLevel is added', () => { + it('without otel, audit records keep their level and no logLevel is added', () => { const transport = new InMemoryTransport({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) logger.audit('audit-event') @@ -393,10 +420,30 @@ describe('createLogger mapAuditLevelForOtel', () => { expect(info.logLevel).toBeUndefined() }) + it('with mapAuditLevel: false, audit records keep their LEVEL', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: { mapAuditLevel: false } }) + logger.audit('audit-event') + const info = transport.logs[0] as Record + expect(info[LEVEL]).toBe('audit') + expect(info.logLevel).toBeUndefined() + }) + + it('runs after loggerOptions.format, which still sees LEVEL audit', () => { + const seen: unknown[] = [] + const spy = format((info) => { + seen.push(info[LEVEL]) + return info + }) + const logger = createLogger({ consoleOptions: { silent: true }, otel: true, loggerOptions: { format: spy() } }) + logger.audit('audit-event') + expect(seen).toEqual(['audit']) + }) + it('throws when loggerOptions.level is audit (records would be silently dropped after rewrite)', () => { expect(() => createLogger({ - mapAuditLevelForOtel: true, + otel: true, consoleOptions: { silent: true }, loggerOptions: { level: 'audit' }, }), @@ -406,7 +453,7 @@ describe('createLogger mapAuditLevelForOtel', () => { it('throws when consoleOptions.level is audit', () => { expect(() => createLogger({ - mapAuditLevelForOtel: true, + otel: true, consoleOptions: { level: 'audit', silent: true }, }), ).toThrow(/consoleOptions\.level/) @@ -416,12 +463,55 @@ describe('createLogger mapAuditLevelForOtel', () => { const transport = new InMemoryTransport({ level: 'audit' }) expect(() => createLogger({ - mapAuditLevelForOtel: true, + otel: true, consoleOptions: { silent: true }, transports: [transport], }), ).toThrow(/transports\[0\]\.level/) }) + + it('does not throw for level audit with mapAuditLevel: false', () => { + expect(() => + createLogger({ otel: { mapAuditLevel: false }, consoleOptions: { silent: true }, loggerOptions: { level: 'audit' } }), + ).not.toThrow() + }) + + it('keeps audit records on an audit-level transport when the format is on the transport, and leaves siblings at audit', () => { + const otelTransport = new InMemoryTransport({ level: 'audit', format: otelAttributesFormat() }) + const sibling = new InMemoryTransport({ level: 'audit' }) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [otelTransport, sibling] }) + logger.audit('audit-event') + logger.info('info-event') + expect(otelTransport.logs.map((info) => info.message)).toEqual(['audit-event']) + expect((otelTransport.logs[0] as Record)[LEVEL]).toBe('info') + expect(otelTransport.logs[0].logLevel).toBe('audit') + expect(sibling.logs.map((info) => info.message)).toEqual(['audit-event']) + expect((sibling.logs[0] as Record)[LEVEL]).toBe('audit') + expect(sibling.logs[0].logLevel).toBeUndefined() + }) +}) + +describe('createLogger removed 2.x options', () => { + it.each([ + ['flatten', true], + ['flatten', false], + ['flattenReplacer', () => undefined], + ['mapAuditLevelForOtel', true], + ])('throws for %s, pointing to the migration guide', (key, value) => { + const options = { consoleOptions: { silent: true }, [key]: value } + expect(() => createLogger(options)).toThrow(/Migrating from v2/) + }) + + it('rejects them at compile time in an object literal', () => { + expect(() => + // @ts-expect-error removed in 3.0 + createLogger({ consoleOptions: { silent: true }, flatten: 'otel' }), + ).toThrow(/flatten/) + expect(() => + // @ts-expect-error removed in 3.0 + createLogger({ consoleOptions: { silent: true }, mapAuditLevelForOtel: true }), + ).toThrow(/mapAuditLevelForOtel/) + }) }) describe('createLogger error as the whole record', () => { @@ -671,15 +761,14 @@ describe('createLogger DOMException', () => { transports: [transport], omitPaths: ['service'], redactPaths: ['authorization'], - mapAuditLevelForOtel: true, - flatten: true, + otel: true, }) logger.error('everything on', { service: 'svc', authorization: 'Bearer abc', error: aborted() }) expect(transport.logs[0].service).toBeUndefined() expect(transport.logs[0].authorization).toBe('') - expect(transport.logs[0].error).toContain('TimeoutError') + expect(transport.logs[0].error).toMatchObject({ name: 'TimeoutError', message: 'the operation timed out' }) }) it('serialises one with a configured errorSerializer, wherever it sits in the record', () => { diff --git a/src/index.ts b/src/index.ts index 38a92df..8d82aae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,15 +8,13 @@ import { transports, } from 'winston' import * as Transport from 'winston-transport' -import { jsonStringifyValuesFormat } from './json-stringify-values-format' -import { otelAttributesFormat } from './otel-attributes-format' +import { otelAttributesFormat, OtelAttributesFormatOptions } from './otel-attributes-format' import { omitFormat } from './omit-format' import { omitNilFormat } from './omit-nil-format' import { prettyConsoleFormat } from './pretty-console-format' import { redactFormat } from './redact-format' import { createSerializableErrorReplacer, ErrorSerializer, serializeError } from './serialize-error' import { serializeErrorFormat } from './serialize-error-format' -import { mapAuditLevelForOtel } from './map-audit-level-for-otel' import { withNormalizedErrorArgs } from './normalize-error-args' // `winston/lib/winston/transports` is a CJS deep import that can't be consumed from ESM: it's a @@ -155,37 +153,20 @@ export interface CreateLoggerOptions { redactedValue?: string /** - * Appends a flattening format as the final logger-level format — applied after every other - * logger-level format (including any `loggerOptions.format`), so it affects every transport. + * Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, by appending + * {@link otelAttributesFormat} as the last logger-level format. `true` uses the format's + * defaults; an object is passed to it as its options, with `redactedValue` defaulting to this + * logger's. * - * - `'otel'` appends {@link otelAttributesFormat}: the shape `@opentelemetry/winston-transport` 0.29 - * and later needs. An `err`/`error` value stays an object holding `name`, `message` and `stack`, - * which the transport records as the exception, the rest of the error goes to `Detail` as - * JSON, and every other object value becomes a JSON string. - * - `true` appends `jsonStringifyValuesFormat`, serialising every top-level object value — errors - * included — to a JSON string. For a transport before 0.29; from 0.29 the transport records a - * stringified error as one `exception.message` holding the whole JSON. + * Use this when the transport is added for you, as `WinstonInstrumentation` does. It changes what + * every transport receives, the console included. When you construct the OpenTelemetry transport + * yourself, put `otelAttributesFormat()` on that transport instead and leave this unset. * - * To flatten only what one transport receives, put the format on that transport instead. + * The format exports an `audit` record at `info` severity. At the logger level that happens + * before every transport's level filter, so `createLogger` throws if the logger, the console or a + * transport is set to `level: 'audit'`, unless `mapAuditLevel` is `false`. */ - flatten?: boolean | 'otel' - - /** - * Optional `JSON.stringify` replacer forwarded to `flatten` when serialising each value. - * Applied in both `flatten` modes. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - flattenReplacer?: (this: any, key: string, value: any) => any - - /** - * When `true`, prepends `mapAuditLevelForOtel` to the logger-level format chain. Rewrites the - * triple-beam `LEVEL` symbol from `audit` to `info` so OTEL maps the record onto a known severity - * tier, and copies the original level onto a `logLevel` property so it survives as an OTEL attribute - * (the OTEL winston-transport strips the string `level`). Enable when shipping logs via OTEL — most - * visibly for Azure Monitor / Log Analytics, but applies to any OTEL backend that relies on the - * spec-defined severity. - */ - mapAuditLevelForOtel?: boolean + otel?: boolean | OtelAttributesFormatOptions /** * Custom serializer used whenever an `Error` instance is encountered: by the logger-level @@ -201,11 +182,28 @@ export interface CreateLoggerOptions { * Winston `LoggerOptions` forwarded to the underlying logger (e.g. `level`, `defaultMeta`). * `transports` is managed by this library; pass extras via `transports` instead. * A `format` provided here is appended after the library's logger-level formats, but still runs - * before `flatten` (which is always last when enabled). + * before `otel` (which is always last when enabled). */ loggerOptions?: Omit } +/** 2.x options with no 3.0 meaning. Each maps onto `otel`; see "Migrating from v2" in the README. */ +const REMOVED_OPTIONS = ['flatten', 'flattenReplacer', 'mapAuditLevelForOtel'] as const + +/** + * Throws for an option 3.0 removed. TypeScript rejects one in an object literal, but not in options + * built from config or spread from another object, and ignoring it would quietly send OpenTelemetry + * records nested again. + */ +const rejectRemovedOptions = (options: CreateLoggerOptions) => { + const given = REMOVED_OPTIONS.filter((key) => (options as Record)[key] !== undefined) + if (given.length === 0) return + throw new Error( + `createLogger no longer accepts ${given.join(', ')}. Use the \`otel\` option, or put otelAttributesFormat() ` + + `on the OpenTelemetry transport. See "Migrating from v2" in the @makerx/node-winston README.`, + ) +} + export const createConsoleTransport = ({ consoleFormat = 'json', consoleOptions, @@ -237,15 +235,11 @@ export function createLogger>( export function createLogger(options: CreateLoggerOptions): Logger // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createLogger(options: CreateLoggerOptions): any { - const { - omitPaths, - redactPaths, - redactedValue = '', - flatten, - flattenReplacer, - errorSerializer, - mapAuditLevelForOtel: mapAuditForOtel, - } = options + const { omitPaths, redactPaths, redactedValue = '', errorSerializer, otel } = options + + rejectRemovedOptions(options) + + const otelOptions: OtelAttributesFormatOptions | undefined = otel === true ? {} : otel || undefined // register default colours on first use of a known level set so `colorize` / pretty output works. // Detected by referential equality: an unknown `levels` map means the caller is responsible for @@ -254,9 +248,11 @@ export function createLogger(options: CreateLoggerOptions): any { if (!userLevels || userLevels === defaultLevels) registerDefaultColors() else if (userLevels === winstonDefaultLevels) registerWinstonDefaultColors() - // Guard against silent audit-record loss: the format rewrites the LEVEL symbol from 'audit' to 'info', - // which the filter on any transport set to level:'audit' would then drop (info is more verbose than audit). - if (mapAuditForOtel) { + // Guard against silent audit-record loss: at the logger level the format rewrites the LEVEL symbol + // from 'audit' to 'info' before any transport filters, so a filter set to level:'audit' would then + // drop the record (info is more verbose than audit). On a transport's own format the filter runs + // first, so only this placement needs the guard. + if (otelOptions && otelOptions.mapAuditLevel !== false) { const misconfigured: string[] = [] if (options.loggerOptions?.level === 'audit') misconfigured.push('loggerOptions.level') if (options.consoleOptions?.level === 'audit') misconfigured.push('consoleOptions.level') @@ -266,9 +262,10 @@ export function createLogger(options: CreateLoggerOptions): any { if (misconfigured.length > 0) { throw new Error( `Set ${misconfigured.join(', ')} to 'info' (or a more verbose level) instead of 'audit'. ` + - `mapAuditLevelForOtel rewrites the triple-beam LEVEL symbol from 'audit' to 'info' so ` + - `OTEL maps the record onto a known severity tier — an 'audit'-level filter would then ` + - `drop those records after the rewrite.`, + `With \`otel\` set, every record's triple-beam LEVEL is mapped from 'audit' to 'info' before ` + + `any transport filters it, so an 'audit'-level filter would drop audit records. To keep an ` + + `'audit'-level transport, put otelAttributesFormat() on the OpenTelemetry transport instead ` + + `of setting \`otel\`, or pass otel: { mapAuditLevel: false }.`, ) } } @@ -280,7 +277,6 @@ export function createLogger(options: CreateLoggerOptions): any { // logger-level formats apply to all transports const loggerFormats: Format[] = [serializeErrorFormat({ serializer: errorSerializer })] - if (mapAuditForOtel) loggerFormats.push(mapAuditLevelForOtel()) if (omitPaths) loggerFormats.push(omitFormat({ paths: omitPaths })) // `errorSerializer` is passed for consistency rather than for effect: `serializeErrorFormat` runs // first and leaves no live `Error` behind, so redaction has nothing left to substitute here. It @@ -288,9 +284,8 @@ export function createLogger(options: CreateLoggerOptions): any { // path ever does let one through. if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue, errorSerializer })) if (options.loggerOptions?.format) loggerFormats.push(options.loggerOptions.format) - // flatten is applied last so all prior transformations are captured in the stringified values - if (flatten === 'otel') loggerFormats.push(otelAttributesFormat({ replacer: flattenReplacer })) - else if (flatten) loggerFormats.push(jsonStringifyValuesFormat({ replacer: flattenReplacer })) + // otel is applied last so all prior transformations are captured in the stringified values + if (otelOptions) loggerFormats.push(otelAttributesFormat({ redactedValue, ...otelOptions })) // create logger options using supplied options + transports; loggerOptions.levels (if any) overrides defaultLevels const loggerOptions: LoggerOptions = { From bdd4bdcd554ce747950a8a4a7488bf508b2201f0 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 14:56:37 +0800 Subject: [PATCH 03/13] build!: require @opentelemetry/winston-transport >=0.29 as an optional peer, release 3.0.0 The real-transport spec runs on 0.29 and the latest line under npm aliases. Lint forbids OpenTelemetry imports in library source. PR CI also runs for the v2.x branch. --- .github/workflows/pr.yml | 2 +- eslint.config.mjs | 20 ++++ package-lock.json | 77 +++++---------- package.json | 13 ++- scripts/smoke-cjs.cjs | 17 ++++ src/otel-transport.spec.ts | 198 ++++++++++++++++++++++++++----------- 6 files changed, 212 insertions(+), 115 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index af07b92..57296f1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,7 +2,7 @@ name: Pull Request on: pull_request: - branches: [main] + branches: [main, v2.x] paths-ignore: - '**/*.md' diff --git a/eslint.config.mjs b/eslint.config.mjs index 4351651..73173e9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,4 +34,24 @@ export default [ }, }, }, + { + // The OpenTelemetry transport is an optional peer. Importing it, or `@opentelemetry/api-logs`, from + // the library would make it required, and a second `api-logs` copy in an app sends every log + // line to a no-op. Specs drive the real transport under npm aliases instead. + files: ['src/**/*.ts'], + ignores: ['src/**/*.spec.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@opentelemetry/*', 'otel-winston-transport-*'], + message: 'The library must not depend on OpenTelemetry packages. See the comment in eslint.config.mjs.', + }, + ], + }, + ], + }, + }, ] diff --git a/package-lock.json b/package-lock.json index 2fe4e6c..33f33ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makerx/node-winston", - "version": "2.3.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makerx/node-winston", - "version": "2.3.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -24,7 +24,6 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.223.0", "@opentelemetry/sdk-logs": "^0.223.0", - "@opentelemetry/winston-transport": "^0.34.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-typescript": "^12.3.0", "@tsconfig/node20": "20.1.9", @@ -40,8 +39,8 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.5.5", "npm-run-all": "^4.1.5", - "otel-winston-transport-0.27": "npm:@opentelemetry/winston-transport@^0.27.0", - "otel-winston-transport-0.31": "npm:@opentelemetry/winston-transport@^0.31.0", + "otel-winston-transport-0.29": "npm:@opentelemetry/winston-transport@~0.29.0", + "otel-winston-transport-latest": "npm:@opentelemetry/winston-transport@^0.34.0", "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", @@ -53,11 +52,17 @@ "node": ">=20" }, "peerDependencies": { + "@opentelemetry/winston-transport": ">=0.29", "es-toolkit": ">=1", "logform": ">=2", "triple-beam": ">=1", "winston": ">=3", "winston-transport": ">=4" + }, + "peerDependenciesMeta": { + "@opentelemetry/winston-transport": { + "optional": true + } } }, "node_modules/@andrewbranch/untar.js": { @@ -968,7 +973,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -978,7 +983,7 @@ "version": "0.223.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.223.0.tgz", "integrity": "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -1043,27 +1048,12 @@ "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/winston-transport": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.34.0.tgz", - "integrity": "sha512-0bfxiBP9KhPd1rKT4hf2ANrEqNW2QaN9N87rDBUzDJB14kztugWNT+1q0seix04PlbzqxAc0jJpOBmoyoY4E/Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.223.0", - "@opentelemetry/semantic-conventions": "^1.41.1", - "winston-transport": "4.*" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, "node_modules/@oxc-project/types": { "version": "0.154.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", @@ -6000,25 +5990,25 @@ "node": ">= 0.8.0" } }, - "node_modules/otel-winston-transport-0.27": { + "node_modules/otel-winston-transport-0.29": { "name": "@opentelemetry/winston-transport", - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.27.0.tgz", - "integrity": "sha512-Imeoqm9CqxSJRo6INwtOn1Kx2XXYZzkyrH6q2teIDJ7ZypKx7DRf23l5ylQZ3vYibkcCLIANbzpeMqCNPp4CEw==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.29.0.tgz", + "integrity": "sha512-/VX3xYkWZIFWLasxqPSj/3JFu8Ea8zP22zFsiiSlM8cHQ9ciqmbuX8k2N2CTTezBknteCMeOxt255s59RN3Wkg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.217.0", + "@opentelemetry/api-logs": "^0.219.0", "winston-transport": "4.*" }, "engines": { "node": "^18.19.0 || >=20.6.0" } }, - "node_modules/otel-winston-transport-0.27/node_modules/@opentelemetry/api-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", - "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", + "node_modules/otel-winston-transport-0.29/node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6028,15 +6018,15 @@ "node": ">=8.0.0" } }, - "node_modules/otel-winston-transport-0.31": { + "node_modules/otel-winston-transport-latest": { "name": "@opentelemetry/winston-transport", - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.31.0.tgz", - "integrity": "sha512-higyMoGHbDTNlCX5ue1dJjDQuYSWwC0L4k6x57RKLG0uXdCIdyIu/OQYnjciL+7Kv2XmPMy4VQloeh36NTJTuQ==", + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.34.0.tgz", + "integrity": "sha512-0bfxiBP9KhPd1rKT4hf2ANrEqNW2QaN9N87rDBUzDJB14kztugWNT+1q0seix04PlbzqxAc0jJpOBmoyoY4E/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "^0.221.0", + "@opentelemetry/api-logs": "^0.223.0", "@opentelemetry/semantic-conventions": "^1.41.1", "winston-transport": "4.*" }, @@ -6044,19 +6034,6 @@ "node": "^18.19.0 || >=20.6.0" } }, - "node_modules/otel-winston-transport-0.31/node_modules/@opentelemetry/api-logs": { - "version": "0.221.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.221.0.tgz", - "integrity": "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", diff --git a/package.json b/package.json index 94c5111..abf3460 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/node-winston", - "version": "2.3.0", + "version": "3.0.0", "private": false, "description": "A set of winston formats, console transport and logger creation functions", "author": "MakerX", @@ -50,7 +50,6 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.223.0", "@opentelemetry/sdk-logs": "^0.223.0", - "@opentelemetry/winston-transport": "^0.34.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-typescript": "^12.3.0", "@tsconfig/node20": "20.1.9", @@ -66,8 +65,8 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.5.5", "npm-run-all": "^4.1.5", - "otel-winston-transport-0.27": "npm:@opentelemetry/winston-transport@^0.27.0", - "otel-winston-transport-0.31": "npm:@opentelemetry/winston-transport@^0.31.0", + "otel-winston-transport-0.29": "npm:@opentelemetry/winston-transport@~0.29.0", + "otel-winston-transport-latest": "npm:@opentelemetry/winston-transport@^0.34.0", "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", @@ -76,12 +75,18 @@ "vitest": "^4.1.12" }, "peerDependencies": { + "@opentelemetry/winston-transport": ">=0.29", "es-toolkit": ">=1", "logform": ">=2", "triple-beam": ">=1", "winston": ">=3", "winston-transport": ">=4" }, + "peerDependenciesMeta": { + "@opentelemetry/winston-transport": { + "optional": true + } + }, "dependencies": { "@colors/colors": "^1.6.0", "yamlify-object": "^3.0.0", diff --git a/scripts/smoke-cjs.cjs b/scripts/smoke-cjs.cjs index 86f8911..372060f 100644 --- a/scripts/smoke-cjs.cjs +++ b/scripts/smoke-cjs.cjs @@ -13,12 +13,16 @@ const expectedFns = [ 'createSerializableErrorReplacer', 'serializableErrorReplacer', 'serializeErrorFormat', + 'compactErrorSerializer', 'createLogger', + 'otelAttributesFormat', + 'mapAuditLevelForOtel', 'jsonStringifyValuesFormat', 'redactFormat', 'omitFormat', 'omitNilFormat', 'prettyConsoleFormat', + 'CallbackTransport', ] const missing = expectedFns.filter((name) => typeof main[name] !== 'function') if (missing.length > 0) { @@ -26,6 +30,16 @@ if (missing.length > 0) { process.exit(1) } +if (!Array.isArray(main.defaultOtelRedactKeys)) { + console.error('CJS smoke: defaultOtelRedactKeys is not an array:', main.defaultOtelRedactKeys) + process.exit(1) +} + +if (distPkg.peerDependenciesMeta?.['@opentelemetry/winston-transport']?.optional !== true) { + console.error('CJS smoke: dist/package.json does not mark @opentelemetry/winston-transport as an optional peer') + process.exit(1) +} + const err = new Error('boom') err.cause = new Error('cause') const out = main.serializeError(err) @@ -37,4 +51,7 @@ if (!out || out.message !== 'boom' || !out.cause || out.cause.message !== 'cause const logger = main.createLogger({ name: 'smoke', consoleOptions: { silent: true } }) logger.info('cjs smoke ok') +const otelLogger = main.createLogger({ otel: true, consoleOptions: { silent: true } }) +otelLogger.audit('cjs smoke otel ok', { error: err }) + console.log('CJS smoke test passed') diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index 4c65e61..de8f8c6 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -2,21 +2,21 @@ * What a record looks like once it is an OpenTelemetry log record, through the real * `@opentelemetry/winston-transport` and the logs SDK — the row a backend stores. * - * Three transport lines, installed side by side under npm aliases: - * - 0.27: no exception handling; every attribute is passed through as it is. - * - 0.31: lifts `err`/`error` off the record as the exception (from 0.29). - * - 0.34: also normalises object attributes into nested maps, errors included (from 0.32). + * The supported transport line, installed side by side under npm aliases: + * - 0.29: the oldest release that lifts `err`/`error` off the record as the exception. + * - latest: also normalises object attributes into nested maps (from 0.32), and takes + * `severityMapping` and `logSeverity` options (from 0.34). * * Each transport binds its logger when it is constructed, so the provider is registered first. */ -import { logs } from '@opentelemetry/api-logs' +import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { InMemoryLogRecordExporter, LoggerProvider, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs' -import { OpenTelemetryTransportV3 as Transport034 } from '@opentelemetry/winston-transport' -import { OpenTelemetryTransportV3 as Transport027 } from 'otel-winston-transport-0.27' -import { OpenTelemetryTransportV3 as Transport031 } from 'otel-winston-transport-0.31' +import { OpenTelemetryTransportV3 as Transport029 } from 'otel-winston-transport-0.29' +import { OpenTelemetryTransportV3 as TransportLatest } from 'otel-winston-transport-latest' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type TransportStream from 'winston-transport' -import { CallbackTransport, createLogger, type CreateLoggerOptions } from './index' +import { CallbackTransport, createLogger, type CreateLoggerOptions, type Logger } from './index' +import { jsonStringifyValuesFormat } from './json-stringify-values-format' import { otelAttributesFormat } from './otel-attributes-format' type TransportClass = new (options?: TransportStream.TransportStreamOptions) => TransportStream @@ -42,65 +42,61 @@ class ProviderError extends Error { const failure = () => new ProviderError('Request too large', { cause: new Error('body exceeds limit') }) const at = new Date('2026-01-02T03:04:05.000Z') +/** An AI SDK `APICallError`: the prompt and the response headers are own properties. */ +const apiCallError = (attempt: number) => + Object.assign(new Error(`attempt ${attempt} failed`), { + name: 'AI_APICallError', + statusCode: 429, + requestBodyValues: { messages: [{ role: 'user', content: 'the whole prompt' }] }, + responseHeaders: { 'x-request-id': `req-${attempt}` }, + }) + +/** An AI SDK `RetryError`: every attempt under `errors`, and the last once more under `lastError`. */ +const retryError = () => { + const errors = [apiCallError(1), apiCallError(2)] + return Object.assign(new Error('Failed after 2 attempts'), { name: 'AI_RetryError', errors, lastError: errors[1] }) +} + let sequence = 0 -/** Logs one error line through a fresh logger on `Transport` and returns its exported attributes. */ -async function exported(Transport: TransportClass, options: Partial = {}, transport?: TransportStream) { +/** Logs one line through a fresh logger and returns the log records it exported. */ +async function emitted(options: Partial, log: (logger: Logger, message: string) => void) { const message = `line ${++sequence}` - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport ?? new Transport()], ...options }) - logger.error(message, { error: failure(), at, request: { route: '/chat', attempt: 2 } }) + const logger = createLogger({ consoleOptions: { silent: true }, ...options }) + log(logger, message) await new Promise((resolve) => setImmediate(resolve)) - const records = exporter.getFinishedLogRecords().filter((record) => record.body === message) + return exporter.getFinishedLogRecords().filter((record) => record.body === message) +} + +/** Logs one error line through a fresh logger on `Transport` and returns its exported attributes. */ +async function exported(Transport: TransportClass, options: Partial = {}, transport?: TransportStream) { + const records = await emitted({ transports: [transport ?? new Transport()], ...options }, (logger, message) => + logger.error(message, { error: failure(), at, request: { route: '/chat', attempt: 2 } }), + ) expect(records).toHaveLength(1) return records[0].attributes as Record } const isScalar = (value: unknown) => value === null || ['string', 'number', 'boolean'].includes(typeof value) -describe('flatten: true', () => { - it('0.27 writes the error as one JSON attribute holding its message and status', async () => { - const attributes = await exported(Transport027, { flatten: true }) - expect(JSON.parse(attributes.error as string)).toMatchObject({ message: 'Request too large', statusCode: 413 }) - expect(attributes).not.toHaveProperty('exception.message') - }) - - it.each([ - ['0.31', Transport031], - ['0.34', Transport034], - ])( - '%s records the whole error JSON as exception.message, which is why flatten: true is for transports before 0.29', - async (_, Transport) => { - const attributes = await exported(Transport, { flatten: true }) - expect(String(attributes['exception.message']).startsWith('{')).toBe(true) - expect(attributes).not.toHaveProperty('exception.type') - }, - ) -}) +const lines: [string, TransportClass][] = [ + ['0.29', Transport029], + ['latest', TransportLatest], +] -describe('no flatten', () => { - it('0.34 records the exception but drops the rest of the error', async () => { - const attributes = await exported(Transport034) - expect(attributes['exception.message']).toBe('Request too large') - expect(JSON.stringify(attributes)).not.toContain('413') - }) -}) - -describe.each([ - ['0.31', Transport031], - ['0.34', Transport034], -])("flatten: 'otel' on %s", (_, Transport) => { +describe.each(lines)('otel: true on %s', (_, Transport) => { it('records the error as exception.type, exception.message and exception.stacktrace', async () => { - const attributes = await exported(Transport, { flatten: 'otel' }) + const attributes = await exported(Transport, { otel: true }) expect(attributes).toMatchObject({ 'exception.type': 'ProviderError', 'exception.message': 'Request too large' }) expect(String(attributes['exception.stacktrace'])).toContain('Request too large') }) it("records the error's cause as error.cause.message", async () => { - expect((await exported(Transport, { flatten: 'otel' }))['error.cause.message']).toBe('body exceeds limit') + expect((await exported(Transport, { otel: true }))['error.cause.message']).toBe('body exceeds limit') }) it("keeps the error's status, provider data and cause chain under errorDetail", async () => { - const attributes = await exported(Transport, { flatten: 'otel' }) + const attributes = await exported(Transport, { otel: true }) expect(JSON.parse(attributes.errorDetail as string)).toMatchObject({ statusCode: 413, data: { error: { code: 'request_too_large' } }, @@ -109,36 +105,118 @@ describe.each([ }) it('writes every attribute as a scalar, none of them [object Object]', async () => { - const attributes = await exported(Transport, { flatten: 'otel' }) + const attributes = await exported(Transport, { otel: true }) expect(Object.values(attributes).every(isScalar)).toBe(true) expect(Object.values(attributes)).not.toContain('[object Object]') }) it('writes other objects as JSON strings and a Date as its ISO string', async () => { - const attributes = await exported(Transport, { flatten: 'otel' }) + const attributes = await exported(Transport, { otel: true }) expect(attributes.request).toBe('{"route":"/chat","attempt":2}') expect(attributes.at).toBe('2026-01-02T03:04:05.000Z') }) + + it('exports an audit record at INFO severity, with logLevel audit', async () => { + const records = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => logger.audit(message)) + expect(records).toHaveLength(1) + expect(records[0].severityNumber).toBe(SeverityNumber.INFO) + expect(records[0].attributes.logLevel).toBe('audit') + }) + + it('redacts the prompt and compacts a failed retry in errorDetail', async () => { + const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => + logger.error(message, { error: retryError() }), + ) + expect(record.attributes['exception.type']).toBe('AI_RetryError') + const detail = JSON.parse(record.attributes.errorDetail as string) as { errors: Record[] } + expect(detail).not.toHaveProperty('lastError') + expect(detail.errors).toHaveLength(2) + expect(detail.errors.map((attempt) => attempt.requestBodyValues)).toEqual(['', '']) + expect(detail.errors.some((attempt) => 'responseHeaders' in attempt)).toBe(false) + expect(JSON.stringify(record.attributes)).not.toContain('the whole prompt') + }) + + it('caps errorDetail at 8,192 characters', async () => { + const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => + logger.error(message, { error: Object.assign(new Error('big'), { body: 'x'.repeat(20_000) }) }), + ) + const detail = record.attributes.errorDetail as string + expect(detail).toHaveLength(8192) + expect(detail.endsWith('[Truncated]')).toBe(true) + }) + + // Upstream behaviour, pinned so a change to it is noticed: with no `err` or `error` on the record, + // the transport takes the first splat argument that looks like an exception — any object with a + // `name`, `code`, `message` or `stack` — as the record's exception. + it('records a metadata object with a name as the exception when there is no err or error', async () => { + const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => + logger.info(message, { name: 'Alice' }), + ) + expect(record.attributes['exception.type']).toBe('Alice') + expect(record.attributes.name).toBe('Alice') + }) }) -describe('otelAttributesFormat on the transport', () => { +describe.each(lines)('otelAttributesFormat on the transport on %s', (_, Transport) => { it('flattens what the OpenTelemetry transport receives and leaves other transports nested', async () => { const seen: Record[] = [] const sibling = new CallbackTransport( async ({ meta }) => void seen.push(meta), () => undefined, ) - const message = `line ${++sequence}` - const logger = createLogger({ - consoleOptions: { silent: true }, - transports: [new Transport034({ format: otelAttributesFormat() }), sibling], - }) - logger.error(message, { error: failure(), request: { route: '/chat' } }) - await new Promise((resolve) => setImmediate(resolve)) - - const [record] = exporter.getFinishedLogRecords().filter((r) => r.body === message) + const [record] = await emitted({ transports: [new Transport({ format: otelAttributesFormat() }), sibling] }, (logger, message) => + logger.error(message, { error: failure(), request: { route: '/chat' } }), + ) expect(record.attributes).toMatchObject({ 'exception.type': 'ProviderError', request: '{"route":"/chat"}' }) expect(seen[0].request).toEqual({ route: '/chat' }) expect(seen[0].error).toMatchObject({ statusCode: 413 }) }) + + it('exports audit records at INFO severity from a transport set to level audit', async () => { + const transport = new Transport({ level: 'audit', format: otelAttributesFormat() }) + const records = await emitted({ transports: [transport] }, (logger, message) => { + logger.audit(message) + logger.info(message) + }) + expect(records).toHaveLength(1) + expect(records[0].severityNumber).toBe(SeverityNumber.INFO) + expect(records[0].attributes.logLevel).toBe('audit') + }) + + it('records the whole error JSON as exception.message with jsonStringifyValuesFormat, which is why it is not for 0.29 and later', async () => { + const attributes = await exported(Transport, {}, new Transport({ format: jsonStringifyValuesFormat() })) + expect(String(attributes['exception.message']).startsWith('{')).toBe(true) + expect(attributes).not.toHaveProperty('exception.type') + }) +}) + +describe('without otel on latest', () => { + it('records the exception but drops the rest of the error', async () => { + const attributes = await exported(TransportLatest) + expect(attributes['exception.message']).toBe('Request too large') + expect(JSON.stringify(attributes)).not.toContain('413') + }) +}) + +describe('transport severity options on latest', () => { + it('severityMapping keeps severityText audit when the format leaves the level alone', async () => { + const transport = new TransportLatest({ + format: otelAttributesFormat({ mapAuditLevel: false }), + severityMapping: { audit: SeverityNumber.INFO }, + }) + const [record] = await emitted({ transports: [transport] }, (logger, message) => logger.audit(message)) + expect(record.severityNumber).toBe(SeverityNumber.INFO) + expect(record.severityText).toBe('audit') + expect(record.attributes).not.toHaveProperty('logLevel') + }) + + it('logSeverity drops an audit record nothing maps', async () => { + const transport = new TransportLatest({ format: otelAttributesFormat({ mapAuditLevel: false }), logSeverity: SeverityNumber.INFO }) + expect(await emitted({ transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(0) + }) + + it('logSeverity keeps an audit record the format maps', async () => { + const transport = new TransportLatest({ format: otelAttributesFormat(), logSeverity: SeverityNumber.INFO }) + expect(await emitted({ transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(1) + }) }) From 9bc263a989227f98e0ce70d0cbfb469d11fcc853 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 14:56:37 +0800 Subject: [PATCH 04/13] docs: document the otel option and add Migrating from v2 --- README.md | 211 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 151 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 3e7f526..0ff3d44 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ npm install @makerx/node-winston `winston`, `logform`, `winston-transport`, `triple-beam` and `es-toolkit` are peer dependencies so the package shares a single instance with your app's `winston`. npm 7+ and pnpm install them automatically; on Yarn Classic, install them yourself. +`@opentelemetry/winston-transport` `>=0.29` is an optional peer dependency. Install it only when you ship logs through OpenTelemetry: see [OpenTelemetry](#opentelemetry). + Requires Node.js `>=20`. -### Upgrading from v1 +### Upgrading -See [Migrating from v1](#migrating-from-v1) at the bottom for breaking changes and new functionality. +See [Migrating from v2](#migrating-from-v2) and [Migrating from v1](#migrating-from-v1) at the bottom for breaking changes and new functionality. ## Creating a Logger @@ -22,25 +24,23 @@ See [Migrating from v1](#migrating-from-v1) at the bottom for breaking changes a Formats are applied in two layers: -- **Logger-level** (applied to all transports, in order): `serializeErrorFormat` → `mapAuditLevelForOtel` (if enabled) → `omitFormat` (if `omitPaths`) → `redactFormat` (if `redactPaths`) → `loggerOptions.format` (if supplied) → `otelAttributesFormat` (if `flatten: 'otel'`) or `jsonStringifyValuesFormat` (if `flatten: true`); always last so it captures every prior transformation. +- **Logger-level** (applied to all transports, in order): `serializeErrorFormat` → `omitFormat` (if `omitPaths`) → `redactFormat` (if `redactPaths`) → `loggerOptions.format` (if supplied) → `otelAttributesFormat` (if `otel`); always last so it captures every prior transformation. - **Console transport** (applied to Console output only): `omitNilFormat` → any `consoleFormats` → either `prettyConsoleFormat` (when `consoleFormat: 'pretty'`) or `timestamp` + `json` (when `consoleFormat: 'json'`). ### Options -| Option | Type | Description | -| ---------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | -| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | -| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | -| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | -| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | -| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. Applied at the logger level, so affects all transports. | -| `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | -| `flatten` | `boolean \| 'otel'` | Flattens every record as the last logger-level format. `'otel'` produces the attribute shape `@opentelemetry/winston-transport` 0.29 and later needs; `true` serialises every object value to a JSON string, for a transport before 0.29. See [OpenTelemetry](#opentelemetry). | -| `flattenReplacer` | `(key, value) => any` | Optional `JSON.stringify` replacer used by either `flatten` mode for every value it serialises. | -| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. | -| `mapAuditLevelForOtel` | `boolean` | When `true`, rewrites the triple-beam `LEVEL` from `audit` to `info` and copies the original onto `logLevel` for OTEL compatibility. See [Shipping the audit level via OpenTelemetry](#shipping-the-audit-level-via-opentelemetry). | -| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `flatten` when enabled. | +| Option | Type | Description | +| ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | +| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | +| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | +| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | +| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | +| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. Applied at the logger level, so affects all transports. | +| `redactedValue` | `string` | Replacement value used by `redactPaths`, and by `otel` redaction unless `otel.redactedValue` is set. Defaults to `''`. | +| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`; an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport; otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | +| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Pass `compactErrorSerializer` to leave out what makes a failed AI SDK retry too large to log. | +| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `otel` when enabled. | ### Log levels @@ -50,6 +50,8 @@ Formats are applied in two layers: { error: 0, warn: 1, audit: 2, info: 3, debug: 4, verbose: 5 } ``` +`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, so it adds no delivery or retention guarantee: route it with a transport if it needs one (see [`CallbackTransport`](#callbacktransport)). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so the OTel output exports an `audit` record at `info` severity with `logLevel: 'audit'` (see [Audit severity](#audit-severity)). In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. + Colours for the default levels (including `audit`) are registered on the first `createLogger` call that uses them, so `colorize` / pretty output works out of the box. Pass your own `levels` via `loggerOptions` to override. When you do, the returned logger is typed against your level keys (so `logger.audit` is only present when the default levels are in use), and you should register colours for your levels via `winston.addColors`: @@ -222,28 +224,57 @@ const logger = createLogger({ In-flight dispatches are tracked internally and drained via `Promise.allSettled` on `close()`, and any rejection from the handler is routed to the supplied `logError` so a failing endpoint can't recurse back through this transport. -`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option remains accurate when paired with `mapAuditLevelForOtel` (which rewrites the symbol but leaves the string untouched). For the same reason it sidesteps the `mapAuditLevelForOtel` + `level: 'audit'` guard in `createLogger` — the option is intercepted by the transport rather than passed down to the base `winston-transport` class. +`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option remains accurate when paired with `createLogger({ otel: true })`, whose audit mapping rewrites the symbol but leaves the string untouched. For the same reason it sidesteps the `otel` + `level: 'audit'` guard in `createLogger` — the option is intercepted by the transport rather than passed down to the base `winston-transport` class. ## OpenTelemetry -[`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) forwards each winston record through the OpenTelemetry Logs SDK. Add it yourself, or let [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) add it to every logger. Two `createLogger` options shape the records it receives: `flatten`, chosen by transport line below, and `mapAuditLevelForOtel: true` when using the default level set (which includes `audit`). +[`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) forwards each winston record through the OpenTelemetry Logs SDK. Add it yourself, or let [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) add it to every logger. It is an optional peer dependency of this library, and version 0.29 or later is required. -### Flattening for OpenTelemetry +`otelAttributesFormat` shapes each record into the attributes the transport and the logs SDK need. Most backends, Azure Log Analytics included, index only scalar attributes, so a nested value has to arrive as a JSON string to be queryable. -Most backends — Azure Log Analytics included — index only scalar attributes, so a nested value needs to arrive as a JSON string to be queryable. What else the record needs depends on the transport line: +### Where to apply it -| `@opentelemetry/winston-transport` | Matching SDK line | What the transport does | Use | -| ---------------------------------- | ----------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------- | -| before 0.29 | ≤ 0.218 | Passes every value through as it is | `flatten: true`, or `jsonStringifyValuesFormat` | -| 0.29 – 0.31 | 0.219 – 0.221 | Records an `err`/`error` value as the record's exception; passes every other value through | `flatten: 'otel'`, or `otelAttributesFormat` | -| 0.32 and later | 0.222 and later | As 0.29, and normalises object values into nested map attributes | `flatten: 'otel'`, or `otelAttributesFormat` | +When you construct the transport yourself, put the format on that transport. The console and every other transport keep their nested output: -From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception, and the SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. Two things go wrong without help: +```ts +import { OpenTelemetryTransportV3 } from '@opentelemetry/winston-transport' +import { createLogger, otelAttributesFormat } from '@makerx/node-winston' -- **`flatten: true`** turns the error into a JSON string first, and the SDK records the whole string as `exception.message`, with no `exception.type` or stack trace. -- **No flattening** records the exception, but loses the rest of the error — an HTTP status, a provider's error body, the cause chain — and other object values arrive as nested maps. +const logger = createLogger({ + transports: [new OpenTelemetryTransportV3({ format: otelAttributesFormat() })], +}) +``` -`flatten: 'otel'` keeps the error an object with only the fields the SDK reads, moves the rest of it to `errorDetail` as JSON, and serialises every other object value to a JSON string. Scalars are left alone, and a `Date` becomes its ISO string. A provider error logged as `logger.error('Chat failed', { error, request: { route: '/chat' } })` is exported as: +`@opentelemetry/instrumentation-winston` constructs its transport without a format, so with it use the `otel` option. It applies the format at the logger level, which changes what every transport receives, the console included: + +```ts +const logger = createLogger({ otel: true }) +``` + +`otel` also takes the format's [options](#options-1), for example `otel: { redactKeys: [...defaultOtelRedactKeys, 'prompt'] }`. + +### Supported transport lines + +| `@opentelemetry/winston-transport` | Matching SDK line | What the transport does | +| ---------------------------------- | ----------------- | ------------------------------------------------------------------------------------------ | +| 0.29 – 0.31 | 0.219 – 0.221 | Records an `err`/`error` value as the record's exception; passes every other value through | +| 0.32 – 0.33 | 0.222 – 0.223 | As 0.29, and normalises object values into nested map attributes | +| 0.34 and later | 0.223 and later | As 0.32, and takes `severityMapping` and `logSeverity` options | + +A transport before 0.29 is not supported: see [Migrating from v2](#migrating-from-v2). + +The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, the transport binds to a logger provider nobody registers, and every log line goes nowhere. + +### What the format does + +From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception. The SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. The format works with that: + +- The error stays an object with only the fields the SDK reads. The rest of it, such as an HTTP status, a provider's error body or the cause chain, goes to `errorDetail` as JSON. +- Every other object value becomes a JSON string. Scalars are left alone, and a `Date` becomes its ISO string. +- Every record gets `logLevel`, and an `audit` record is exported at `info` severity. See [Audit severity](#audit-severity). +- `requestBodyValues` is redacted, a failed AI SDK retry is compacted, and every string the format writes is capped at 8,192 characters. See [Errors from the AI SDK](#errors-from-the-ai-sdk). + +A provider error logged as `logger.error('Chat failed', { error, request: { route: '/chat' } })` is exported as: ```ts { @@ -254,63 +285,93 @@ From 0.29 the transport gives the logs SDK an `err`/`error` value as the excepti 'error.cause.message': 'body exceeds limit', errorDetail: '{"statusCode":413,"data":{"error":{"code":"request_too_large"}},"cause":{…}}', request: '{"route":"/chat"}', + logLevel: 'error', } ``` The SDK takes a truthy `code` over `name` as `exception.type`, so `code` goes to `errorDetail` unless the error has no `name`. -#### Where to apply it +Without the format, two things go wrong: + +- **`jsonStringifyValuesFormat`** turns the error into a JSON string first, and the SDK records the whole string as `exception.message`, with no `exception.type` or stack trace. +- **No format** records the exception, but loses the rest of the error, and other object values arrive as nested maps. + +### Options + +| Option | Default | Description | +| -------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | +| `redactKeys` | `defaultOtelRedactKeys` | Keys whose values are replaced with `redactedValue`, matched at every depth. The list replaces the default: spread `defaultOtelRedactKeys` to add a key, or pass `[]`. | +| `redactedValue` | `''` | Replacement value for `redactKeys`. With `otel`, defaults to the logger's `redactedValue`. | +| `compactErrors` | `true` | Leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` already holds it. | +| `maxAttributeLength` | `8192` | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. `Infinity` writes every string whole. | +| `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. Runs after redaction and compaction. | -`flatten` is a logger-level format, so it changes what every transport receives — the console included. When you construct the OpenTelemetry transport yourself, put the format on that transport instead and the console keeps its nested JSON: +`defaultOtelRedactKeys` is `['requestBodyValues']`. + +### Audit severity + +OpenTelemetry's log spec defines a fixed severity enumeration, so the transport has no severity for winston's `audit` level. Azure Monitor ignores a record without one, and a transport's `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity by rewriting the triple-beam `LEVEL` symbol. It also copies every record's level onto `logLevel`, so the original survives as an attribute. The string `level` is not changed, so console JSON still shows `"level": "audit"`. + +`LEVEL` also drives level filtering, so where the mapping runs matters: + +- **On a transport's own format**, it runs after that transport's level filter. A transport set to `level: 'audit'` keeps audit records, and other transports never see the rewrite. +- **With `otel`**, it runs before every transport's filter. A logger, console or transport set to `level: 'audit'` would then drop audit records, so `createLogger` throws for that combination. Use `'info'` there, or put the format on the transport instead. + +Under `otel`, pretty console output colours an `audit` line as `info`. + +From 0.34 the transport can map the level itself and keep `severityText: 'audit'`. Turn the format's mapping off and give the transport a `severityMapping`: ```ts -import { OpenTelemetryTransportV3 } from '@opentelemetry/winston-transport' -import { createLogger, otelAttributesFormat } from '@makerx/node-winston' +import { SeverityNumber } from '@opentelemetry/api-logs' -const logger = createLogger({ - mapAuditLevelForOtel: true, - transports: [new OpenTelemetryTransportV3({ format: otelAttributesFormat() })], +new OpenTelemetryTransportV3({ + format: otelAttributesFormat({ mapAuditLevel: false }), + severityMapping: { audit: SeverityNumber.INFO }, }) ``` -`@opentelemetry/instrumentation-winston` constructs its transport without a format, so with it the logger-level option is the only place to apply one: +`WinstonInstrumentation` takes the same `severityMapping` option: pair it with `otel: { mapAuditLevel: false }`. Do not turn the mapping off without a `severityMapping` when you set `logSeverity`, because an `audit` record with no severity is dropped. -```ts -const logger = createLogger({ flatten: 'otel', mapAuditLevelForOtel: true }) -``` +### Errors from the AI SDK -Pair either `flatten` mode with `flattenReplacer` to customise the `JSON.stringify` replacer for every value it serialises. +An AI SDK `APICallError` carries the whole request as `requestBodyValues`: the prompt and everything it was built from. A `RetryError` holds every attempt under `errors` and the last one again under `lastError`, each with its response headers. That overruns the 8,192-character limit of an App Insights custom dimension, which App Insights then cuts short so it no longer parses as JSON. -### Shipping the audit level via OpenTelemetry +So by default the format: -`@opentelemetry/winston-transport` derives OTEL's `severityText` / `severityNumber` from Winston's triple-beam `LEVEL` symbol and strips the string `level` property before building attributes. OTEL's log spec only defines a fixed severity enumeration (trace/debug/info/warn/error/fatal), so Winston's custom `audit` level arrives with `severityNumber: undefined` and no queryable record of the original level. This is most visible on Azure Monitor / Log Analytics (which ignores records without a mapped severity), but affects any OTEL backend that relies on the spec-defined severity. +- replaces `requestBodyValues` with `''`, at every depth +- leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` already holds it +- cuts any string it writes that is longer than 8,192 characters, ending it in `[Truncated]` so the cut is visible -Set `mapAuditLevelForOtel: true` to opt in: +These defaults apply to OpenTelemetry output only. To apply them to the console and every other transport as well: ```ts +import { compactErrorSerializer, createLogger } from '@makerx/node-winston' + const logger = createLogger({ - mapAuditLevelForOtel: true, + redactPaths: ['requestBodyValues'], + errorSerializer: compactErrorSerializer, }) ``` -The format rewrites the triple-beam `LEVEL` symbol from `audit` to `info` (so OTEL maps the record onto `info` severity) and copies the original level onto a `logLevel` property (so it survives as an OTEL attribute — e.g. queryable as `customDimensions.logLevel == "audit"` in Azure Log Analytics). The string `info.level` is left as `audit` for other transports — so local Console JSON output still shows `"level": "audit"`. +### Exceptions found in metadata -**Caveat:** the `LEVEL` symbol also drives transport-level filtering, so a logger, console, or transport explicitly set to `level: 'audit'` would silently drop these records after the rewrite (`info` is more verbose than `audit`). `createLogger` detects this combination and throws at construction — use `'info'` (or a more verbose level) instead. Only enable this option when shipping logs via OTEL — typically a deployed-environment concern. +With no `err` or `error` on a record, the transport takes the first metadata object that has a `name`, `code`, `message` or `stack` as the record's exception. So `logger.info('created', { name: 'Alice' })` is exported with `exception.type: 'Alice'`, and a backend may file it as an exception. This is the transport's behaviour, not the format's. Log such values under a more specific key, such as `userName`. ## Formats Every format used by `createLogger` is also exported for direct use with your own winston setup. -| Format | Purpose | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | -| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | -| `omitNilFormat` | Removes top-level `null` or `undefined` values. | -| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | -| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. Accepts an optional `replacer`. See [OpenTelemetry](#opentelemetry). | -| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. | -| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | -| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. | +| Format | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | +| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | +| `omitNilFormat` | Removes top-level `null` or `undefined` values. | +| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | +| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity, redacts `requestBodyValues`, compacts a failed AI SDK retry and caps long strings. See [OpenTelemetry](#opentelemetry). | +| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | +| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | +| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | `redactFormat` paths accept plain keys (`email`, matched at every level), dot-notation paths (`user.email`), and `[*]` array wildcards to iterate every element of an array segment — for example `files[*].name`, `users[*].addresses[*].zip`, or `tags[*]` to redact each element of a primitive array. @@ -391,6 +452,8 @@ const logger = createLogger({ }) ``` +`compactErrorSerializer` is a ready-made serializer: `serializeError` without `responseHeaders` on any error, and without `lastError` on an error whose `errors` already holds it. Pass it as `errorSerializer` to keep a failed AI SDK retry small on every transport. See [Errors from the AI SDK](#errors-from-the-ai-sdk). + #### `DOMException` An `AbortSignal` carries a `DOMException` as its `reason`, and an operation cancelled through one rejects with that reason. A `DOMException` is therefore what a `catch` block receives whenever a fetch, a stream or a job is abandoned on a deadline. It needs no special treatment: log it like any other error. @@ -416,6 +479,34 @@ const serializer = (error: Error) => ({ kind: error.name, detail: error.message format.combine(serializeErrorFormat({ serializer }), format.json({ replacer: createSerializableErrorReplacer(serializer) })) ``` +## Migrating from v2 + +v3 puts OpenTelemetry behind one option, `otel`, and supports `@opentelemetry/winston-transport` 0.29 and later only. 2.x gets fixes only, published under the `latest-2` dist-tag. + +Breaking changes: + +- **`flatten`, `flattenReplacer` and `mapAuditLevelForOtel` are removed from `createLogger`.** They are type errors, and `createLogger` throws if one is passed, so config-built options cannot silently fall back to nested output. Replace them as follows: + +| 2.x | 3.0 | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `flatten: 'otel', mapAuditLevelForOtel: true` | `otel: true` | +| `flattenReplacer: r` with `flatten: 'otel'` | `otel: { replacer: r }` | +| `otelAttributesFormat()` on the transport, plus `mapAuditLevelForOtel: true` | `otelAttributesFormat()` on the transport alone. A transport set to `level: 'audit'` is now allowed. | +| `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | +| An `errorSerializer` that drops `responseHeaders` and `lastError` | Nothing for OpenTelemetry output, which now does it by default. To keep it on the console too, use `errorSerializer: compactErrorSerializer`. | +| `redactPaths: ['requestBodyValues', …]` | Nothing for OpenTelemetry output, which now redacts it by default. **Keep it** in `redactPaths` if the console must not carry the prompt either. | + +- **`@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`.** npm 7 and later refuse to install alongside an older transport; pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. +- **The audit mapping runs last.** With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`; in 2.x it saw `info`. +- **OpenTelemetry output changes shape for anyone who logs these keys on purpose.** `requestBodyValues` is redacted, `responseHeaders` and a repeated `lastError` are left out of errors, and strings the format writes are capped at 8,192 characters. Each has an option to turn it off: see [Options](#options-1). +- **A `Date` under `otel` is its ISO string, unquoted.** `flatten: true` wrote it as a quoted JSON string. + +New functionality: + +- `otelAttributesFormat` takes `mapAuditLevel`, `redactKeys`, `redactedValue`, `compactErrors`, `maxAttributeLength` and `replacer` options. +- `compactErrorSerializer` compacts a failed AI SDK retry on every transport. +- `defaultOtelRedactKeys` lists the keys the format redacts by default. + ## Migrating from v1 Breaking changes: @@ -432,10 +523,10 @@ Breaking changes: New functionality: - New `redactPaths` / `redactedValue` options replace values at dot-notation paths with a placeholder (default `''`). Like `omitPaths` they apply at the logger level and affect every transport, so they're a drop-in replacement for any hand-rolled redaction you previously bolted onto `loggerOptions.format` or a custom transport. Also exported as the standalone `redactFormat`, plus the `redactValues` / `redactValuesWith` helpers for direct use. -- New `flatten` / `flattenReplacer` options serialise every top-level value on the log info to a JSON string, producing a flat `{ key: string }` shape suited to OTEL + Azure Log Analytics and other scalar-only aggregators. Also available as `jsonStringifyValuesFormat` and `jsonStringifyValues`. +- New `flatten` / `flattenReplacer` options serialise every top-level value on the log info to a JSON string, producing a flat `{ key: string }` shape suited to OTEL + Azure Log Analytics and other scalar-only aggregators. Also available as `jsonStringifyValuesFormat` and `jsonStringifyValues`. (The options were replaced by `otel` in v3.) - The new `serializeErrorFormat` is exported for direct use in your own format chains, and accepts a `serializer` override for swapping in custom error normalisation. - `createLogger` is now generic over the level map. When you pass `loggerOptions.levels`, the returned logger's method signatures narrow to your level keys (`logger.fatal(...)` becomes valid, `logger.audit` becomes a type error). - Colours for the default levels (including `audit`) are registered on first use of the default levels, so `colorize` / pretty output works out of the box without a module-load side effect. - `defaultLevels` and `defaultLevelColors` are exported directly if you want to extend or re-use them. - `winstonDefaultLevels` and `winstonDefaultLevelColors` re-export winston's stock `npm` level set (`error`/`warn`/`info`/`http`/`verbose`/`debug`/`silly`) so consumers can opt back into the standard winston levels via `loggerOptions.levels` — colours register automatically on first use. -- New `mapAuditLevelForOtel` option (and standalone format) rewrites the triple-beam `LEVEL` symbol from `audit` to `info` so OTEL maps the record onto a known severity tier, while preserving the original level on a `logLevel` property. See [Shipping the audit level via OpenTelemetry](#shipping-the-audit-level-via-opentelemetry). +- New `mapAuditLevelForOtel` option (and standalone format) rewrites the triple-beam `LEVEL` symbol from `audit` to `info` so OTEL maps the record onto a known severity tier, while preserving the original level on a `logLevel` property. (The option was replaced by `otel` in v3; see [Audit severity](#audit-severity).) From 0adb66d19262ba939201bbb944659ccfbc7fcc68 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 15:17:50 +0800 Subject: [PATCH 05/13] refactor!: replace AI SDK defaults with generic capabilities otelAttributesFormat no longer redacts requestBodyValues, compacts retries or caps strings by default: redactKeys, redactedValue, compactErrors, defaultOtelRedactKeys and compactErrorSerializer are removed. - createErrorSerializer({ omit, dedupe }) leaves properties out of every error, or writes a shared object once. serializeError is its default. - maxAttributeLength is off by default. - The README shows the AI SDK setup as an example built from redactPaths, createErrorSerializer and maxAttributeLength, and the transport spec runs that example on 0.29 and the latest line. --- README.md | 122 +++++++++++++------------ scripts/smoke-cjs.cjs | 7 +- src/compacted-error-keys.ts | 25 ------ src/index.spec.ts | 21 ++++- src/index.ts | 6 +- src/otel-attributes-format.spec.ts | 140 +++++++---------------------- src/otel-attributes-format.ts | 87 ++++-------------- src/otel-transport.spec.ts | 21 +++-- src/serialize-error.spec.ts | 109 +++++++++++++++------- src/serialize-error.ts | 125 ++++++++++++++++++-------- 10 files changed, 311 insertions(+), 352 deletions(-) delete mode 100644 src/compacted-error-keys.ts diff --git a/README.md b/README.md index 0ff3d44..2acfd98 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,18 @@ Formats are applied in two layers: ### Options -| Option | Type | Description | -| ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | -| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | -| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | -| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | -| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | -| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. Applied at the logger level, so affects all transports. | -| `redactedValue` | `string` | Replacement value used by `redactPaths`, and by `otel` redaction unless `otel.redactedValue` is set. Defaults to `''`. | -| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`; an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport; otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | -| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Pass `compactErrorSerializer` to leave out what makes a failed AI SDK retry too large to log. | -| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `otel` when enabled. | +| Option | Type | Description | +| ----------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | +| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | +| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | +| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | +| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | +| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. A plain key matches at every depth, inside serialized errors too. Applied at the logger level, so affects all transports. | +| `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | +| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`; an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport; otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | +| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Build one with `createErrorSerializer` to leave properties out of every error. | +| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `otel` when enabled. | ### Log levels @@ -251,7 +251,7 @@ const logger = createLogger({ const logger = createLogger({ otel: true }) ``` -`otel` also takes the format's [options](#options-1), for example `otel: { redactKeys: [...defaultOtelRedactKeys, 'prompt'] }`. +`otel` also takes the format's [options](#options-1), for example `otel: { maxAttributeLength: 8192 }`. ### Supported transport lines @@ -272,7 +272,7 @@ From 0.29 the transport gives the logs SDK an `err`/`error` value as the excepti - The error stays an object with only the fields the SDK reads. The rest of it, such as an HTTP status, a provider's error body or the cause chain, goes to `errorDetail` as JSON. - Every other object value becomes a JSON string. Scalars are left alone, and a `Date` becomes its ISO string. - Every record gets `logLevel`, and an `audit` record is exported at `info` severity. See [Audit severity](#audit-severity). -- `requestBodyValues` is redacted, a failed AI SDK retry is compacted, and every string the format writes is capped at 8,192 characters. See [Errors from the AI SDK](#errors-from-the-ai-sdk). +- Strings it writes can be capped at a backend's limit with `maxAttributeLength`. See [Keeping records small and private](#keeping-records-small-and-private). A provider error logged as `logger.error('Chat failed', { error, request: { route: '/chat' } })` is exported as: @@ -298,16 +298,11 @@ Without the format, two things go wrong: ### Options -| Option | Default | Description | -| -------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | -| `redactKeys` | `defaultOtelRedactKeys` | Keys whose values are replaced with `redactedValue`, matched at every depth. The list replaces the default: spread `defaultOtelRedactKeys` to add a key, or pass `[]`. | -| `redactedValue` | `''` | Replacement value for `redactKeys`. With `otel`, defaults to the logger's `redactedValue`. | -| `compactErrors` | `true` | Leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` already holds it. | -| `maxAttributeLength` | `8192` | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. `Infinity` writes every string whole. | -| `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. Runs after redaction and compaction. | - -`defaultOtelRedactKeys` is `['requestBodyValues']`. +| Option | Default | Description | +| -------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | +| `maxAttributeLength` | none | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. By default every string is written whole. Strings the record already held are never cut. | +| `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. It is called with the object holding the value as `this`, so it can leave a property out by its siblings. | ### Audit severity @@ -333,27 +328,34 @@ new OpenTelemetryTransportV3({ `WinstonInstrumentation` takes the same `severityMapping` option: pair it with `otel: { mapAuditLevel: false }`. Do not turn the mapping off without a `severityMapping` when you set `logSeverity`, because an `audit` record with no severity is dropped. -### Errors from the AI SDK +### Keeping records small and private + +The format writes what the record holds. Three generic options decide what that is, whatever library an error comes from: -An AI SDK `APICallError` carries the whole request as `requestBodyValues`: the prompt and everything it was built from. A `RetryError` holds every attempt under `errors` and the last one again under `lastError`, each with its response headers. That overruns the 8,192-character limit of an App Insights custom dimension, which App Insights then cuts short so it no longer parses as JSON. +- **`redactPaths`** replaces values on every record. A plain key matches at every depth, inside serialized errors too, so it reaches a property an error carries. +- **`createErrorSerializer`** builds an `errorSerializer` that leaves properties out of every error with `omit`, and with `dedupe` writes an object two properties share only once. See [Error serialization](#error-serialization). +- **`maxAttributeLength`** cuts any string the format writes at a backend's limit, and ends it in `[Truncated]` so the cut is visible. The logs SDK's own `attributeValueLengthLimit` cuts silently, which leaves a JSON value that no longer parses. -So by default the format: +`redactPaths` and `errorSerializer` shape what every transport receives, the console included. To shape only the OpenTelemetry output, give that transport its own chain, such as `format.combine(redactFormat({ paths }), otelAttributesFormat({ replacer }))`. -- replaces `requestBodyValues` with `''`, at every depth -- leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` already holds it -- cuts any string it writes that is longer than 8,192 characters, ending it in `[Truncated]` so the cut is visible +#### Example: AI SDK errors in App Insights -These defaults apply to OpenTelemetry output only. To apply them to the console and every other transport as well: +The Vercel AI SDK's `APICallError` carries the whole request body as `requestBodyValues`, the prompt included, and the provider's response as `responseHeaders`, `responseBody` and `data`. Its `RetryError` holds every attempt under `errors`, and the last attempt again under `lastError`. A failed retry can overrun the 8,192-character limit of an App Insights custom dimension, which App Insights then cuts short so it no longer parses as JSON. ```ts -import { compactErrorSerializer, createLogger } from '@makerx/node-winston' +import { createErrorSerializer, createLogger } from '@makerx/node-winston' const logger = createLogger({ + otel: { maxAttributeLength: 8192 }, redactPaths: ['requestBodyValues'], - errorSerializer: compactErrorSerializer, + errorSerializer: createErrorSerializer({ + omit: (key, error) => key === 'responseHeaders' || (key === 'lastError' && 'errors' in error), + }), }) ``` +When you construct the transport yourself, leave out `otel` and pass the cap to its format instead: `otelAttributesFormat({ maxAttributeLength: 8192 })`. `dedupe: true` is an alternative to the `lastError` rule: the last attempt is written in full under `errors`, and `lastError` as `'[Duplicate]'`. + ### Exceptions found in metadata With no `err` or `error` on a record, the transport takes the first metadata object that has a `name`, `code`, `message` or `stack` as the record's exception. So `logger.info('created', { name: 'Alice' })` is exported with `exception.type: 'Alice'`, and a backend may file it as an exception. This is the transport's behaviour, not the format's. Log such values under a more specific key, such as `userName`. @@ -362,16 +364,16 @@ With no `err` or `error` on a record, the transport takes the first metadata obj Every format used by `createLogger` is also exported for direct use with your own winston setup. -| Format | Purpose | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | -| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | -| `omitNilFormat` | Removes top-level `null` or `undefined` values. | -| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | -| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity, redacts `requestBodyValues`, compacts a failed AI SDK retry and caps long strings. See [OpenTelemetry](#opentelemetry). | -| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | -| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | -| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | +| Format | Purpose | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | +| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | +| `omitNilFormat` | Removes top-level `null` or `undefined` values. | +| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | +| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity. See [OpenTelemetry](#opentelemetry). | +| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | +| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | +| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | `redactFormat` paths accept plain keys (`email`, matched at every level), dot-notation paths (`user.email`), and `[*]` array wildcards to iterate every element of an array segment — for example `files[*].name`, `users[*].addresses[*].zip`, or `tags[*]` to redact each element of a primitive array. @@ -452,7 +454,18 @@ const logger = createLogger({ }) ``` -`compactErrorSerializer` is a ready-made serializer: `serializeError` without `responseHeaders` on any error, and without `lastError` on an error whose `errors` already holds it. Pass it as `errorSerializer` to keep a failed AI SDK retry small on every transport. See [Errors from the AI SDK](#errors-from-the-ai-sdk). +`createErrorSerializer` builds a serializer that works like `serializeError`, with two options: + +- **`omit`** leaves a property out of every error the serializer reaches: the error itself, its `cause`, each entry of its `errors`, and any error held in a property. It is given the property name and the error that holds it, so a rule can depend on the error's other properties. It is never asked about a plain object. +- **`dedupe`** writes an object two properties of one error share only once, and every later occurrence as `'[Duplicate]'`. The first property in key order keeps the object. + +```ts +import { createErrorSerializer, createLogger } from '@makerx/node-winston' + +const logger = createLogger({ + errorSerializer: createErrorSerializer({ omit: (key) => key === 'headers', dedupe: true }), +}) +``` #### `DOMException` @@ -487,25 +500,22 @@ Breaking changes: - **`flatten`, `flattenReplacer` and `mapAuditLevelForOtel` are removed from `createLogger`.** They are type errors, and `createLogger` throws if one is passed, so config-built options cannot silently fall back to nested output. Replace them as follows: -| 2.x | 3.0 | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `flatten: 'otel', mapAuditLevelForOtel: true` | `otel: true` | -| `flattenReplacer: r` with `flatten: 'otel'` | `otel: { replacer: r }` | -| `otelAttributesFormat()` on the transport, plus `mapAuditLevelForOtel: true` | `otelAttributesFormat()` on the transport alone. A transport set to `level: 'audit'` is now allowed. | -| `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | -| An `errorSerializer` that drops `responseHeaders` and `lastError` | Nothing for OpenTelemetry output, which now does it by default. To keep it on the console too, use `errorSerializer: compactErrorSerializer`. | -| `redactPaths: ['requestBodyValues', …]` | Nothing for OpenTelemetry output, which now redacts it by default. **Keep it** in `redactPaths` if the console must not carry the prompt either. | +| 2.x | 3.0 | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `flatten: 'otel', mapAuditLevelForOtel: true` | `otel: true` | +| `flattenReplacer: r` with `flatten: 'otel'` | `otel: { replacer: r }` | +| `otelAttributesFormat()` on the transport, plus `mapAuditLevelForOtel: true` | `otelAttributesFormat()` on the transport alone. A transport set to `level: 'audit'` is now allowed. | +| `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | +| A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | - **`@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`.** npm 7 and later refuse to install alongside an older transport; pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. - **The audit mapping runs last.** With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`; in 2.x it saw `info`. -- **OpenTelemetry output changes shape for anyone who logs these keys on purpose.** `requestBodyValues` is redacted, `responseHeaders` and a repeated `lastError` are left out of errors, and strings the format writes are capped at 8,192 characters. Each has an option to turn it off: see [Options](#options-1). - **A `Date` under `otel` is its ISO string, unquoted.** `flatten: true` wrote it as a quoted JSON string. New functionality: -- `otelAttributesFormat` takes `mapAuditLevel`, `redactKeys`, `redactedValue`, `compactErrors`, `maxAttributeLength` and `replacer` options. -- `compactErrorSerializer` compacts a failed AI SDK retry on every transport. -- `defaultOtelRedactKeys` lists the keys the format redacts by default. +- `otelAttributesFormat` takes `mapAuditLevel` and `maxAttributeLength` options, beside its `replacer`. +- `createErrorSerializer` builds an error serializer that can leave properties out of every error, or write a shared object once. ## Migrating from v1 diff --git a/scripts/smoke-cjs.cjs b/scripts/smoke-cjs.cjs index 372060f..848fa68 100644 --- a/scripts/smoke-cjs.cjs +++ b/scripts/smoke-cjs.cjs @@ -13,7 +13,7 @@ const expectedFns = [ 'createSerializableErrorReplacer', 'serializableErrorReplacer', 'serializeErrorFormat', - 'compactErrorSerializer', + 'createErrorSerializer', 'createLogger', 'otelAttributesFormat', 'mapAuditLevelForOtel', @@ -30,11 +30,6 @@ if (missing.length > 0) { process.exit(1) } -if (!Array.isArray(main.defaultOtelRedactKeys)) { - console.error('CJS smoke: defaultOtelRedactKeys is not an array:', main.defaultOtelRedactKeys) - process.exit(1) -} - if (distPkg.peerDependenciesMeta?.['@opentelemetry/winston-transport']?.optional !== true) { console.error('CJS smoke: dist/package.json does not mark @opentelemetry/winston-transport as an optional peer') process.exit(1) diff --git a/src/compacted-error-keys.ts b/src/compacted-error-keys.ts deleted file mode 100644 index d949ea6..0000000 --- a/src/compacted-error-keys.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Internal: deliberately not re-exported from index.ts. - -const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null - -/** An error as `serializeError` writes one, or a provider's own error object: it has a string `name` and `message`. */ -const isErrorShaped = (value: unknown): value is Record => - isObject(value) && typeof value.name === 'string' && typeof value.message === 'string' - -/** - * Whether `key` is left out when a serialized error is compacted. - * - * An AI SDK `RetryError` holds every attempt's `APICallError` under `errors`, and the last attempt - * once more under `lastError`. Each attempt carries its response headers. Together they overrun the - * 8,192-character limit of an App Insights custom dimension, so the dimension is cut off and no longer - * parses as JSON. `responseHeaders` is dropped from every error, and `lastError` from an error whose - * `errors` already holds it. - * - * Only an error is compacted: a caller's own `responseHeaders` on a plain object is kept. - */ -export const isCompactedErrorKey = (holder: unknown, key: string): boolean => { - if (key !== 'responseHeaders' && key !== 'lastError') return false - if (!isErrorShaped(holder)) return false - if (key === 'responseHeaders') return true - return Array.isArray(holder.errors) && holder.errors.length > 0 -} diff --git a/src/index.spec.ts b/src/index.spec.ts index ff79dfc..9241076 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3,6 +3,7 @@ import { config, format } from 'winston' import TransportStream from 'winston-transport' import { describe, expect, it } from 'vitest' import { + createErrorSerializer, createLogger, defaultLevelColors, defaultLevels, @@ -349,11 +350,23 @@ describe('createLogger otel', () => { expect(JSON.parse(data)).toEqual({ secret: '', other: 'def' }) }) - it("redacts requestBodyValues with the logger's redactedValue", () => { + it('redacts inside an error with redactPaths before flattening it', () => { const transport = new InMemoryTransport({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true, redactedValue: '***' }) - logger.error('failed', { error: Object.assign(new Error('boom'), { requestBodyValues: { prompt: 'secret' } }) }) - expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ requestBodyValues: '***' }) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true, redactPaths: ['body'] }) + logger.error('failed', { error: Object.assign(new Error('boom'), { body: { prompt: 'secret' }, statusCode: 500 }) }) + expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ body: '', statusCode: 500 }) + }) + + it('leaves out error properties with an errorSerializer from createErrorSerializer', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + otel: true, + errorSerializer: createErrorSerializer({ omit: (key) => key === 'headers' }), + }) + logger.error('failed', { error: Object.assign(new Error('boom'), { headers: { a: '1' }, statusCode: 500 }) }) + expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ statusCode: 500 }) }) it('runs after loggerOptions.format so injected object values are stringified', () => { diff --git a/src/index.ts b/src/index.ts index 8d82aae..9c8cecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,8 +155,7 @@ export interface CreateLoggerOptions { /** * Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, by appending * {@link otelAttributesFormat} as the last logger-level format. `true` uses the format's - * defaults; an object is passed to it as its options, with `redactedValue` defaulting to this - * logger's. + * defaults; an object is passed to it as its options. * * Use this when the transport is added for you, as `WinstonInstrumentation` does. It changes what * every transport receives, the console included. When you construct the OpenTelemetry transport @@ -175,6 +174,7 @@ export interface CreateLoggerOptions { * replacer (safety net for errors that slip through). Defaults to the library's * `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when * non-enumerable, walks own enumerable properties, and is safe against circular references. + * Build one with `createErrorSerializer` to leave properties out of every error. */ errorSerializer?: ErrorSerializer @@ -285,7 +285,7 @@ export function createLogger(options: CreateLoggerOptions): any { if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue, errorSerializer })) if (options.loggerOptions?.format) loggerFormats.push(options.loggerOptions.format) // otel is applied last so all prior transformations are captured in the stringified values - if (otelOptions) loggerFormats.push(otelAttributesFormat({ redactedValue, ...otelOptions })) + if (otelOptions) loggerFormats.push(otelAttributesFormat(otelOptions)) // create logger options using supplied options + transports; loggerOptions.levels (if any) overrides defaultLevels const loggerOptions: LoggerOptions = { diff --git a/src/otel-attributes-format.spec.ts b/src/otel-attributes-format.spec.ts index 4effc91..e787890 100644 --- a/src/otel-attributes-format.spec.ts +++ b/src/otel-attributes-format.spec.ts @@ -1,7 +1,7 @@ import { TransformableInfo } from 'logform' import { LEVEL } from 'triple-beam' import { describe, expect, it } from 'vitest' -import { defaultOtelRedactKeys, otelAttributesFormat, OtelAttributesFormatOptions } from './otel-attributes-format' +import { otelAttributesFormat, OtelAttributesFormatOptions } from './otel-attributes-format' const run = (info: Record, opts?: OtelAttributesFormatOptions) => { const input = { [LEVEL]: 'error', level: 'error', message: 'failed', ...info } as TransformableInfo @@ -125,134 +125,56 @@ describe('otelAttributesFormat — level', () => { }) }) -/** An AI SDK `APICallError` as `serializeError` writes it. */ -const apiCallError = (attempt: number) => ({ - name: 'AI_APICallError', - message: `attempt ${attempt} failed`, - stack: `AI_APICallError: attempt ${attempt} failed\n at x`, - statusCode: 429, - requestBodyValues: { messages: [{ role: 'user', content: 'the whole prompt' }] }, - responseHeaders: { 'retry-after': '1', 'x-request-id': `req-${attempt}` }, - responseBody: '{"error":"rate limited"}', -}) - -/** An AI SDK `RetryError`: every attempt under `errors`, and the last once more under `lastError`. */ -const retryError = () => ({ - name: 'AI_RetryError', - message: 'Failed after 2 attempts', - stack: 'AI_RetryError: Failed after 2 attempts\n at x', - reason: 'maxRetriesExceeded', - errors: [apiCallError(1), apiCallError(2)], - lastError: apiCallError(2), -}) - -describe('otelAttributesFormat — redaction', () => { - it('redacts requestBodyValues in errorDetail', () => { - const detail = JSON.parse(run({ error: apiCallError(1) }).errorDetail as string) as Record - expect(detail.requestBodyValues).toBe('') - expect(detail.statusCode).toBe(429) - }) - - it('redacts it inside every attempt of a retry', () => { - const detail = JSON.parse(run({ error: retryError() }).errorDetail as string) as { errors: Record[] } - expect(detail.errors.map((attempt) => attempt.requestBodyValues)).toEqual(['', '']) - }) - - it('redacts it at the top of the record and nested in other values', () => { - const result = run({ requestBodyValues: { prompt: 'p' }, ctx: { call: { requestBodyValues: 'p' } } }) - expect(result.requestBodyValues).toBe('') - expect(JSON.parse(result.ctx as string)).toEqual({ call: { requestBodyValues: '' } }) - }) - - it('leaves a nil value as it is', () => { - expect(JSON.parse(run({ ctx: { requestBodyValues: null } }).ctx as string)).toEqual({ requestBodyValues: null }) - }) - - it('redacts nothing with redactKeys: []', () => { - const detail = JSON.parse(run({ error: apiCallError(1) }, { redactKeys: [] }).errorDetail as string) as Record - expect(detail.requestBodyValues).toEqual(apiCallError(1).requestBodyValues) - }) - - it('redacts added keys beside the defaults, with a custom redactedValue', () => { - const result = run( - { error: serialized({ prompt: 'p', requestBodyValues: 'v' }) }, - { redactKeys: [...defaultOtelRedactKeys, 'prompt'], redactedValue: '***' }, - ) - expect(JSON.parse(result.errorDetail as string)).toEqual({ prompt: '***', requestBodyValues: '***' }) - }) +/** A serialized error holding other errors: a cause, a list of attempts, and the latest attempt again. */ +const withAttempts = () => { + const attempt = (n: number) => serialized({ message: `attempt ${n}`, headers: { 'x-request-id': `req-${n}` } }) + const attempts = [attempt(1), attempt(2)] + return serialized({ cause: serialized({ message: 'root' }), attempts, latest: attempts[1] }) +} - it('runs before the replacer, which sees the redacted value', () => { - const seen: unknown[] = [] - const replacer = (key: string, value: unknown) => { - if (key === 'requestBodyValues') seen.push(value) +describe('otelAttributesFormat — replacer', () => { + it('is called with the holding object as `this`, so it can leave a property out by its siblings', () => { + function replacer(this: Record, key: string, value: unknown) { + if (key === 'headers') return undefined + if (key === 'latest' && Array.isArray(this.attempts)) return undefined return value } - run({ ctx: { requestBodyValues: 'p' } }, { replacer }) - expect(seen).toEqual(['']) - }) -}) - -describe('otelAttributesFormat — retry compaction', () => { - it('drops responseHeaders from the error and from every attempt, and lastError beside errors', () => { - const result = run({ error: retryError() }) - const detail = JSON.parse(result.errorDetail as string) as { errors: Record[] } - expect(detail).not.toHaveProperty('lastError') - expect(detail.errors).toHaveLength(2) - expect(detail.errors.every((attempt) => !('responseHeaders' in attempt))).toBe(true) - expect(detail.errors[1].responseBody).toBe('{"error":"rate limited"}') - expect(result.error).toEqual({ name: 'AI_RetryError', message: 'Failed after 2 attempts', stack: retryError().stack }) - }) - - it('drops responseHeaders from an error logged on its own', () => { - expect(JSON.parse(run({ error: apiCallError(1) }).errorDetail as string)).not.toHaveProperty('responseHeaders') - }) - - it('keeps lastError when there is no errors list to hold it', () => { - const detail = JSON.parse(run({ error: serialized({ lastError: apiCallError(1), errors: [] }) }).errorDetail as string) - expect(detail.lastError).toMatchObject({ message: 'attempt 1 failed' }) - }) - - it("keeps a caller's own responseHeaders on a value that is not an error", () => { - expect(JSON.parse(run({ response: { status: 200, responseHeaders: { etag: 'x' } } }).response as string)).toEqual({ - status: 200, - responseHeaders: { etag: 'x' }, - }) - }) - - it('keeps everything with compactErrors: false', () => { - const detail = JSON.parse(run({ error: retryError() }, { compactErrors: false }).errorDetail as string) as Record - expect(detail).toHaveProperty('lastError') - expect((detail.errors as Record[])[0]).toHaveProperty('responseHeaders') + const detail = JSON.parse(run({ error: withAttempts() }, { replacer }).errorDetail as string) as Record + expect(detail).not.toHaveProperty('latest') + expect(detail.attempts).toEqual([ + { name: 'Error', message: 'attempt 1', stack: 'Error: boom\n at x' }, + { name: 'Error', message: 'attempt 2', stack: 'Error: boom\n at x' }, + ]) }) }) describe('otelAttributesFormat — length cap', () => { const long = 'x'.repeat(20_000) - it('cuts errorDetail to 8,192 characters, ending in [Truncated]', () => { - const detail = run({ error: serialized({ body: long }) }).errorDetail as string + it('writes every string whole by default', () => { + expect(JSON.parse(run({ ctx: { body: long } }).ctx as string)).toEqual({ body: long }) + expect(JSON.parse(run({ error: serialized({ body: long }) }).errorDetail as string)).toEqual({ body: long }) + }) + + it('cuts errorDetail to maxAttributeLength, ending in [Truncated]', () => { + const detail = run({ error: serialized({ body: long }) }, { maxAttributeLength: 8192 }).errorDetail as string expect(detail).toHaveLength(8192) expect(detail.endsWith('[Truncated]')).toBe(true) }) it('cuts other stringified values too', () => { - const value = run({ ctx: { body: long } }).ctx as string - expect(value).toHaveLength(8192) + const value = run({ ctx: { body: long } }, { maxAttributeLength: 100 }).ctx as string + expect(value).toHaveLength(100) expect(value.endsWith('[Truncated]')).toBe(true) }) it('leaves strings the caller wrote, the message and the exception whole', () => { - const result = run({ message: long, text: long, error: serialized({ message: long, stack: long }) }) + const result = run({ message: long, text: long, error: serialized({ message: long, stack: long }) }, { maxAttributeLength: 100 }) expect(result.message).toBe(long) expect(result.text).toBe(long) expect(result.error).toMatchObject({ message: long, stack: long }) }) - it('honours a custom maxAttributeLength, and Infinity writes every string whole', () => { - expect(run({ ctx: { body: long } }, { maxAttributeLength: 100 }).ctx).toHaveLength(100) - expect(JSON.parse(run({ ctx: { body: long } }, { maxAttributeLength: Infinity }).ctx as string)).toEqual({ body: long }) - }) - it('does not split a surrogate pair', () => { const value = run({ ctx: { body: '😀'.repeat(100) } }, { maxAttributeLength: 30 }).ctx as string expect(value.endsWith('[Truncated]')).toBe(true) @@ -262,8 +184,8 @@ describe('otelAttributesFormat — length cap', () => { describe('otelAttributesFormat — shared state', () => { it('changes no nested value, so a transport placement leaves its siblings their own objects', () => { - const error = retryError() - const ctx = { requestBodyValues: 'p', inner: { a: 1 } } + const error = withAttempts() + const ctx = { inner: { a: 1 }, list: [{ b: 2 }] } const before = structuredClone({ error, ctx }) run({ error, ctx }) expect({ error, ctx }).toEqual(before) @@ -271,7 +193,7 @@ describe('otelAttributesFormat — shared state', () => { it('applied twice writes the same record as applied once', () => { const fmt = otelAttributesFormat() - const input = { [LEVEL]: 'audit', level: 'audit', message: 'm', error: retryError(), ctx: { a: 1 } } as unknown as TransformableInfo + const input = { [LEVEL]: 'audit', level: 'audit', message: 'm', error: withAttempts(), ctx: { a: 1 } } as unknown as TransformableInfo const once = fmt.transform(structuredClone(input), fmt.options) as TransformableInfo const onceCopy = { ...once } const twice = fmt.transform(once, fmt.options) as TransformableInfo diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 1ee6d6e..90766b5 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -1,30 +1,18 @@ import { format } from 'winston' import { applyAuditLevelMapping } from './audit-level' -import { isCompactedErrorKey } from './compacted-error-keys' import { serializeError } from './serialize-error' import { serializesItself } from './serializes-itself' // eslint-disable-next-line @typescript-eslint/no-explicit-any type Replacer = (this: any, key: string, value: any) => any -/** - * Keys `otelAttributesFormat` redacts by default, wherever they appear in a record. - * - * `requestBodyValues` is the whole request an AI SDK `APICallError` carries as an own property: the - * prompt, and everything the prompt was built from. - */ -export const defaultOtelRedactKeys: readonly string[] = Object.freeze(['requestBodyValues']) - -/** The longest string attribute an App Insights custom dimension keeps. */ -const DEFAULT_MAX_ATTRIBUTE_LENGTH = 8192 - const TRUNCATED = '[Truncated]' export interface OtelAttributesFormatOptions { /** * `JSON.stringify` replacer applied to every value this format stringifies, `errorDetail` - * included. It runs after redaction and compaction, so it sees `''` in place of a - * redacted value. + * included. It is called with the object holding the value as `this`, so it can decide on a + * property by its siblings. Return `undefined` to leave the property out. */ replacer?: Replacer @@ -36,45 +24,27 @@ export interface OtelAttributesFormatOptions { mapAuditLevel?: boolean /** - * Keys whose values are replaced with `redactedValue`, matched at every depth. Defaults to - * {@link defaultOtelRedactKeys}. The list replaces the default: spread it to add a key, or pass `[]` - * to redact nothing. - */ - redactKeys?: readonly string[] - - /** Replacement value for `redactKeys`. Defaults to `''`. */ - redactedValue?: string - - /** - * Leaves `responseHeaders` out of every error, and `lastError` out of an error whose `errors` - * already holds it. Defaults to `true`. These are what make a failed AI SDK retry overrun an App - * Insights custom dimension. - */ - compactErrors?: boolean - - /** - * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`. - * Defaults to 8,192, the App Insights custom-dimension limit. Pass `Infinity` to write every - * string whole. Values that were already strings are never cut. + * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`, so + * a backend's own limit never cuts it silently. By default every string is written whole. Values + * that were already strings are never cut. + * + * @example App Insights keeps 8,192 characters of a custom dimension + * ```ts + * otelAttributesFormat({ maxAttributeLength: 8192 }) + * ``` */ maxAttributeLength?: number } interface Settings { replacer: Replacer | undefined - redactKeys: ReadonlySet - redactedValue: string - compactErrors: boolean maxAttributeLength: number mapAuditLevel: boolean } const resolveSettings = (opts: OtelAttributesFormatOptions): Settings => ({ replacer: opts.replacer, - redactKeys: new Set(opts.redactKeys ?? defaultOtelRedactKeys), - redactedValue: opts.redactedValue ?? '', - compactErrors: opts.compactErrors ?? true, - maxAttributeLength: opts.maxAttributeLength ?? DEFAULT_MAX_ATTRIBUTE_LENGTH, + maxAttributeLength: opts.maxAttributeLength ?? Infinity, mapAuditLevel: opts.mapAuditLevel ?? true, }) @@ -117,8 +87,6 @@ const isDescribableCause = (cause: unknown): boolean => typeof cause.code === 'string' || typeof cause.code === 'number')) -const isRedacted = (settings: Settings, key: string, value: unknown): boolean => value != null && settings.redactKeys.has(key) - /** Cuts a string to `max`, ending in `[Truncated]`, without splitting a surrogate pair. */ const cap = (text: string, max: number): string => { if (text.length <= max) return text @@ -128,20 +96,11 @@ const cap = (text: string, max: number): string => { return text.slice(0, end) + TRUNCATED } -/** - * Plain JSON for any value: compacted keys left out, redacted keys replaced, errors serialized, - * BigInts written as `n`, the result capped, and never a throw out of the log call. - * - * A JSON replacer is called with the holding object as `this`, so a `lastError` is judged against - * its own siblings without the value being cloned or changed. - */ +/** Plain JSON for any value: errors serialized, BigInts written as `n`, the result capped, and never a throw out of the log call. */ const stringify = (value: unknown, settings: Settings): string => { const { replacer } = settings const plain = function (this: unknown, key: string, entry: unknown): unknown { - let converted: unknown - if (key !== '' && settings.compactErrors && isCompactedErrorKey(this, key)) return undefined - if (key !== '' && isRedacted(settings, key, entry)) converted = settings.redactedValue - else converted = entry instanceof Error ? serializeError(entry) : typeof entry === 'bigint' ? `${entry}n` : entry + const converted = entry instanceof Error ? serializeError(entry) : typeof entry === 'bigint' ? `${entry}n` : entry return replacer ? (replacer.call(this, key, converted) as unknown) : converted } let json: string @@ -179,10 +138,8 @@ const splitException = (record: Record, key: string, error: Rec const exception: Record = {} const detail: Record = {} for (const [field, value] of Object.entries(error)) { - if (settings.compactErrors && isCompactedErrorKey(error, field)) continue - const written = isRedacted(settings, field, value) ? settings.redactedValue : value - if (EXCEPTION_FIELDS.has(field)) exception[field] = written - else detail[field] = written + if (EXCEPTION_FIELDS.has(field)) exception[field] = value + else detail[field] = value } if (isDescribableCause(error.cause)) exception.cause = error.cause if (exception.name == null && error.code != null) { @@ -211,13 +168,9 @@ const splitException = (record: Record, key: string, error: Rec * turn them into nested map attributes, which most backends — Azure Log Analytics included — do not * index. Scalars are left alone. A `Date` becomes its ISO string, unquoted. * - * By default it also: - * - exports an `audit` record at `info` severity, with every record's level on `logLevel` - * - redacts {@link defaultOtelRedactKeys} at every depth - * - leaves out what makes a failed AI SDK retry too large (`responseHeaders`, a repeated `lastError`) - * - caps every string it writes at 8,192 characters, ending in `[Truncated]` - * - * See {@link OtelAttributesFormatOptions} to change any of these. + * OpenTelemetry has no severity for winston's `audit` level, so by default the format exports an + * `audit` record at `info` severity, with every record's level on `logLevel`. See + * {@link OtelAttributesFormatOptions} to turn that off, cap the strings it writes, or pass a replacer. * * Put it on the OpenTelemetry transport to keep other transports' output nested, or use * `createLogger({ otel: true })` when the transport is added for you, as `WinstonInstrumentation` @@ -234,10 +187,6 @@ export const otelAttributesFormat = format((info, opts) => { const lifted = EXCEPTION_KEYS.find((key) => isExceptionCandidate(record[key])) for (const key of Object.keys(record)) { const value = record[key] - if (isRedacted(settings, key, value)) { - record[key] = settings.redactedValue - continue - } if (!isObject(value)) continue if (key === lifted) splitException(record, key, value instanceof Error ? serializeError(value) : value, settings) else record[key] = flatValue(value, settings) diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index de8f8c6..ebb427d 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -15,7 +15,7 @@ import { OpenTelemetryTransportV3 as Transport029 } from 'otel-winston-transport import { OpenTelemetryTransportV3 as TransportLatest } from 'otel-winston-transport-latest' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type TransportStream from 'winston-transport' -import { CallbackTransport, createLogger, type CreateLoggerOptions, type Logger } from './index' +import { CallbackTransport, createErrorSerializer, createLogger, type CreateLoggerOptions, type Logger } from './index' import { jsonStringifyValuesFormat } from './json-stringify-values-format' import { otelAttributesFormat } from './otel-attributes-format' @@ -123,10 +123,17 @@ describe.each(lines)('otel: true on %s', (_, Transport) => { expect(records[0].attributes.logLevel).toBe('audit') }) - it('redacts the prompt and compacts a failed retry in errorDetail', async () => { - const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => - logger.error(message, { error: retryError() }), - ) + // The README's recipe for AI SDK errors, built from generic options only. + it('redacts the prompt and trims a failed retry with redactPaths and createErrorSerializer', async () => { + const options: Partial = { + otel: { maxAttributeLength: 8192 }, + redactPaths: ['requestBodyValues'], + errorSerializer: createErrorSerializer({ + omit: (key, error) => key === 'responseHeaders' || (key === 'lastError' && 'errors' in error), + }), + transports: [new Transport()], + } + const [record] = await emitted(options, (logger, message) => logger.error(message, { error: retryError() })) expect(record.attributes['exception.type']).toBe('AI_RetryError') const detail = JSON.parse(record.attributes.errorDetail as string) as { errors: Record[] } expect(detail).not.toHaveProperty('lastError') @@ -136,8 +143,8 @@ describe.each(lines)('otel: true on %s', (_, Transport) => { expect(JSON.stringify(record.attributes)).not.toContain('the whole prompt') }) - it('caps errorDetail at 8,192 characters', async () => { - const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => + it('caps errorDetail at maxAttributeLength', async () => { + const [record] = await emitted({ otel: { maxAttributeLength: 8192 }, transports: [new Transport()] }, (logger, message) => logger.error(message, { error: Object.assign(new Error('big'), { body: 'x'.repeat(20_000) }) }), ) const detail = record.attributes.errorDetail as string diff --git a/src/serialize-error.spec.ts b/src/serialize-error.spec.ts index a72b8ff..dc4cb97 100644 --- a/src/serialize-error.spec.ts +++ b/src/serialize-error.spec.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' -import { compactErrorSerializer, createSerializableErrorReplacer, serializableErrorReplacer, serializeError } from './serialize-error' +import { createErrorSerializer, createSerializableErrorReplacer, serializableErrorReplacer, serializeError } from './serialize-error' describe('serializeError', () => { it('message and stack are not enumerable (and not serialized) by default', () => { @@ -212,57 +212,98 @@ describe('createSerializableErrorReplacer', () => { }) }) -describe('compactErrorSerializer', () => { - /** An AI SDK `APICallError`: its response headers are own properties. */ - const apiCallError = (attempt: number) => - Object.assign(new Error(`attempt ${attempt} failed`), { - name: 'AI_APICallError', - statusCode: 429, - responseHeaders: { 'x-request-id': `req-${attempt}` }, - responseBody: 'rate limited', - }) +describe('createErrorSerializer', () => { + /** An error with response headers as an own property, as an HTTP client's error might carry. */ + const httpError = (attempt: number) => + Object.assign(new Error(`attempt ${attempt} failed`), { statusCode: 429, headers: { 'x-request-id': `req-${attempt}` } }) - /** An AI SDK `RetryError`: every attempt under `errors`, and the last once more under `lastError`. */ + /** An error holding each attempt under `attempts`, and the latest one again under `latest`. */ const retryError = () => { - const errors = [apiCallError(1), apiCallError(2)] - return Object.assign(new Error('Failed after 2 attempts'), { name: 'AI_RetryError', errors, lastError: errors[1] }) + const attempts = [httpError(1), httpError(2)] + return Object.assign(new Error('failed after 2 attempts'), { attempts, latest: attempts[1] }) } - it('drops responseHeaders from every error and lastError beside errors', () => { - const out = compactErrorSerializer(retryError()) as { errors: Record[] } - expect(out).not.toHaveProperty('lastError') - expect(out.errors).toHaveLength(2) - expect(out.errors.every((attempt) => !('responseHeaders' in attempt))).toBe(true) - expect(out.errors[1]).toMatchObject({ name: 'AI_APICallError', statusCode: 429, responseBody: 'rate limited' }) + it('with no options, writes what serializeError writes', () => { + const error = Object.assign(new Error('plain'), { code: 'E_X', detail: { a: [1, 2] } }) + expect(createErrorSerializer()(error)).toEqual(serializeError(error)) }) - it('drops responseHeaders from a cause too', () => { - const out = compactErrorSerializer(new Error('wrapped', { cause: apiCallError(1) })) as { cause: Record } - expect(out.cause).toMatchObject({ statusCode: 429 }) - expect(out.cause).not.toHaveProperty('responseHeaders') + it('omit leaves a property out of the error and of every error inside it', () => { + const serializer = createErrorSerializer({ omit: (key) => key === 'headers' }) + const out = serializer(new Error('wrapped', { cause: retryError() })) as { cause: { attempts: object[]; latest: object } } + expect(out.cause.attempts).toHaveLength(2) + expect(out.cause.attempts.every((attempt) => !('headers' in attempt))).toBe(true) + expect(out.cause.latest).not.toHaveProperty('headers') + expect(out.cause.attempts[0]).toMatchObject({ message: 'attempt 1 failed', statusCode: 429 }) }) - it('keeps lastError when there is no errors list', () => { - const error = Object.assign(new Error('x'), { lastError: apiCallError(1) }) - expect(compactErrorSerializer(error)).toHaveProperty('lastError') + it('omit is given the error, so it can decide by its other properties', () => { + const serializer = createErrorSerializer({ omit: (key, error) => key === 'latest' && 'attempts' in error }) + expect(serializer(retryError())).not.toHaveProperty('latest') + expect(serializer(Object.assign(new Error('x'), { latest: httpError(1) }))).toHaveProperty('latest') }) - it('writes everything else as serializeError does', () => { - const error = Object.assign(new Error('plain'), { code: 'E_X', detail: { a: [1, 2] } }) - expect(compactErrorSerializer(error)).toEqual(serializeError(error)) + it('omit can leave out a well-known property', () => { + expect(createErrorSerializer({ omit: (key) => key === 'stack' })(new Error('x'))).not.toHaveProperty('stack') + }) + + it('omit is never asked about a plain object', () => { + const asked: string[] = [] + const serializer = createErrorSerializer({ + omit: (key) => { + asked.push(key) + return false + }, + }) + serializer(Object.assign(new Error('x'), { context: { headers: {} } })) + expect(asked).not.toContain('headers') + expect(asked).toContain('context') }) - it('compacts what a transport receives when passed to createLogger as errorSerializer', async () => { + it('omit that throws keeps the property', () => { + const serializer = createErrorSerializer({ + omit: () => { + throw new Error('bad predicate') + }, + }) + expect(serializer(new Error('x'))).toMatchObject({ name: 'Error', message: 'x' }) + }) + + it('dedupe writes a shared object once, and every later occurrence as [Duplicate]', () => { + const out = createErrorSerializer({ dedupe: true })(retryError()) as { attempts: object[]; latest: unknown } + expect(out.attempts[1]).toMatchObject({ message: 'attempt 2 failed' }) + expect(out.latest).toBe('[Duplicate]') + }) + + it('dedupe still writes a Date in full each time, and a cycle as [Circular]', () => { + const at = new Date('2026-01-02T03:04:05.000Z') + const error = Object.assign(new Error('x'), { first: at, second: at }) + Object.assign(error, { self: error }) + const out = createErrorSerializer({ dedupe: true })(error) + expect(out).toMatchObject({ first: '2026-01-02T03:04:05.000Z', second: '2026-01-02T03:04:05.000Z', self: '[Circular]' }) + }) + + it('without dedupe, writes a shared object every time', () => { + const out = createErrorSerializer()(retryError()) as { latest: unknown } + expect(out.latest).toMatchObject({ message: 'attempt 2 failed' }) + }) + + it('shapes what a transport receives when passed to createLogger as errorSerializer', async () => { const { CallbackTransport, createLogger } = await import('./index') const received: Record[] = [] const transport = new CallbackTransport( async ({ meta }) => void received.push(meta), () => undefined, ) - const logger = createLogger({ consoleOptions: { silent: true }, errorSerializer: compactErrorSerializer, transports: [transport] }) - logger.error('chat failed', { error: retryError() }) + const logger = createLogger({ + consoleOptions: { silent: true }, + errorSerializer: createErrorSerializer({ omit: (key) => key === 'headers', dedupe: true }), + transports: [transport], + }) + logger.error('request failed', { error: retryError() }) await transport.close!() - expect(received[0].error).not.toHaveProperty('lastError') - expect((received[0].error as { errors: object[] }).errors[0]).not.toHaveProperty('responseHeaders') + const error = received[0].error as { attempts: object[]; latest: unknown } + expect(error.attempts[0]).not.toHaveProperty('headers') + expect(error.latest).toBe('[Duplicate]') }) }) diff --git a/src/serialize-error.ts b/src/serialize-error.ts index 5213547..fa2069f 100644 --- a/src/serialize-error.ts +++ b/src/serialize-error.ts @@ -1,5 +1,4 @@ import { JsonOptions } from 'logform' -import { isCompactedErrorKey } from './compacted-error-keys' import { serializesItself } from './serializes-itself' /** @@ -9,6 +8,35 @@ import { serializesItself } from './serializes-itself' */ export type ErrorSerializer = (error: Error) => Record +export interface ErrorSerializerOptions { + /** + * Leaves a property out of an error. Called with each property name of every error the + * serializer reaches — the error itself, its `cause`, each entry of its `errors`, and any error + * held in a property — and the error that holds it. Return `true` to leave the property out. + * Plain objects are never passed to it. A predicate that throws keeps the property. + * + * @example Leave response headers out of every error, at any depth + * ```ts + * createErrorSerializer({ omit: (key) => key === 'responseHeaders' }) + * ``` + */ + omit?: (key: string, error: Error) => boolean + + /** + * Writes an object that has already been written once in the same error as `'[Duplicate]'`, so + * an object two properties share appears once. The first property in key order keeps the object. + * Values written through their `toJSON`, such as a `Date`, are always written in full. + * Defaults to `false`. + */ + dedupe?: boolean +} + +interface WalkOptions { + omit: ErrorSerializerOptions['omit'] + /** Every object already written, when `dedupe` is on. */ + written: WeakSet | undefined +} + // Stops runaway recursion on pathological structures. 16 is well past anything that occurs in // realistic error trees; consumers who need deeper walks can supply a custom `errorSerializer`. const MAX_DEPTH = 16 @@ -44,7 +72,17 @@ const safeToJSON = (source: object): { ok: true; value: unknown } | { ok: false } } -const walk = (value: unknown, seen: WeakSet, depth: number): unknown => { +/** The caller's `omit`, which must never throw out of the log call. */ +const isOmitted = (options: WalkOptions, key: string, error: Error): boolean => { + if (!options.omit) return false + try { + return options.omit(key, error) === true + } catch { + return false + } +} + +const walk = (value: unknown, seen: WeakSet, depth: number, options: WalkOptions): unknown => { if (value === null || value === undefined) return value const type = typeof value if (type === 'bigint') return `${value as bigint}n` @@ -57,29 +95,36 @@ const walk = (value: unknown, seen: WeakSet, depth: number): unknown => if (seen.has(obj)) return '[Circular]' if (depth >= MAX_DEPTH) return '[Truncated]' + const errorLike = isErrorLike(obj) + // What `JSON.stringify` would have written: a `Date` becomes its ISO string (`null` when + // invalid) rather than `{}`. An error is never asked, so one that defines `toJSON` — an + // `AxiosError`, say — is still captured whole below. + const json = !Array.isArray(obj) && !errorLike && serializesItself(obj) ? safeToJSON(obj) : undefined + + // A value written through its `toJSON` is what it returns, not a shared object, so it is never + // a duplicate. + if (!json?.ok && options.written) { + if (options.written.has(obj)) return '[Duplicate]' + options.written.add(obj) + } + seen.add(obj) try { - if (Array.isArray(obj)) return obj.map((entry) => walk(entry, seen, depth + 1)) - - // What `JSON.stringify` would have written: a `Date` becomes its ISO string (`null` when - // invalid) rather than `{}`. An error is never asked, so one that defines `toJSON` — an - // `AxiosError`, say — is still captured whole below. - if (!isErrorLike(obj) && serializesItself(obj)) { - const json = safeToJSON(obj) - if (json.ok) return walk(json.value, seen, depth + 1) - } + if (Array.isArray(obj)) return obj.map((entry) => walk(entry, seen, depth + 1, options)) + if (json?.ok) return walk(json.value, seen, depth + 1, options) const out: Record = {} for (const key of Object.keys(obj)) { + if (errorLike && isOmitted(options, key, obj)) continue const read = safeRead(obj, key) - if (read.ok) out[key] = walk(read.value, seen, depth + 1) + if (read.ok) out[key] = walk(read.value, seen, depth + 1, options) } - if (isErrorLike(obj)) { + if (errorLike) { for (const key of WELL_KNOWN_ERROR_PROPS) { - if (key in out) continue + if (key in out || isOmitted(options, key, obj)) continue const read = safeRead(obj, key) if (!read.ok || read.value === undefined || read.value === null) continue - out[key] = walk(read.value, seen, depth + 1) + out[key] = walk(read.value, seen, depth + 1, options) } } return out @@ -88,6 +133,30 @@ const walk = (value: unknown, seen: WeakSet, depth: number): unknown => } } +/** + * Builds an {@link ErrorSerializer} that works like {@link serializeError}, and can leave + * properties out of each error or write a shared object once. Pass the result as `createLogger`'s + * `errorSerializer`. + * + * @example Leave out response headers, and an error's `lastError` when its `errors` holds it + * ```ts + * createLogger({ + * errorSerializer: createErrorSerializer({ + * omit: (key, error) => key === 'responseHeaders' || (key === 'lastError' && 'errors' in error), + * }), + * }) + * ``` + */ +export const createErrorSerializer = + (options: ErrorSerializerOptions = {}): ErrorSerializer => + (error) => { + if (error === null || error === undefined || typeof error !== 'object') { + return { name: 'NonError', message: typeof error === 'function' ? '' : String(error) } + } + const walkOptions: WalkOptions = { omit: options.omit, written: options.dedupe ? new WeakSet() : undefined } + return walk(error, new WeakSet(), 0, walkOptions) as Record + } + /** * Serialize an `Error` (or any thrown value) into a plain, JSON-serializable object. * @@ -98,32 +167,10 @@ const walk = (value: unknown, seen: WeakSet, depth: number): unknown => * are stringified, functions are dropped, Buffers and streams render as sentinel strings, * and recursion is capped at a fixed depth (`'[Truncated]'`). Non-Error throws (strings, * numbers, etc.) are wrapped as `{ name: 'NonError', message: String(value) }`. - */ -export const serializeError: ErrorSerializer = (error) => { - if (error === null || error === undefined || typeof error !== 'object') { - return { name: 'NonError', message: typeof error === 'function' ? '' : String(error) } - } - return walk(error, new WeakSet(), 0) as Record -} - -const compact = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(compact) - if (value === null || typeof value !== 'object') return value - const out: Record = {} - for (const [key, entry] of Object.entries(value)) { - if (!isCompactedErrorKey(value, key)) out[key] = compact(entry) - } - return out -} - -/** - * {@link serializeError}, without what makes a failed AI SDK retry too large to log: `responseHeaders` - * on every error, and `lastError` on an error whose `errors` already holds it. * - * `otelAttributesFormat` does this to OpenTelemetry output by default. Pass this as `createLogger`'s - * `errorSerializer` to compact every transport's output, the console included. + * Use {@link createErrorSerializer} to leave properties out or write shared objects once. */ -export const compactErrorSerializer: ErrorSerializer = (error) => compact(serializeError(error)) as Record +export const serializeError: ErrorSerializer = createErrorSerializer() /** * Builds a JSON replacer that substitutes `Error` instances with the output of the supplied From 26a808b4581c7d7ccb69da288c89d8022cb9d4fe Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 15:48:21 +0800 Subject: [PATCH 06/13] docs: tidy the v3 prose --- README.md | 60 ++++++++++++++++++---------------- src/callback-transport.spec.ts | 4 +-- src/index.ts | 8 ++--- src/otel-attributes-format.ts | 10 +++--- src/otel-transport.spec.ts | 6 ++-- src/serialize-error.ts | 14 ++++---- 6 files changed, 53 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 2acfd98..74b69a4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npm install @makerx/node-winston Requires Node.js `>=20`. -### Upgrading +### Upgrade from an earlier version See [Migrating from v2](#migrating-from-v2) and [Migrating from v1](#migrating-from-v1) at the bottom for breaking changes and new functionality. @@ -38,9 +38,9 @@ Formats are applied in two layers: | `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | | `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. A plain key matches at every depth, inside serialized errors too. Applied at the logger level, so affects all transports. | | `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | -| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`; an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport; otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | +| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`, and an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport. Otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | | `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Build one with `createErrorSerializer` to leave properties out of every error. | -| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `otel` when enabled. | +| `loggerOptions` | `LoggerOptions` | Winston logger options, for example `level` and `defaultMeta`. A `format` supplied here is appended after the library's logger-level formats, and runs before `otel` when `otel` is set. | ### Log levels @@ -50,7 +50,7 @@ Formats are applied in two layers: { error: 0, warn: 1, audit: 2, info: 3, debug: 4, verbose: 5 } ``` -`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, so it adds no delivery or retention guarantee: route it with a transport if it needs one (see [`CallbackTransport`](#callbacktransport)). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so the OTel output exports an `audit` record at `info` severity with `logLevel: 'audit'` (see [Audit severity](#audit-severity)). In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. +`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else, route them with a transport such as [`CallbackTransport`](#callbacktransport). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so `otelAttributesFormat` exports an `audit` record at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. Colours for the default levels (including `audit`) are registered on the first `createLogger` call that uses them, so `colorize` / pretty output works out of the box. @@ -224,7 +224,7 @@ const logger = createLogger({ In-flight dispatches are tracked internally and drained via `Promise.allSettled` on `close()`, and any rejection from the handler is routed to the supplied `logError` so a failing endpoint can't recurse back through this transport. -`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option remains accurate when paired with `createLogger({ otel: true })`, whose audit mapping rewrites the symbol but leaves the string untouched. For the same reason it sidesteps the `otel` + `level: 'audit'` guard in `createLogger` — the option is intercepted by the transport rather than passed down to the base `winston-transport` class. +`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option remains accurate when paired with `createLogger({ otel: true })`, whose audit mapping rewrites the symbol but leaves the string untouched. For the same reason, the `otel` + `level: 'audit'` guard in `createLogger` does not apply to it: the transport keeps its `level` option and does not pass it to the base `winston-transport` class. ## OpenTelemetry @@ -263,16 +263,16 @@ const logger = createLogger({ otel: true }) A transport before 0.29 is not supported: see [Migrating from v2](#migrating-from-v2). -The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, the transport binds to a logger provider nobody registers, and every log line goes nowhere. +The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, the transport gets a logger from a copy that no provider is registered with, and every log line is dropped. ### What the format does -From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception. The SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. The format works with that: +From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception. The SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. The format does four things: - The error stays an object with only the fields the SDK reads. The rest of it, such as an HTTP status, a provider's error body or the cause chain, goes to `errorDetail` as JSON. - Every other object value becomes a JSON string. Scalars are left alone, and a `Date` becomes its ISO string. - Every record gets `logLevel`, and an `audit` record is exported at `info` severity. See [Audit severity](#audit-severity). -- Strings it writes can be capped at a backend's limit with `maxAttributeLength`. See [Keeping records small and private](#keeping-records-small-and-private). +- It can cap the strings it writes at a backend's limit, with `maxAttributeLength`. See [Record size and privacy](#record-size-and-privacy). A provider error logged as `logger.error('Chat failed', { error, request: { route: '/chat' } })` is exported as: @@ -298,11 +298,11 @@ Without the format, two things go wrong: ### Options -| Option | Default | Description | -| -------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | -| `maxAttributeLength` | none | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. By default every string is written whole. Strings the record already held are never cut. | -| `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. It is called with the object holding the value as `this`, so it can leave a property out by its siblings. | +| Option | Default | Description | +| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | +| `maxAttributeLength` | none | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. By default every string is written whole. Strings the record already held are never cut. | +| `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. It is called with the object that holds the value as `this`, so it can decide on a property from the other properties of that object. | ### Audit severity @@ -310,8 +310,8 @@ OpenTelemetry's log spec defines a fixed severity enumeration, so the transport `LEVEL` also drives level filtering, so where the mapping runs matters: -- **On a transport's own format**, it runs after that transport's level filter. A transport set to `level: 'audit'` keeps audit records, and other transports never see the rewrite. -- **With `otel`**, it runs before every transport's filter. A logger, console or transport set to `level: 'audit'` would then drop audit records, so `createLogger` throws for that combination. Use `'info'` there, or put the format on the transport instead. +- On a transport's own format, the mapping runs after that transport's level filter. A transport set to `level: 'audit'` keeps audit records, and other transports never see the rewrite. +- With `otel`, the mapping runs before every transport's filter. A logger, console or transport set to `level: 'audit'` would then drop audit records, so `createLogger` throws for that combination. Use `'info'` there, or put the format on the transport instead. Under `otel`, pretty console output colours an `audit` line as `info`. @@ -326,17 +326,19 @@ new OpenTelemetryTransportV3({ }) ``` -`WinstonInstrumentation` takes the same `severityMapping` option: pair it with `otel: { mapAuditLevel: false }`. Do not turn the mapping off without a `severityMapping` when you set `logSeverity`, because an `audit` record with no severity is dropped. +`@opentelemetry/instrumentation-winston` 0.68 and later take the same `severityMapping` option. Pair it with `otel: { mapAuditLevel: false }`. Do not turn the mapping off without a `severityMapping` when you set `logSeverity`, because an `audit` record with no severity is dropped. -### Keeping records small and private +### Record size and privacy -The format writes what the record holds. Three generic options decide what that is, whatever library an error comes from: +Three options control what a record holds, for errors from any library: -- **`redactPaths`** replaces values on every record. A plain key matches at every depth, inside serialized errors too, so it reaches a property an error carries. -- **`createErrorSerializer`** builds an `errorSerializer` that leaves properties out of every error with `omit`, and with `dedupe` writes an object two properties share only once. See [Error serialization](#error-serialization). -- **`maxAttributeLength`** cuts any string the format writes at a backend's limit, and ends it in `[Truncated]` so the cut is visible. The logs SDK's own `attributeValueLengthLimit` cuts silently, which leaves a JSON value that no longer parses. +| Option | Effect | Applies to | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | +| `redactPaths` | Replaces values on every record. A plain key matches at every depth, inside serialized errors too. | Every transport | +| `errorSerializer`, built with `createErrorSerializer` | Leaves properties out of every error with `omit`. With `dedupe`, writes an object two properties share only once. See [Error serialization](#error-serialization). | Every transport | +| `maxAttributeLength` | Cuts any string the format writes at a backend's limit, and ends it in `[Truncated]` so the cut is visible. | OpenTelemetry output | -`redactPaths` and `errorSerializer` shape what every transport receives, the console included. To shape only the OpenTelemetry output, give that transport its own chain, such as `format.combine(redactFormat({ paths }), otelAttributesFormat({ replacer }))`. +The logs SDK's own `attributeValueLengthLimit` cuts a value without a marker, and a cut JSON value does not parse. To shape only the OpenTelemetry output, give that transport its own chain, such as `format.combine(redactFormat({ paths }), otelAttributesFormat({ replacer }))`. #### Example: AI SDK errors in App Insights @@ -456,8 +458,10 @@ const logger = createLogger({ `createErrorSerializer` builds a serializer that works like `serializeError`, with two options: -- **`omit`** leaves a property out of every error the serializer reaches: the error itself, its `cause`, each entry of its `errors`, and any error held in a property. It is given the property name and the error that holds it, so a rule can depend on the error's other properties. It is never asked about a plain object. -- **`dedupe`** writes an object two properties of one error share only once, and every later occurrence as `'[Duplicate]'`. The first property in key order keeps the object. +| Option | Effect | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omit` | Leaves a property out of every error the serializer reaches: the error itself, its `cause`, each entry of its `errors`, and any error held in a property. It is called with the property name and the error that holds it, so a rule can depend on the error's other properties. It is never called for a plain object. | +| `dedupe` | Writes an object that two properties of one error share only once, and each later occurrence as `'[Duplicate]'`. The first property in key order keeps the object. | ```ts import { createErrorSerializer, createLogger } from '@makerx/node-winston' @@ -498,7 +502,7 @@ v3 puts OpenTelemetry behind one option, `otel`, and supports `@opentelemetry/wi Breaking changes: -- **`flatten`, `flattenReplacer` and `mapAuditLevelForOtel` are removed from `createLogger`.** They are type errors, and `createLogger` throws if one is passed, so config-built options cannot silently fall back to nested output. Replace them as follows: +- `flatten`, `flattenReplacer` and `mapAuditLevelForOtel` are removed from `createLogger`. They are type errors, and `createLogger` throws if one is passed, so config-built options cannot silently fall back to nested output. Replace them as follows: | 2.x | 3.0 | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -508,9 +512,9 @@ Breaking changes: | `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | | A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | -- **`@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`.** npm 7 and later refuse to install alongside an older transport; pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. -- **The audit mapping runs last.** With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`; in 2.x it saw `info`. -- **A `Date` under `otel` is its ISO string, unquoted.** `flatten: true` wrote it as a quoted JSON string. +- `@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`. npm 7 and later refuse to install alongside an older transport, and pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. +- The audit mapping runs last. With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`. In 2.x it saw `info`. +- A `Date` under `otel` is its ISO string, unquoted. `flatten: true` wrote it as a quoted JSON string. New functionality: diff --git a/src/callback-transport.spec.ts b/src/callback-transport.spec.ts index 8c80c82..7fd8b92 100644 --- a/src/callback-transport.spec.ts +++ b/src/callback-transport.spec.ts @@ -139,8 +139,8 @@ describe('CallbackTransport', () => { () => {}, { level: 'audit' }, ) - // We intercept `level` in our constructor so `createLogger`'s `otel` audit guard - // (which reads `transport.level`) does not trip when the transport is configured for audit. + // The constructor keeps `level` for itself, so `createLogger`'s `otel` audit guard (which reads + // `transport.level`) does not trip when the transport is configured for audit. expect(transport.level).toBeUndefined() }) diff --git a/src/index.ts b/src/index.ts index 9c8cecc..d82bda5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -187,13 +187,13 @@ export interface CreateLoggerOptions { loggerOptions?: Omit } -/** 2.x options with no 3.0 meaning. Each maps onto `otel`; see "Migrating from v2" in the README. */ +/** 2.x options that 3.0 removed. Each maps onto `otel`. See "Migrating from v2" in the README. */ const REMOVED_OPTIONS = ['flatten', 'flattenReplacer', 'mapAuditLevelForOtel'] as const /** - * Throws for an option 3.0 removed. TypeScript rejects one in an object literal, but not in options - * built from config or spread from another object, and ignoring it would quietly send OpenTelemetry - * records nested again. + * Throws for an option that 3.0 removed. TypeScript rejects one in an object literal, but not in + * options built from config or spread from another object. Ignoring it would send OpenTelemetry + * records nested, with no warning. */ const rejectRemovedOptions = (options: CreateLoggerOptions) => { const given = REMOVED_OPTIONS.filter((key) => (options as Record)[key] !== undefined) diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 90766b5..4036d5b 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -11,8 +11,8 @@ const TRUNCATED = '[Truncated]' export interface OtelAttributesFormatOptions { /** * `JSON.stringify` replacer applied to every value this format stringifies, `errorDetail` - * included. It is called with the object holding the value as `this`, so it can decide on a - * property by its siblings. Return `undefined` to leave the property out. + * included. It is called with the object that holds the value as `this`, so it can decide on a + * property from the other properties of that object. Return `undefined` to leave the property out. */ replacer?: Replacer @@ -24,9 +24,9 @@ export interface OtelAttributesFormatOptions { mapAuditLevel?: boolean /** - * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`, so - * a backend's own limit never cuts it silently. By default every string is written whole. Values - * that were already strings are never cut. + * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`. Set + * it to the backend's limit so that the backend does not cut the value without a marker. By + * default every string is written whole. Values that were already strings are never cut. * * @example App Insights keeps 8,192 characters of a custom dimension * ```ts diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index ebb427d..c4c7a96 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -152,9 +152,9 @@ describe.each(lines)('otel: true on %s', (_, Transport) => { expect(detail.endsWith('[Truncated]')).toBe(true) }) - // Upstream behaviour, pinned so a change to it is noticed: with no `err` or `error` on the record, - // the transport takes the first splat argument that looks like an exception — any object with a - // `name`, `code`, `message` or `stack` — as the record's exception. + // Upstream behaviour, pinned so that a change to it fails this test. With no `err` or `error` on + // the record, the transport takes the first splat argument that has a `name`, `code`, `message` + // or `stack` as the record's exception. it('records a metadata object with a name as the exception when there is no err or error', async () => { const [record] = await emitted({ otel: true, transports: [new Transport()] }, (logger, message) => logger.info(message, { name: 'Alice' }), diff --git a/src/serialize-error.ts b/src/serialize-error.ts index fa2069f..0aa9331 100644 --- a/src/serialize-error.ts +++ b/src/serialize-error.ts @@ -10,10 +10,10 @@ export type ErrorSerializer = (error: Error) => Record export interface ErrorSerializerOptions { /** - * Leaves a property out of an error. Called with each property name of every error the - * serializer reaches — the error itself, its `cause`, each entry of its `errors`, and any error - * held in a property — and the error that holds it. Return `true` to leave the property out. - * Plain objects are never passed to it. A predicate that throws keeps the property. + * Leaves a property out of an error. Called with the property name and the error that holds it, + * for every error the serializer reaches: the error itself, its `cause`, each entry of its + * `errors`, and any error held in a property. Return `true` to leave the property out. It is never + * called for a plain object. If it throws, the property is kept. * * @example Leave response headers out of every error, at any depth * ```ts @@ -72,7 +72,7 @@ const safeToJSON = (source: object): { ok: true; value: unknown } | { ok: false } } -/** The caller's `omit`, which must never throw out of the log call. */ +/** Calls `omit`. A throw keeps the property, so a faulty predicate cannot throw out of the log call. */ const isOmitted = (options: WalkOptions, key: string, error: Error): boolean => { if (!options.omit) return false try { @@ -97,8 +97,8 @@ const walk = (value: unknown, seen: WeakSet, depth: number, options: Wal const errorLike = isErrorLike(obj) // What `JSON.stringify` would have written: a `Date` becomes its ISO string (`null` when - // invalid) rather than `{}`. An error is never asked, so one that defines `toJSON` — an - // `AxiosError`, say — is still captured whole below. + // invalid) rather than `{}`. An error is never asked, so an error that defines `toJSON`, such as + // an `AxiosError`, is still captured whole below. const json = !Array.isArray(obj) && !errorLike && serializesItself(obj) ? safeToJSON(obj) : undefined // A value written through its `toJSON` is what it returns, not a shared object, so it is never From 08cf9f77306101052e2303257c74419c44d4e063 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 16 Oct 2026 16:08:20 +0800 Subject: [PATCH 07/13] fix: address review of #157 - Read the audit level from LEVEL, so a format such as colorize() before otel no longer stops audit records being mapped. Keep an existing logLevel so a second pass stays idempotent. - Cap a string to as much of [Truncated] as fits when maxAttributeLength is shorter than the marker, and cut nothing for a negative or NaN limit. - Make the surrogate-pair test cut inside a pair. - Document that omit also runs for plain objects with a string name, message and stack. - README: cover the audit-mapping default in Migrating from v2, state when two api-logs copies drop lines, and make the severityMapping warning unconditional. Point jsonStringifyValuesFormat users at otelAttributesFormat for the OpenTelemetry transport. --- README.md | 14 ++++++++------ eslint.config.mjs | 5 +++-- src/audit-level.ts | 9 ++++++--- src/index.spec.ts | 14 ++++++++++++++ src/json-stringify-values-format.ts | 6 ++++-- src/map-audit-level-for-otel.ts | 7 +++++-- src/otel-attributes-format.spec.ts | 28 +++++++++++++++++++++++++--- src/otel-attributes-format.ts | 14 ++++++++++---- src/otel-transport.spec.ts | 10 ++++++++++ src/serialize-error.spec.ts | 8 +++++++- src/serialize-error.ts | 7 +++++-- 11 files changed, 97 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 74b69a4..29a4190 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ const logger = createLogger({ otel: true }) A transport before 0.29 is not supported: see [Migrating from v2](#migrating-from-v2). -The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, the transport gets a logger from a copy that no provider is registered with, and every log line is dropped. +The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, a transport constructed before the SDK registers its logger provider stays bound to a no-op logger, and every line it writes is dropped. ### What the format does @@ -326,7 +326,7 @@ new OpenTelemetryTransportV3({ }) ``` -`@opentelemetry/instrumentation-winston` 0.68 and later take the same `severityMapping` option. Pair it with `otel: { mapAuditLevel: false }`. Do not turn the mapping off without a `severityMapping` when you set `logSeverity`, because an `audit` record with no severity is dropped. +`@opentelemetry/instrumentation-winston` 0.68 and later take the same `severityMapping` option. Pair it with `otel: { mapAuditLevel: false }`. Turn the mapping off only together with a `severityMapping` for `audit`. Without one, an `audit` record has no severity, and Azure Monitor or a `logSeverity` filter drops it. ### Record size and privacy @@ -458,10 +458,10 @@ const logger = createLogger({ `createErrorSerializer` builds a serializer that works like `serializeError`, with two options: -| Option | Effect | -| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `omit` | Leaves a property out of every error the serializer reaches: the error itself, its `cause`, each entry of its `errors`, and any error held in a property. It is called with the property name and the error that holds it, so a rule can depend on the error's other properties. It is never called for a plain object. | -| `dedupe` | Writes an object that two properties of one error share only once, and each later occurrence as `'[Duplicate]'`. The first property in key order keeps the object. | +| Option | Effect | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omit` | Leaves a property out of every error the serializer reaches: the error itself, its `cause`, each entry of its `errors`, and any error held in a property. It is called with the property name and the error that holds it, so a rule can depend on the error's other properties. An error is an `Error` instance, or an object with a string `name`, `message` and `stack`, such as an error already serialized elsewhere. It is not called for any other object. | +| `dedupe` | Writes an object that two properties of one error share only once, and each later occurrence as `'[Duplicate]'`. The first property in key order keeps the object. | ```ts import { createErrorSerializer, createLogger } from '@makerx/node-winston' @@ -507,12 +507,14 @@ Breaking changes: | 2.x | 3.0 | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `flatten: 'otel', mapAuditLevelForOtel: true` | `otel: true` | +| `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: { mapAuditLevel: false }` for the same output, or `otel: true` to also map `audit`. | | `flattenReplacer: r` with `flatten: 'otel'` | `otel: { replacer: r }` | | `otelAttributesFormat()` on the transport, plus `mapAuditLevelForOtel: true` | `otelAttributesFormat()` on the transport alone. A transport set to `level: 'audit'` is now allowed. | | `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | | A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | - `@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`. npm 7 and later refuse to install alongside an older transport, and pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. +- `otelAttributesFormat` maps `audit` to `info` severity and writes `logLevel` by default. Pass `mapAuditLevel: false` to keep the 2.x output of `flatten: 'otel'` or of the format on a transport. With `otel: true`, `createLogger` throws if the logger, the console or a transport is set to `level: 'audit'`. - The audit mapping runs last. With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`. In 2.x it saw `info`. - A `Date` under `otel` is its ISO string, unquoted. `flatten: true` wrote it as a quoted JSON string. diff --git a/eslint.config.mjs b/eslint.config.mjs index 73173e9..81690fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,8 +36,9 @@ export default [ }, { // The OpenTelemetry transport is an optional peer. Importing it, or `@opentelemetry/api-logs`, from - // the library would make it required, and a second `api-logs` copy in an app sends every log - // line to a no-op. Specs drive the real transport under npm aliases instead. + // the library would make it required and can add a second `api-logs` copy to an app. A transport + // that gets its logger from a second copy before the SDK registers its provider writes every line + // to a no-op logger. Specs drive the real transport under npm aliases instead. files: ['src/**/*.ts'], ignores: ['src/**/*.spec.ts'], rules: { diff --git a/src/audit-level.ts b/src/audit-level.ts index 4a28fa1..a34384c 100644 --- a/src/audit-level.ts +++ b/src/audit-level.ts @@ -5,11 +5,14 @@ import { LEVEL } from 'triple-beam' /** * Copies the record's level onto `logLevel`, and exports an `audit` record at `info` severity. * - * The string `level` is never changed, so a second pass writes the same `logLevel` again. + * The level is read from the triple-beam `LEVEL` symbol, because a format such as + * `format.colorize()` rewrites the string `level`. A `logLevel` the record already has is kept, + * so a second pass leaves `logLevel` at `audit` after the first pass has changed `LEVEL`. * See `mapAuditLevelForOtel` for why the mapping exists. */ export const applyAuditLevelMapping = (info: TransformableInfo): TransformableInfo => { - info.logLevel = info.level - if (info.level === 'audit') info[LEVEL] = 'info' + const level = info[LEVEL] as string | undefined + if (typeof info.logLevel !== 'string') info.logLevel = level + if (level === 'audit') info[LEVEL] = 'info' return info } diff --git a/src/index.spec.ts b/src/index.spec.ts index 9241076..1a01082 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -442,6 +442,20 @@ describe('createLogger otel audit level', () => { expect(info.logLevel).toBeUndefined() }) + it('maps audit after a loggerOptions.format that colorizes the string level', () => { + const transport = new InMemoryTransport({}) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + otel: true, + loggerOptions: { format: format.colorize() }, + }) + logger.audit('audit-event') + const info = transport.logs[0] as Record + expect(info[LEVEL]).toBe('info') + expect(info.logLevel).toBe('audit') + }) + it('runs after loggerOptions.format, which still sees LEVEL audit', () => { const seen: unknown[] = [] const spy = format((info) => { diff --git a/src/json-stringify-values-format.ts b/src/json-stringify-values-format.ts index 6333725..1bcf800 100644 --- a/src/json-stringify-values-format.ts +++ b/src/json-stringify-values-format.ts @@ -3,8 +3,10 @@ import { format } from 'winston' /** * Serialises all top-level object values on the log info to JSON strings, producing a flat - * `{ key: string }` shape suitable for transports and aggregators that expect scalar values - * (e.g. OTEL + Azure Log Analytics). + * `{ key: string }` shape for transports and aggregators that expect scalar values. + * + * Do not use it for `@opentelemetry/winston-transport`: from 0.29 the transport records an error + * written as a JSON string as one `exception.message`. Use `otelAttributesFormat` there. * * Accepts an optional `replacer` forwarded to `JSON.stringify` for each value. * diff --git a/src/map-audit-level-for-otel.ts b/src/map-audit-level-for-otel.ts index 1b4ec2d..fce86f7 100644 --- a/src/map-audit-level-for-otel.ts +++ b/src/map-audit-level-for-otel.ts @@ -12,9 +12,12 @@ import { applyAuditLevelMapping } from './audit-level' * This format: * - rewrites `info[LEVEL]` from `audit` to `info` so OTEL maps to a known severity tier * - copies the original level onto `logLevel` on every record so it survives as an OTEL - * attribute (e.g. queryable as `customDimensions.logLevel == "audit"` in Azure Log Analytics) + * attribute (e.g. queryable as `customDimensions.logLevel == "audit"` in Azure Log Analytics). + * A record that already has a string `logLevel` keeps it. * - * The string `info.level` is left as it is, so other transports still show `audit`. + * The level is read from `info[LEVEL]`, so a format that rewrites the string `info.level`, such as + * `format.colorize()`, can run first. The string `info.level` is left as it is, so other + * transports still show `audit`. * * `otelAttributesFormat` already does this, so most loggers need only that. Use this format on * its own in a chain you build yourself, for example beside `jsonStringifyValuesFormat`. diff --git a/src/otel-attributes-format.spec.ts b/src/otel-attributes-format.spec.ts index e787890..992d4f5 100644 --- a/src/otel-attributes-format.spec.ts +++ b/src/otel-attributes-format.spec.ts @@ -118,6 +118,16 @@ describe('otelAttributesFormat — level', () => { expect(result.logLevel).toBe('warn') }) + it('reads the level from LEVEL, so a colorized string level still maps', () => { + const result = run({ [LEVEL]: 'audit', level: '\u001b[35maudit\u001b[39m' }) + expect(result[LEVEL]).toBe('info') + expect(result.logLevel).toBe('audit') + }) + + it('keeps a string logLevel the record already has', () => { + expect(run({ [LEVEL]: 'audit', level: 'audit', logLevel: 'custom' }).logLevel).toBe('custom') + }) + it('maps nothing with mapAuditLevel: false', () => { const result = at('audit', { mapAuditLevel: false }) expect(result[LEVEL]).toBe('audit') @@ -175,11 +185,23 @@ describe('otelAttributesFormat — length cap', () => { expect(result.error).toMatchObject({ message: long, stack: long }) }) - it('does not split a surrogate pair', () => { - const value = run({ ctx: { body: '😀'.repeat(100) } }, { maxAttributeLength: 30 }).ctx as string + // `{"body":"` is 9 characters and each emoji is 2, so at 31 the cut falls inside a pair and at 30 + // it falls between two pairs. + it.each([30, 31])('does not split a surrogate pair at maxAttributeLength %i', (max) => { + const value = run({ ctx: { body: '😀'.repeat(100) } }, { maxAttributeLength: max }).ctx as string + expect(value.length).toBeLessThanOrEqual(max) expect(value.endsWith('[Truncated]')).toBe(true) expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(value)).toBe(false) }) + + it('writes as much of the marker as fits when maxAttributeLength is shorter than it', () => { + expect(run({ ctx: { body: 'x'.repeat(100) } }, { maxAttributeLength: 5 }).ctx).toBe('[Trun') + }) + + it('cuts nothing when maxAttributeLength is negative or NaN', () => { + expect(JSON.parse(run({ ctx: { body: 'x' } }, { maxAttributeLength: -1 }).ctx as string)).toEqual({ body: 'x' }) + expect(JSON.parse(run({ ctx: { body: 'x' } }, { maxAttributeLength: NaN }).ctx as string)).toEqual({ body: 'x' }) + }) }) describe('otelAttributesFormat — shared state', () => { @@ -194,7 +216,7 @@ describe('otelAttributesFormat — shared state', () => { it('applied twice writes the same record as applied once', () => { const fmt = otelAttributesFormat() const input = { [LEVEL]: 'audit', level: 'audit', message: 'm', error: withAttempts(), ctx: { a: 1 } } as unknown as TransformableInfo - const once = fmt.transform(structuredClone(input), fmt.options) as TransformableInfo + const once = fmt.transform(input, fmt.options) as TransformableInfo const onceCopy = { ...once } const twice = fmt.transform(once, fmt.options) as TransformableInfo expect(twice).toEqual(onceCopy) diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 4036d5b..d434c53 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -26,7 +26,8 @@ export interface OtelAttributesFormatOptions { /** * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`. Set * it to the backend's limit so that the backend does not cut the value without a marker. By - * default every string is written whole. Values that were already strings are never cut. + * default every string is written whole. Values that were already strings are never cut. A limit + * shorter than the 11-character marker writes as much of the marker as fits. * * @example App Insights keeps 8,192 characters of a custom dimension * ```ts @@ -87,10 +88,15 @@ const isDescribableCause = (cause: unknown): boolean => typeof cause.code === 'string' || typeof cause.code === 'number')) -/** Cuts a string to `max`, ending in `[Truncated]`, without splitting a surrogate pair. */ +/** + * Cuts a string to `max`, ending in `[Truncated]`, without splitting a surrogate pair. A `max` + * shorter than the marker gets as much of the marker as fits. A negative or `NaN` `max` cuts nothing. + */ const cap = (text: string, max: number): string => { - if (text.length <= max) return text - let end = Math.max(0, max - TRUNCATED.length) + if (!(text.length > max)) return text + if (max < 0) return text + if (max < TRUNCATED.length) return TRUNCATED.slice(0, max) + let end = max - TRUNCATED.length const last = text.charCodeAt(end - 1) if (last >= 0xd800 && last <= 0xdbff) end -= 1 return text.slice(0, end) + TRUNCATED diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index c4c7a96..bb65329 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -14,6 +14,7 @@ import { InMemoryLogRecordExporter, LoggerProvider, SimpleLogRecordProcessor } f import { OpenTelemetryTransportV3 as Transport029 } from 'otel-winston-transport-0.29' import { OpenTelemetryTransportV3 as TransportLatest } from 'otel-winston-transport-latest' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { format } from 'winston' import type TransportStream from 'winston-transport' import { CallbackTransport, createErrorSerializer, createLogger, type CreateLoggerOptions, type Logger } from './index' import { jsonStringifyValuesFormat } from './json-stringify-values-format' @@ -124,6 +125,15 @@ describe.each(lines)('otel: true on %s', (_, Transport) => { }) // The README's recipe for AI SDK errors, built from generic options only. + it('exports an audit record at INFO severity when loggerOptions.format colorizes the level', async () => { + const records = await emitted( + { otel: true, loggerOptions: { format: format.colorize() }, transports: [new Transport()] }, + (logger, message) => logger.audit(message), + ) + expect(records[0].severityNumber).toBe(SeverityNumber.INFO) + expect(records[0].attributes.logLevel).toBe('audit') + }) + it('redacts the prompt and trims a failed retry with redactPaths and createErrorSerializer', async () => { const options: Partial = { otel: { maxAttributeLength: 8192 }, diff --git a/src/serialize-error.spec.ts b/src/serialize-error.spec.ts index dc4cb97..81ccaa9 100644 --- a/src/serialize-error.spec.ts +++ b/src/serialize-error.spec.ts @@ -247,7 +247,13 @@ describe('createErrorSerializer', () => { expect(createErrorSerializer({ omit: (key) => key === 'stack' })(new Error('x'))).not.toHaveProperty('stack') }) - it('omit is never asked about a plain object', () => { + it('omit is called for a plain object with a string name, message and stack', () => { + const serialized = { name: 'RemoteError', message: 'm', stack: 'RemoteError: m', headers: { a: '1' } } + const out = createErrorSerializer({ omit: (key) => key === 'headers' })(Object.assign(new Error('x'), { remote: serialized })) + expect(out.remote).toEqual({ name: 'RemoteError', message: 'm', stack: 'RemoteError: m' }) + }) + + it('omit is never asked about any other plain object', () => { const asked: string[] = [] const serializer = createErrorSerializer({ omit: (key) => { diff --git a/src/serialize-error.ts b/src/serialize-error.ts index 0aa9331..1ee9ba3 100644 --- a/src/serialize-error.ts +++ b/src/serialize-error.ts @@ -12,8 +12,11 @@ export interface ErrorSerializerOptions { /** * Leaves a property out of an error. Called with the property name and the error that holds it, * for every error the serializer reaches: the error itself, its `cause`, each entry of its - * `errors`, and any error held in a property. Return `true` to leave the property out. It is never - * called for a plain object. If it throws, the property is kept. + * `errors`, and any error held in a property. Return `true` to leave the property out. If it + * throws, the property is kept. + * + * An error is an `Error` instance, or an object with a string `name`, `message` and `stack`, such + * as an error already serialized elsewhere. It is not called for any other object. * * @example Leave response headers out of every error, at any depth * ```ts From fa0ea7fa63b4e611a4b33bb76a94a901ccc97a76 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 10:38:55 +0800 Subject: [PATCH 08/13] feat!: otel shapes the OpenTelemetry transport, not the logger createLogger({ otel: true }) now gives otelAttributesFormat to each OpenTelemetryTransportV3 on the logger that has no format of its own: the one WinstonInstrumentation adds, and any passed in transports. The console and other transports keep nested values and level 'audit'. - The audit mapping runs after the transport's level filter, so the level: 'audit' guard is removed. - A spec runs the real WinstonInstrumentation before winston loads, as an app's tracing module does, and checks the exported records and a sibling transport. - The README leads with one setup: WinstonInstrumentation beside the other instrumentations, and otel: true. The per-transport placement and its examples are gone, and Migrating from v2 maps every 2.x setup onto it. --- README.md | 133 ++++++------ eslint.config.mjs | 2 +- package-lock.json | 125 +++++++++-- package.json | 3 +- src/callback-transport.spec.ts | 18 +- src/callback-transport.ts | 11 +- src/index.spec.ts | 209 ++++++------------- src/index.ts | 71 +++---- src/otel-transport.spec.ts | 46 ++-- src/tests/winston-instrumentation.fixture.ts | 53 +++++ src/winston-instrumentation.spec.ts | 51 +++++ 11 files changed, 409 insertions(+), 313 deletions(-) create mode 100644 src/tests/winston-instrumentation.fixture.ts create mode 100644 src/winston-instrumentation.spec.ts diff --git a/README.md b/README.md index 29a4190..91bf789 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ See [Migrating from v2](#migrating-from-v2) and [Migrating from v1](#migrating-f `createLogger` builds a winston `Logger` with a pre-configured `Console` transport and a set of logger-level formats that apply to every transport. -Formats are applied in two layers: +Formats are applied in layers: -- **Logger-level** (applied to all transports, in order): `serializeErrorFormat` → `omitFormat` (if `omitPaths`) → `redactFormat` (if `redactPaths`) → `loggerOptions.format` (if supplied) → `otelAttributesFormat` (if `otel`); always last so it captures every prior transformation. +- **Logger-level** (applied to all transports, in order): `serializeErrorFormat` → `omitFormat` (if `omitPaths`) → `redactFormat` (if `redactPaths`) → `loggerOptions.format` (if supplied). - **Console transport** (applied to Console output only): `omitNilFormat` → any `consoleFormats` → either `prettyConsoleFormat` (when `consoleFormat: 'pretty'`) or `timestamp` + `json` (when `consoleFormat: 'json'`). +- **OpenTelemetry transport** (with `otel`): `otelAttributesFormat`, after the logger-level formats. See [OpenTelemetry](#opentelemetry). ### Options @@ -38,9 +39,9 @@ Formats are applied in two layers: | `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | | `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. A plain key matches at every depth, inside serialized errors too. Applied at the logger level, so affects all transports. | | `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | -| `otel` | `boolean \| OtelAttributesFormatOptions` | Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, as the last logger-level format. `true` uses the defaults of `otelAttributesFormat`, and an object is passed to it as its options. Use it when `@opentelemetry/instrumentation-winston` adds the transport. Otherwise put the format on the transport. See [OpenTelemetry](#opentelemetry). | +| `otel` | `boolean \| OtelAttributesFormatOptions` | Gives the OpenTelemetry transport that `@opentelemetry/instrumentation-winston` adds the `otelAttributesFormat`. Other transports are unchanged. `true` uses the format's defaults, and an object is passed to it as its options. See [OpenTelemetry](#opentelemetry). | | `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Build one with `createErrorSerializer` to leave properties out of every error. | -| `loggerOptions` | `LoggerOptions` | Winston logger options, for example `level` and `defaultMeta`. A `format` supplied here is appended after the library's logger-level formats, and runs before `otel` when `otel` is set. | +| `loggerOptions` | `LoggerOptions` | Winston logger options, for example `level` and `defaultMeta`. A `format` supplied here is appended after the library's logger-level formats. | ### Log levels @@ -50,7 +51,7 @@ Formats are applied in two layers: { error: 0, warn: 1, audit: 2, info: 3, debug: 4, verbose: 5 } ``` -`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else, route them with a transport such as [`CallbackTransport`](#callbacktransport). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so `otelAttributesFormat` exports an `audit` record at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. +`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else, route them with a transport such as [`CallbackTransport`](#callbacktransport). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so with `otel` an `audit` record is exported at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). Colours for the default levels (including `audit`) are registered on the first `createLogger` call that uses them, so `colorize` / pretty output works out of the box. @@ -224,34 +225,41 @@ const logger = createLogger({ In-flight dispatches are tracked internally and drained via `Promise.allSettled` on `close()`, and any rejection from the handler is routed to the supplied `logError` so a failing endpoint can't recurse back through this transport. -`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option remains accurate when paired with `createLogger({ otel: true })`, whose audit mapping rewrites the symbol but leaves the string untouched. For the same reason, the `otel` + `level: 'audit'` guard in `createLogger` does not apply to it: the transport keeps its `level` option and does not pass it to the base `winston-transport` class. +`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option stays accurate when a logger-level format such as `mapAuditLevelForOtel` rewrites the symbol. The transport keeps its `level` option and does not pass it to the base `winston-transport` class. ## OpenTelemetry -[`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) forwards each winston record through the OpenTelemetry Logs SDK. Add it yourself, or let [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) add it to every logger. It is an optional peer dependency of this library, and version 0.29 or later is required. +Send logs through OpenTelemetry with [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) and `createLogger({ otel: true })`. The instrumentation adds an [`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) transport to every logger and stamps trace and span ids on each record. `otel: true` gives that transport `otelAttributesFormat`, which shapes each record into the attributes the logs SDK and most backends need. The console and every other transport are unchanged. -`otelAttributesFormat` shapes each record into the attributes the transport and the logs SDK need. Most backends, Azure Log Analytics included, index only scalar attributes, so a nested value has to arrive as a JSON string to be queryable. +Install both packages. The instrumentation loads the transport at runtime but does not depend on it, and without it no record is sent. The transport is an optional peer dependency of this library, at 0.29 or later. -### Where to apply it +### Setup -When you construct the transport yourself, put the format on that transport. The console and every other transport keep their nested output: +Register `WinstonInstrumentation` with your other instrumentations, in a module that runs before anything imports winston. An instrumentation patches a module as it loads, so a logger created from a winston that loaded first is never instrumented. ```ts -import { OpenTelemetryTransportV3 } from '@opentelemetry/winston-transport' -import { createLogger, otelAttributesFormat } from '@makerx/node-winston' +// tracing.ts: load first, for example with `node --import ./tracing.js ./main.js` +import { register } from 'node:module' +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' +import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston' +import { NodeSDK } from '@opentelemetry/sdk-node' -const logger = createLogger({ - transports: [new OpenTelemetryTransportV3({ format: otelAttributesFormat() })], -}) -``` +const instrumentations = [new HttpInstrumentation(), new WinstonInstrumentation()] -`@opentelemetry/instrumentation-winston` constructs its transport without a format, so with it use the `otel` option. It applies the format at the logger level, which changes what every transport receives, the console included: +// For an ES module app: winston is loaded by `import`, which only this hook can patch. +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) + +new NodeSDK({ instrumentations }).start() +``` ```ts -const logger = createLogger({ otel: true }) +// logger.ts +import { createLogger } from '@makerx/node-winston' + +export const logger = createLogger({ otel: true }) ``` -`otel` also takes the format's [options](#options-1), for example `otel: { maxAttributeLength: 8192 }`. +Where the SDK does not run, as in most tests, the logger has no OpenTelemetry transport and `otel` changes nothing. Do not also add an `OpenTelemetryTransportV3` yourself while the instrumentation sends logs: every record would be exported twice. ### Supported transport lines @@ -265,14 +273,14 @@ A transport before 0.29 is not supported: see [Migrating from v2](#migrating-fro The transport is tied to an SDK line through its `@opentelemetry/api-logs` dependency, so move the two together. With two copies of `api-logs` in the tree, a transport constructed before the SDK registers its logger provider stays bound to a no-op logger, and every line it writes is dropped. -### What the format does +### What a record looks like -From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception. The SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. The format does four things: +From 0.29 the transport gives the logs SDK an `err`/`error` value as the exception. The SDK keeps only `name`, `message`, `stack` and a cause's message: they become `exception.type`, `exception.message`, `exception.stacktrace` and `error.cause.*`, and every other field of the error is dropped. So `otelAttributesFormat`: -- The error stays an object with only the fields the SDK reads. The rest of it, such as an HTTP status, a provider's error body or the cause chain, goes to `errorDetail` as JSON. -- Every other object value becomes a JSON string. Scalars are left alone, and a `Date` becomes its ISO string. -- Every record gets `logLevel`, and an `audit` record is exported at `info` severity. See [Audit severity](#audit-severity). -- It can cap the strings it writes at a backend's limit, with `maxAttributeLength`. See [Record size and privacy](#record-size-and-privacy). +- leaves the error an object with only the fields the SDK reads, and moves the rest of it, such as an HTTP status, a provider's error body or the cause chain, to `errorDetail` as JSON +- writes every other object value as a JSON string, because most backends, Azure Log Analytics included, index only scalar attributes +- leaves scalars as they are, and writes a `Date` as its ISO string +- copies every record's level onto `logLevel`, and exports an `audit` record at `info` severity (see [Audit severity](#audit-severity)) A provider error logged as `logger.error('Chat failed', { error, request: { route: '/chat' } })` is exported as: @@ -291,13 +299,10 @@ A provider error logged as `logger.error('Chat failed', { error, request: { rout The SDK takes a truthy `code` over `name` as `exception.type`, so `code` goes to `errorDetail` unless the error has no `name`. -Without the format, two things go wrong: - -- **`jsonStringifyValuesFormat`** turns the error into a JSON string first, and the SDK records the whole string as `exception.message`, with no `exception.type` or stack trace. -- **No format** records the exception, but loses the rest of the error, and other object values arrive as nested maps. - ### Options +Pass options as `otel: { … }`. + | Option | Default | Description | | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | @@ -306,27 +311,18 @@ Without the format, two things go wrong: ### Audit severity -OpenTelemetry's log spec defines a fixed severity enumeration, so the transport has no severity for winston's `audit` level. Azure Monitor ignores a record without one, and a transport's `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity by rewriting the triple-beam `LEVEL` symbol. It also copies every record's level onto `logLevel`, so the original survives as an attribute. The string `level` is not changed, so console JSON still shows `"level": "audit"`. - -`LEVEL` also drives level filtering, so where the mapping runs matters: +OpenTelemetry's log spec has no severity for winston's `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity, and copies every record's level onto `logLevel` so that the original survives. In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. -- On a transport's own format, the mapping runs after that transport's level filter. A transport set to `level: 'audit'` keeps audit records, and other transports never see the rewrite. -- With `otel`, the mapping runs before every transport's filter. A logger, console or transport set to `level: 'audit'` would then drop audit records, so `createLogger` throws for that combination. Use `'info'` there, or put the format on the transport instead. +The format runs on the OpenTelemetry transport only, after that transport's level filter. A logger or transport set to `level: 'audit'` keeps audit records, and the console still shows `"level": "audit"`. -Under `otel`, pretty console output colours an `audit` line as `info`. - -From 0.34 the transport can map the level itself and keep `severityText: 'audit'`. Turn the format's mapping off and give the transport a `severityMapping`: +From `@opentelemetry/instrumentation-winston` 0.68 and transport 0.34, the transport can map the level itself and keep `severityText: 'audit'`. Give the instrumentation a `severityMapping`, and turn the format's mapping off: ```ts -import { SeverityNumber } from '@opentelemetry/api-logs' - -new OpenTelemetryTransportV3({ - format: otelAttributesFormat({ mapAuditLevel: false }), - severityMapping: { audit: SeverityNumber.INFO }, -}) +new WinstonInstrumentation({ severityMapping: { audit: SeverityNumber.INFO } }) // SeverityNumber from @opentelemetry/api-logs +createLogger({ otel: { mapAuditLevel: false } }) ``` -`@opentelemetry/instrumentation-winston` 0.68 and later take the same `severityMapping` option. Pair it with `otel: { mapAuditLevel: false }`. Turn the mapping off only together with a `severityMapping` for `audit`. Without one, an `audit` record has no severity, and Azure Monitor or a `logSeverity` filter drops it. +Turn the mapping off only together with a `severityMapping` for `audit`. Without one, an `audit` record has no severity, and Azure Monitor or a `logSeverity` filter drops it. ### Record size and privacy @@ -338,7 +334,7 @@ Three options control what a record holds, for errors from any library: | `errorSerializer`, built with `createErrorSerializer` | Leaves properties out of every error with `omit`. With `dedupe`, writes an object two properties share only once. See [Error serialization](#error-serialization). | Every transport | | `maxAttributeLength` | Cuts any string the format writes at a backend's limit, and ends it in `[Truncated]` so the cut is visible. | OpenTelemetry output | -The logs SDK's own `attributeValueLengthLimit` cuts a value without a marker, and a cut JSON value does not parse. To shape only the OpenTelemetry output, give that transport its own chain, such as `format.combine(redactFormat({ paths }), otelAttributesFormat({ replacer }))`. +The logs SDK's own `attributeValueLengthLimit` cuts a value without a marker, and a cut JSON value does not parse. To change only the OpenTelemetry output, use `otel: { replacer }`. #### Example: AI SDK errors in App Insights @@ -356,7 +352,7 @@ const logger = createLogger({ }) ``` -When you construct the transport yourself, leave out `otel` and pass the cap to its format instead: `otelAttributesFormat({ maxAttributeLength: 8192 })`. `dedupe: true` is an alternative to the `lastError` rule: the last attempt is written in full under `errors`, and `lastError` as `'[Duplicate]'`. +`dedupe: true` is an alternative to the `lastError` rule: the last attempt is written in full under `errors`, and `lastError` as `'[Duplicate]'`. ### Exceptions found in metadata @@ -366,16 +362,16 @@ With no `err` or `error` on a record, the transport takes the first metadata obj Every format used by `createLogger` is also exported for direct use with your own winston setup. -| Format | Purpose | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | -| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | -| `omitNilFormat` | Removes top-level `null` or `undefined` values. | -| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | -| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity. See [OpenTelemetry](#opentelemetry). | -| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | -| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | -| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | +| Format | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | +| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | +| `omitNilFormat` | Removes top-level `null` or `undefined` values. | +| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | +| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity. `otel` puts it on the OpenTelemetry transport. See [OpenTelemetry](#opentelemetry). | +| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | +| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | +| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | `redactFormat` paths accept plain keys (`email`, matched at every level), dot-notation paths (`user.email`), and `[*]` array wildcards to iterate every element of an array segment — for example `files[*].name`, `users[*].addresses[*].zip`, or `tags[*]` to redact each element of a primitive array. @@ -498,28 +494,29 @@ format.combine(serializeErrorFormat({ serializer }), format.json({ replacer: cre ## Migrating from v2 -v3 puts OpenTelemetry behind one option, `otel`, and supports `@opentelemetry/winston-transport` 0.29 and later only. 2.x gets fixes only, published under the `latest-2` dist-tag. +v3 has one OpenTelemetry setup: `WinstonInstrumentation` adds the transport, and `createLogger({ otel: true })` shapes what it receives. It supports `@opentelemetry/winston-transport` 0.29 and later only. 2.x gets fixes only, published under the `latest-2` dist-tag. Breaking changes: - `flatten`, `flattenReplacer` and `mapAuditLevelForOtel` are removed from `createLogger`. They are type errors, and `createLogger` throws if one is passed, so config-built options cannot silently fall back to nested output. Replace them as follows: -| 2.x | 3.0 | -| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `flatten: 'otel', mapAuditLevelForOtel: true` | `otel: true` | -| `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: { mapAuditLevel: false }` for the same output, or `otel: true` to also map `audit`. | -| `flattenReplacer: r` with `flatten: 'otel'` | `otel: { replacer: r }` | -| `otelAttributesFormat()` on the transport, plus `mapAuditLevelForOtel: true` | `otelAttributesFormat()` on the transport alone. A transport set to `level: 'audit'` is now allowed. | -| `flatten: true`, or `jsonStringifyValuesFormat()` on a transport before 0.29 | Move the transport and the SDK to 0.29 or later, then use `otelAttributesFormat()` or `otel`. If you cannot move yet, stay on 2.x. | -| A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | +| 2.x | 3.0 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `flatten: 'otel'` or `flatten: true`, with `mapAuditLevelForOtel: true` | `otel: true` | +| `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: { mapAuditLevel: false }` for the same attributes, or `otel: true` to also map `audit` | +| `flattenReplacer: r` | `otel: { replacer: r }` | +| An `OpenTelemetryTransportV3` you add yourself, with `otelAttributesFormat()` or `jsonStringifyValuesFormat()`, and `disableLogSending: true` on `WinstonInstrumentation` | Remove the transport and `disableLogSending`, and use `otel: true`. See [Setup](#setup). | +| A transport before 0.29 | Move the transport and the SDK to 0.29 or later. If you cannot move yet, stay on 2.x. | +| A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | - `@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`. npm 7 and later refuse to install alongside an older transport, and pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. -- `otelAttributesFormat` maps `audit` to `info` severity and writes `logLevel` by default. Pass `mapAuditLevel: false` to keep the 2.x output of `flatten: 'otel'` or of the format on a transport. With `otel: true`, `createLogger` throws if the logger, the console or a transport is set to `level: 'audit'`. -- The audit mapping runs last. With `otel`, a `loggerOptions.format` now sees `LEVEL: 'audit'`. In 2.x it saw `info`. +- `otel` shapes the OpenTelemetry transport only. The console and other transports keep nested values and `level: 'audit'`, so a local `flatten: false` is no longer needed. A logger or transport set to `level: 'audit'` is allowed. +- `otelAttributesFormat` maps `audit` to `info` severity and writes `logLevel` by default. Pass `mapAuditLevel: false` to keep the 2.x output of `flatten: 'otel'` without `mapAuditLevelForOtel`. - A `Date` under `otel` is its ISO string, unquoted. `flatten: true` wrote it as a quoted JSON string. New functionality: +- `otel` finds the transport `WinstonInstrumentation` adds, so the console stays readable while OpenTelemetry receives flat attributes. - `otelAttributesFormat` takes `mapAuditLevel` and `maxAttributeLength` options, beside its `replacer`. - `createErrorSerializer` builds an error serializer that can leave properties out of every error, or write a shared object once. diff --git a/eslint.config.mjs b/eslint.config.mjs index 81690fc..f5abf4c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,7 +40,7 @@ export default [ // that gets its logger from a second copy before the SDK registers its provider writes every line // to a no-op logger. Specs drive the real transport under npm aliases instead. files: ['src/**/*.ts'], - ignores: ['src/**/*.spec.ts'], + ignores: ['src/**/*.spec.ts', 'src/tests/**'], rules: { 'no-restricted-imports': [ 'error', diff --git a/package-lock.json b/package-lock.json index 33f33ee..20a3e53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "@makerx/ts-toolkit": "^5.0.0-beta.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/instrumentation-winston": "^0.68.1", "@opentelemetry/sdk-logs": "^0.223.0", + "@opentelemetry/winston-transport": "^0.34.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-typescript": "^12.3.0", "@tsconfig/node20": "20.1.9", @@ -40,7 +42,6 @@ "eslint-plugin-prettier": "^5.5.5", "npm-run-all": "^4.1.5", "otel-winston-transport-0.29": "npm:@opentelemetry/winston-transport@~0.29.0", - "otel-winston-transport-latest": "npm:@opentelemetry/winston-transport@^0.34.0", "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", @@ -973,7 +974,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -983,7 +984,7 @@ "version": "0.223.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.223.0.tgz", "integrity": "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -1008,6 +1009,41 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.223.0.tgz", + "integrity": "sha512-tttBo88TbwK1zpsH6VeJoxLQopxOjW3cnrP50fsQJRZftkSKDDwjolXXBvow2jy0LKXlNRkDQh0NT6yI/q+BEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-winston": { + "version": "0.68.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.68.1.tgz", + "integrity": "sha512-J+ko44pKaIqDF0xS5YbwHU0DlcrX6LTC3tjmJZdJNKiYkZ/xe7bI82Plk2VdNk8Ns4NfIPVUrMaYNQ6GySfTnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/instrumentation": "^0.223.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@opentelemetry/resources": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", @@ -1048,12 +1084,27 @@ "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" } }, + "node_modules/@opentelemetry/winston-transport": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.34.0.tgz", + "integrity": "sha512-0bfxiBP9KhPd1rKT4hf2ANrEqNW2QaN9N87rDBUzDJB14kztugWNT+1q0seix04PlbzqxAc0jJpOBmoyoY4E/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/semantic-conventions": "^1.41.1", + "winston-transport": "4.*" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.154.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", @@ -4423,6 +4474,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/import-in-the-middle/node_modules/cjs-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-in-the-middle/node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "dev": true, + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5600,6 +5680,13 @@ "node": ">=10" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6018,22 +6105,6 @@ "node": ">=8.0.0" } }, - "node_modules/otel-winston-transport-latest": { - "name": "@opentelemetry/winston-transport", - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/winston-transport/-/winston-transport-0.34.0.tgz", - "integrity": "sha512-0bfxiBP9KhPd1rKT4hf2ANrEqNW2QaN9N87rDBUzDJB14kztugWNT+1q0seix04PlbzqxAc0jJpOBmoyoY4E/Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.223.0", - "@opentelemetry/semantic-conventions": "^1.41.1", - "winston-transport": "4.*" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6465,6 +6536,20 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", diff --git a/package.json b/package.json index abf3460..62b8a13 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "@makerx/ts-toolkit": "^5.0.0-beta.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.223.0", + "@opentelemetry/instrumentation-winston": "^0.68.1", "@opentelemetry/sdk-logs": "^0.223.0", + "@opentelemetry/winston-transport": "^0.34.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-typescript": "^12.3.0", "@tsconfig/node20": "20.1.9", @@ -66,7 +68,6 @@ "eslint-plugin-prettier": "^5.5.5", "npm-run-all": "^4.1.5", "otel-winston-transport-0.29": "npm:@opentelemetry/winston-transport@~0.29.0", - "otel-winston-transport-latest": "npm:@opentelemetry/winston-transport@^0.34.0", "rimraf": "^6.1.3", "rollup": "4.60.2", "tslib": "^2.8.1", diff --git a/src/callback-transport.spec.ts b/src/callback-transport.spec.ts index 7fd8b92..850a60d 100644 --- a/src/callback-transport.spec.ts +++ b/src/callback-transport.spec.ts @@ -122,8 +122,8 @@ describe('CallbackTransport', () => { ), ) - // Simulate the `otel` audit mapping: LEVEL symbol rewritten from 'audit' to 'info', string - // `level` left untouched. winston-transport's stock filter would drop this; ours must not. + // As `mapAuditLevelForOtel` leaves a record: LEVEL rewritten from 'audit' to 'info', the string + // `level` untouched. winston-transport's stock filter would drop this record, and ours must not. const audit = { [LEVEL]: 'info', level: 'audit', message: 'audit-event' } as TransformableInfo transport.log!(audit, () => {}) @@ -139,13 +139,13 @@ describe('CallbackTransport', () => { () => {}, { level: 'audit' }, ) - // The constructor keeps `level` for itself, so `createLogger`'s `otel` audit guard (which reads - // `transport.level`) does not trip when the transport is configured for audit. + // The constructor keeps `level` for itself, so the base class's filter, which reads the LEVEL + // symbol, never runs. expect(transport.level).toBeUndefined() }) - it('routes audit records through createLogger with otel: true and level: audit', async () => { - const { createLogger } = await import('./index') + it('routes audit records at level audit after a logger-level mapAuditLevelForOtel', async () => { + const { createLogger, mapAuditLevelForOtel } = await import('./index') const received: TransformedInfo[] = [] const transport = new CallbackTransport( async (info) => { @@ -155,11 +155,11 @@ describe('CallbackTransport', () => { { level: 'audit' }, ) - // Without the level interception this combination would either throw at construction - // (createLogger guard) or silently drop the record (LEVEL rewrite + symbol-based filter). + // The mapping rewrites LEVEL before any transport filters, so a filter on LEVEL would drop the + // audit record. const logger = createLogger({ consoleOptions: { silent: true }, - otel: true, + loggerOptions: { format: mapAuditLevelForOtel() }, transports: [transport], }) diff --git a/src/callback-transport.ts b/src/callback-transport.ts index 268ed2a..bd0d16e 100644 --- a/src/callback-transport.ts +++ b/src/callback-transport.ts @@ -22,17 +22,14 @@ export type LogHandler = (info: TransformedInfo) => Promise * with the dispatch handler, an error sink for dispatch failures, and (optionally) the standard * winston-transport options. * - * Unlike the stock winston-transport `level` filter — which compares the triple-beam `LEVEL` - * symbol — this transport filters on the string `info.level` instead. That makes the `level` - * option safe to combine with `createLogger({ otel: true })`, whose audit mapping rewrites the - * `LEVEL` symbol but leaves `info.level` alone. The option is intercepted in the constructor (not - * forwarded to the base class), so `createLogger`'s `otel` + `level: 'audit'` guard does not trip - * on it. + * The stock winston-transport `level` filter compares the triple-beam `LEVEL` symbol. This transport + * filters on the string `info.level` instead, so its `level` option stays accurate when a + * logger-level format such as `mapAuditLevelForOtel` rewrites the `LEVEL` symbol. The constructor + * keeps the option and does not pass it to the base class, so the base filter never runs. * * @example * ```ts * const logger = createLogger({ - * otel: true, * transports: [ * new CallbackTransport( * ({ level, message, meta }) => sendToAuditEndpoint({ level, message, ...meta }), diff --git a/src/index.spec.ts b/src/index.spec.ts index 1a01082..9452aaa 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -7,7 +7,6 @@ import { createLogger, defaultLevelColors, defaultLevels, - otelAttributesFormat, winstonDefaultLevelColors, winstonDefaultLevels, } from './index' @@ -170,7 +169,7 @@ describe('createLogger Date metadata', () => { }) it('otel writes a Date in metadata as its ISO string, unquoted', () => { - const transport = new InMemoryTransport({}) + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true }) logger.info('db query', { parameters: [when, 1], at: when }) const info = transport.logs[0] as Record @@ -313,33 +312,27 @@ describe('createLogger child loggers', () => { }) describe('createLogger otel', () => { - it('stringifies object and array values and leaves scalars alone', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ - consoleOptions: { silent: true }, - transports: [transport], - otel: true, - }) - logger.info('hello', { obj: { a: 1 }, arr: [1, 2], count: 3, flag: true, text: 'literal' }) - const info = transport.logs[0] as Record - expect(info.obj).toBe('{"a":1}') - expect(info.arr).toBe('[1,2]') - expect(info.count).toBe(3) - expect(info.flag).toBe(true) - expect(info.text).toBe('literal') - }) - - it('keeps an error an object holding what the transport records as the exception', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true }) - logger.error('failed', { error: Object.assign(new Error('boom'), { statusCode: 500 }) }) - const info = transport.logs[0] as Record - expect(info.error).toMatchObject({ name: 'Error', message: 'boom' }) - expect(JSON.parse(info.errorDetail as string)).toEqual({ statusCode: 500 }) + it('gives the format to an OpenTelemetry transport and leaves other transports nested', () => { + const otelTransport = new OpenTelemetryTransportV3({}) + const sibling = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [otelTransport, sibling], otel: true }) + logger.error('failed', { error: Object.assign(new Error('boom'), { statusCode: 500 }), obj: { a: 1 }, count: 3 }) + expect(otelTransport.logs[0]).toMatchObject({ obj: '{"a":1}', count: 3, error: { name: 'Error', message: 'boom' } }) + expect(JSON.parse(otelTransport.logs[0].errorDetail as string)).toEqual({ statusCode: 500 }) + expect(sibling.logs[0]).toMatchObject({ obj: { a: 1 }, error: { message: 'boom', statusCode: 500 } }) + expect(sibling.logs[0]).not.toHaveProperty('errorDetail') + }) + + it('leaves an OpenTelemetry transport that has its own format alone', () => { + const own = format((info) => Object.assign(info, { shapedBy: 'own' })) + const otelTransport = new OpenTelemetryTransportV3({ format: own() }) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [otelTransport], otel: true }) + logger.info('hello', { obj: { a: 1 } }) + expect(otelTransport.logs[0]).toMatchObject({ shapedBy: 'own', obj: { a: 1 } }) }) it('passes its options to the format, replacer included', () => { - const transport = new InMemoryTransport({}) + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], @@ -350,15 +343,23 @@ describe('createLogger otel', () => { expect(JSON.parse(data)).toEqual({ secret: '', other: 'def' }) }) - it('redacts inside an error with redactPaths before flattening it', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true, redactPaths: ['body'] }) + it('shapes the record after the logger-level formats, redaction and loggerOptions.format included', () => { + const transport = new OpenTelemetryTransportV3({}) + const inject = format((info) => Object.assign(info, { injected: { a: 1 } })) + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + otel: true, + redactPaths: ['body'], + loggerOptions: { format: inject() }, + }) logger.error('failed', { error: Object.assign(new Error('boom'), { body: { prompt: 'secret' }, statusCode: 500 }) }) expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ body: '', statusCode: 500 }) + expect(transport.logs[0].injected).toBe('{"a":1}') }) it('leaves out error properties with an errorSerializer from createErrorSerializer', () => { - const transport = new InMemoryTransport({}) + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], @@ -369,24 +370,8 @@ describe('createLogger otel', () => { expect(JSON.parse(transport.logs[0].errorDetail as string)).toEqual({ statusCode: 500 }) }) - it('runs after loggerOptions.format so injected object values are stringified', () => { - const transport = new InMemoryTransport({}) - const injectFormat = format((info) => { - info.injected = { a: 1 } - return info - }) - const logger = createLogger({ - consoleOptions: { silent: true }, - transports: [transport], - otel: true, - loggerOptions: { format: injectFormat() }, - }) - logger.info('hello') - expect((transport.logs[0] as { injected: unknown }).injected).toBe('{"a":1}') - }) - - it('leaves records nested without it', () => { - const transport = new InMemoryTransport({}) + it('leaves the OpenTelemetry transport nested without it', () => { + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) logger.info('hello', { obj: { a: 1 } }) expect(transport.logs[0].obj).toEqual({ a: 1 }) @@ -394,56 +379,35 @@ describe('createLogger otel', () => { }) describe('createLogger otel audit level', () => { - it('rewrites the LEVEL symbol from audit to info, preserves the original on logLevel, and leaves the string level alone', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ - consoleOptions: { silent: true }, - transports: [transport], - otel: true, - }) + it('exports audit at info on the OpenTelemetry transport only, with logLevel audit', () => { + const otelTransport = new OpenTelemetryTransportV3({}) + const sibling = new InMemoryTransport({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [otelTransport, sibling], otel: true }) logger.audit('audit-event', { actor: 'user-1' }) - const info = transport.logs[0] as Record - expect(info[LEVEL]).toBe('info') - expect(info.level).toBe('audit') - expect(info.logLevel).toBe('audit') - expect(info.actor).toBe('user-1') + expect(otelTransport.logs[0]).toMatchObject({ level: 'audit', logLevel: 'audit', actor: 'user-1' }) + expect((otelTransport.logs[0] as Record)[LEVEL]).toBe('info') + expect((sibling.logs[0] as Record)[LEVEL]).toBe('audit') + expect(sibling.logs[0]).not.toHaveProperty('logLevel') }) - it('leaves non-audit levels untouched but still records logLevel', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ - consoleOptions: { silent: true }, - transports: [transport], - otel: true, - }) + it('records logLevel on every other level and leaves its LEVEL alone', () => { + const transport = new OpenTelemetryTransportV3({}) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: true }) logger.warn('heads-up') - const info = transport.logs[0] as Record - expect(info.level).toBe('warn') - expect(info[LEVEL]).toBe('warn') - expect(info.logLevel).toBe('warn') + expect(transport.logs[0].logLevel).toBe('warn') + expect((transport.logs[0] as Record)[LEVEL]).toBe('warn') }) - it('without otel, audit records keep their level and no logLevel is added', () => { - const transport = new InMemoryTransport({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) - logger.audit('audit-event') - const info = transport.logs[0] as Record - expect(info.level).toBe('audit') - expect(info[LEVEL]).toBe('audit') - expect(info.logLevel).toBeUndefined() - }) - - it('with mapAuditLevel: false, audit records keep their LEVEL', () => { - const transport = new InMemoryTransport({}) + it('maps nothing with mapAuditLevel: false', () => { + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: { mapAuditLevel: false } }) logger.audit('audit-event') - const info = transport.logs[0] as Record - expect(info[LEVEL]).toBe('audit') - expect(info.logLevel).toBeUndefined() + expect((transport.logs[0] as Record)[LEVEL]).toBe('audit') + expect(transport.logs[0]).not.toHaveProperty('logLevel') }) it('maps audit after a loggerOptions.format that colorizes the string level', () => { - const transport = new InMemoryTransport({}) + const transport = new OpenTelemetryTransportV3({}) const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], @@ -451,70 +415,24 @@ describe('createLogger otel audit level', () => { loggerOptions: { format: format.colorize() }, }) logger.audit('audit-event') - const info = transport.logs[0] as Record - expect(info[LEVEL]).toBe('info') - expect(info.logLevel).toBe('audit') + expect((transport.logs[0] as Record)[LEVEL]).toBe('info') + expect(transport.logs[0].logLevel).toBe('audit') }) - it('runs after loggerOptions.format, which still sees LEVEL audit', () => { - const seen: unknown[] = [] - const spy = format((info) => { - seen.push(info[LEVEL]) - return info + it('keeps audit records when the logger, the console or the OpenTelemetry transport is set to level audit', () => { + const otelTransport = new OpenTelemetryTransportV3({ level: 'audit' }) + const other = new InMemoryTransport({ level: 'audit' }) + const logger = createLogger({ + consoleOptions: { silent: true, level: 'audit' }, + transports: [otelTransport, other], + otel: true, + loggerOptions: { level: 'audit' }, }) - const logger = createLogger({ consoleOptions: { silent: true }, otel: true, loggerOptions: { format: spy() } }) - logger.audit('audit-event') - expect(seen).toEqual(['audit']) - }) - - it('throws when loggerOptions.level is audit (records would be silently dropped after rewrite)', () => { - expect(() => - createLogger({ - otel: true, - consoleOptions: { silent: true }, - loggerOptions: { level: 'audit' }, - }), - ).toThrow(/loggerOptions\.level/) - }) - - it('throws when consoleOptions.level is audit', () => { - expect(() => - createLogger({ - otel: true, - consoleOptions: { level: 'audit', silent: true }, - }), - ).toThrow(/consoleOptions\.level/) - }) - - it('throws when any supplied transport has level audit', () => { - const transport = new InMemoryTransport({ level: 'audit' }) - expect(() => - createLogger({ - otel: true, - consoleOptions: { silent: true }, - transports: [transport], - }), - ).toThrow(/transports\[0\]\.level/) - }) - - it('does not throw for level audit with mapAuditLevel: false', () => { - expect(() => - createLogger({ otel: { mapAuditLevel: false }, consoleOptions: { silent: true }, loggerOptions: { level: 'audit' } }), - ).not.toThrow() - }) - - it('keeps audit records on an audit-level transport when the format is on the transport, and leaves siblings at audit', () => { - const otelTransport = new InMemoryTransport({ level: 'audit', format: otelAttributesFormat() }) - const sibling = new InMemoryTransport({ level: 'audit' }) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [otelTransport, sibling] }) logger.audit('audit-event') logger.info('info-event') expect(otelTransport.logs.map((info) => info.message)).toEqual(['audit-event']) - expect((otelTransport.logs[0] as Record)[LEVEL]).toBe('info') expect(otelTransport.logs[0].logLevel).toBe('audit') - expect(sibling.logs.map((info) => info.message)).toEqual(['audit-event']) - expect((sibling.logs[0] as Record)[LEVEL]).toBe('audit') - expect(sibling.logs[0].logLevel).toBeUndefined() + expect(other.logs.map((info) => info.message)).toEqual(['audit-event']) }) }) @@ -833,3 +751,6 @@ class InMemoryTransport extends TransportStream { close(): void {} } + +/** Named as `@opentelemetry/winston-transport`'s class is, which is how `otel` finds the transport. */ +class OpenTelemetryTransportV3 extends InMemoryTransport {} diff --git a/src/index.ts b/src/index.ts index d82bda5..3aea2f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -153,17 +153,14 @@ export interface CreateLoggerOptions { redactedValue?: string /** - * Shapes every record for `@opentelemetry/winston-transport` 0.29 and later, by appending - * {@link otelAttributesFormat} as the last logger-level format. `true` uses the format's - * defaults; an object is passed to it as its options. + * Shapes what `@opentelemetry/winston-transport` 0.29 and later receives. Each + * `OpenTelemetryTransportV3` on the logger that has no format of its own gets + * {@link otelAttributesFormat}: the transport `WinstonInstrumentation` adds, and any passed in + * `transports`. Other transports, the console included, keep their nested output. `true` uses the + * format's defaults, and an object is passed to the format as its options. * - * Use this when the transport is added for you, as `WinstonInstrumentation` does. It changes what - * every transport receives, the console included. When you construct the OpenTelemetry transport - * yourself, put `otelAttributesFormat()` on that transport instead and leave this unset. - * - * The format exports an `audit` record at `info` severity. At the logger level that happens - * before every transport's level filter, so `createLogger` throws if the logger, the console or a - * transport is set to `level: 'audit'`, unless `mapAuditLevel` is `false`. + * The format runs after the transport's level filter, so its audit mapping never drops a record. + * A transport added later with `logger.add` is not shaped: give it `otelAttributesFormat()`. */ otel?: boolean | OtelAttributesFormatOptions @@ -181,8 +178,7 @@ export interface CreateLoggerOptions { /** * Winston `LoggerOptions` forwarded to the underlying logger (e.g. `level`, `defaultMeta`). * `transports` is managed by this library; pass extras via `transports` instead. - * A `format` provided here is appended after the library's logger-level formats, but still runs - * before `otel` (which is always last when enabled). + * A `format` provided here is appended after the library's logger-level formats. */ loggerOptions?: Omit } @@ -199,11 +195,29 @@ const rejectRemovedOptions = (options: CreateLoggerOptions) => { const given = REMOVED_OPTIONS.filter((key) => (options as Record)[key] !== undefined) if (given.length === 0) return throw new Error( - `createLogger no longer accepts ${given.join(', ')}. Use the \`otel\` option, or put otelAttributesFormat() ` + - `on the OpenTelemetry transport. See "Migrating from v2" in the @makerx/node-winston README.`, + `createLogger no longer accepts ${given.join(', ')}. Use the \`otel\` option. ` + + `See "Migrating from v2" in the @makerx/node-winston README.`, ) } +/** + * The class `@opentelemetry/winston-transport` exports. It is matched by name because this library + * does not depend on the transport, which is an optional peer. + */ +const OTEL_TRANSPORT_CLASS = 'OpenTelemetryTransportV3' + +/** + * Gives each OpenTelemetry transport on the logger that has no format of its own the + * `otelAttributesFormat`. `WinstonInstrumentation` adds its transport while winston configures the + * logger, so it is on the logger by the time winston's `createLogger` returns. + */ +const shapeOtelTransports = (logger: WinstonLogger, otelOptions: OtelAttributesFormatOptions) => { + const otelFormat = otelAttributesFormat(otelOptions) + for (const transport of logger.transports) { + if (transport.constructor?.name === OTEL_TRANSPORT_CLASS && !transport.format) transport.format = otelFormat + } +} + export const createConsoleTransport = ({ consoleFormat = 'json', consoleOptions, @@ -248,28 +262,6 @@ export function createLogger(options: CreateLoggerOptions): any { if (!userLevels || userLevels === defaultLevels) registerDefaultColors() else if (userLevels === winstonDefaultLevels) registerWinstonDefaultColors() - // Guard against silent audit-record loss: at the logger level the format rewrites the LEVEL symbol - // from 'audit' to 'info' before any transport filters, so a filter set to level:'audit' would then - // drop the record (info is more verbose than audit). On a transport's own format the filter runs - // first, so only this placement needs the guard. - if (otelOptions && otelOptions.mapAuditLevel !== false) { - const misconfigured: string[] = [] - if (options.loggerOptions?.level === 'audit') misconfigured.push('loggerOptions.level') - if (options.consoleOptions?.level === 'audit') misconfigured.push('consoleOptions.level') - options.transports?.forEach((t, i) => { - if (t.level === 'audit') misconfigured.push(`transports[${i}].level`) - }) - if (misconfigured.length > 0) { - throw new Error( - `Set ${misconfigured.join(', ')} to 'info' (or a more verbose level) instead of 'audit'. ` + - `With \`otel\` set, every record's triple-beam LEVEL is mapped from 'audit' to 'info' before ` + - `any transport filters it, so an 'audit'-level filter would drop audit records. To keep an ` + - `'audit'-level transport, put otelAttributesFormat() on the OpenTelemetry transport instead ` + - `of setting \`otel\`, or pass otel: { mapAuditLevel: false }.`, - ) - } - } - // aggregate transports const consoleTransport = createConsoleTransport(options) const transports: Transport[] = [consoleTransport] @@ -284,8 +276,6 @@ export function createLogger(options: CreateLoggerOptions): any { // path ever does let one through. if (redactPaths) loggerFormats.push(redactFormat({ paths: redactPaths, redactedValue, errorSerializer })) if (options.loggerOptions?.format) loggerFormats.push(options.loggerOptions.format) - // otel is applied last so all prior transformations are captured in the stringified values - if (otelOptions) loggerFormats.push(otelAttributesFormat(otelOptions)) // create logger options using supplied options + transports; loggerOptions.levels (if any) overrides defaultLevels const loggerOptions: LoggerOptions = { @@ -295,7 +285,10 @@ export function createLogger(options: CreateLoggerOptions): any { transports, } + const logger = winstonCreateLogger(loggerOptions) + if (otelOptions) shapeOtelTransports(logger, otelOptions) + // Wrapped rather than returned bare: winston branches three ways on an `Error` argument, and only // one of the shapes it produces needs no repair. See {@link withNormalizedErrorArgs}. - return withNormalizedErrorArgs(winstonCreateLogger(loggerOptions), Object.keys(loggerOptions.levels ?? defaultLevels)) + return withNormalizedErrorArgs(logger, Object.keys(loggerOptions.levels ?? defaultLevels)) } diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index bb65329..27ac447 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -2,23 +2,23 @@ * What a record looks like once it is an OpenTelemetry log record, through the real * `@opentelemetry/winston-transport` and the logs SDK — the row a backend stores. * - * The supported transport line, installed side by side under npm aliases: - * - 0.29: the oldest release that lifts `err`/`error` off the record as the exception. - * - latest: also normalises object attributes into nested maps (from 0.32), and takes - * `severityMapping` and `logSeverity` options (from 0.34). + * The supported transport line, installed side by side: + * - 0.29, under an npm alias: the oldest release that lifts `err`/`error` off the record as the + * exception. + * - latest, under its own name: also normalises object attributes into nested maps (from 0.32), + * and takes `severityMapping` and `logSeverity` options (from 0.34). * * Each transport binds its logger when it is constructed, so the provider is registered first. */ import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { InMemoryLogRecordExporter, LoggerProvider, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs' import { OpenTelemetryTransportV3 as Transport029 } from 'otel-winston-transport-0.29' -import { OpenTelemetryTransportV3 as TransportLatest } from 'otel-winston-transport-latest' +import { OpenTelemetryTransportV3 as TransportLatest } from '@opentelemetry/winston-transport' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { format } from 'winston' import type TransportStream from 'winston-transport' import { CallbackTransport, createErrorSerializer, createLogger, type CreateLoggerOptions, type Logger } from './index' import { jsonStringifyValuesFormat } from './json-stringify-values-format' -import { otelAttributesFormat } from './otel-attributes-format' type TransportClass = new (options?: TransportStream.TransportStreamOptions) => TransportStream @@ -172,16 +172,14 @@ describe.each(lines)('otel: true on %s', (_, Transport) => { expect(record.attributes['exception.type']).toBe('Alice') expect(record.attributes.name).toBe('Alice') }) -}) -describe.each(lines)('otelAttributesFormat on the transport on %s', (_, Transport) => { - it('flattens what the OpenTelemetry transport receives and leaves other transports nested', async () => { + it('leaves other transports nested', async () => { const seen: Record[] = [] const sibling = new CallbackTransport( async ({ meta }) => void seen.push(meta), () => undefined, ) - const [record] = await emitted({ transports: [new Transport({ format: otelAttributesFormat() }), sibling] }, (logger, message) => + const [record] = await emitted({ otel: true, transports: [new Transport(), sibling] }, (logger, message) => logger.error(message, { error: failure(), request: { route: '/chat' } }), ) expect(record.attributes).toMatchObject({ 'exception.type': 'ProviderError', request: '{"route":"/chat"}' }) @@ -190,8 +188,7 @@ describe.each(lines)('otelAttributesFormat on the transport on %s', (_, Transpor }) it('exports audit records at INFO severity from a transport set to level audit', async () => { - const transport = new Transport({ level: 'audit', format: otelAttributesFormat() }) - const records = await emitted({ transports: [transport] }, (logger, message) => { + const records = await emitted({ otel: true, transports: [new Transport({ level: 'audit' })] }, (logger, message) => { logger.audit(message) logger.info(message) }) @@ -199,8 +196,10 @@ describe.each(lines)('otelAttributesFormat on the transport on %s', (_, Transpor expect(records[0].severityNumber).toBe(SeverityNumber.INFO) expect(records[0].attributes.logLevel).toBe('audit') }) +}) - it('records the whole error JSON as exception.message with jsonStringifyValuesFormat, which is why it is not for 0.29 and later', async () => { +describe.each(lines)('jsonStringifyValuesFormat on %s', (_, Transport) => { + it('records the whole error JSON as exception.message, which is why it is not for the OpenTelemetry transport', async () => { const attributes = await exported(Transport, {}, new Transport({ format: jsonStringifyValuesFormat() })) expect(String(attributes['exception.message']).startsWith('{')).toBe(true) expect(attributes).not.toHaveProperty('exception.type') @@ -216,24 +215,23 @@ describe('without otel on latest', () => { }) describe('transport severity options on latest', () => { - it('severityMapping keeps severityText audit when the format leaves the level alone', async () => { - const transport = new TransportLatest({ - format: otelAttributesFormat({ mapAuditLevel: false }), - severityMapping: { audit: SeverityNumber.INFO }, - }) - const [record] = await emitted({ transports: [transport] }, (logger, message) => logger.audit(message)) + it('severityMapping keeps severityText audit when otel leaves the level alone', async () => { + const transport = new TransportLatest({ severityMapping: { audit: SeverityNumber.INFO } }) + const [record] = await emitted({ otel: { mapAuditLevel: false }, transports: [transport] }, (logger, message) => logger.audit(message)) expect(record.severityNumber).toBe(SeverityNumber.INFO) expect(record.severityText).toBe('audit') expect(record.attributes).not.toHaveProperty('logLevel') }) it('logSeverity drops an audit record nothing maps', async () => { - const transport = new TransportLatest({ format: otelAttributesFormat({ mapAuditLevel: false }), logSeverity: SeverityNumber.INFO }) - expect(await emitted({ transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(0) + const transport = new TransportLatest({ logSeverity: SeverityNumber.INFO }) + expect( + await emitted({ otel: { mapAuditLevel: false }, transports: [transport] }, (logger, message) => logger.audit(message)), + ).toHaveLength(0) }) - it('logSeverity keeps an audit record the format maps', async () => { - const transport = new TransportLatest({ format: otelAttributesFormat(), logSeverity: SeverityNumber.INFO }) - expect(await emitted({ transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(1) + it('logSeverity keeps an audit record otel maps', async () => { + const transport = new TransportLatest({ logSeverity: SeverityNumber.INFO }) + expect(await emitted({ otel: true, transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(1) }) }) diff --git a/src/tests/winston-instrumentation.fixture.ts b/src/tests/winston-instrumentation.fixture.ts new file mode 100644 index 0000000..8d1c163 --- /dev/null +++ b/src/tests/winston-instrumentation.fixture.ts @@ -0,0 +1,53 @@ +/** + * Run in its own process by ../winston-instrumentation.spec.ts, because the instrumentation patches + * winston only when it is registered before winston loads. Set up as an app's tracing module is, + * then logs through `createLogger({ otel: true })` and prints what each side received as JSON. + */ +import { register } from 'node:module' +import { logs } from '@opentelemetry/api-logs' +import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston' +import { InMemoryLogRecordExporter, LoggerProvider, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs' + +const exporter = new InMemoryLogRecordExporter() +logs.setGlobalLoggerProvider(new LoggerProvider({ processors: [new SimpleLogRecordProcessor({ exporter })] })) +new WinstonInstrumentation() +// This package is ESM, so winston is loaded by `import`, which only this hook can patch. `include` +// limits the hook to winston, so it does not try to wrap this package's TypeScript source. +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url, { data: { include: ['winston'] } }) + +// Loaded after the instrumentation, so winston loads after it too. +const { CallbackTransport, createLogger } = await import('../index') + +class ProviderError extends Error { + readonly name = 'ProviderError' + readonly statusCode = 413 +} + +const sibling: Record[] = [] +const logger = createLogger({ + otel: true, + consoleOptions: { silent: true }, + transports: [ + new CallbackTransport( + async ({ level, meta }) => void sibling.push({ level, ...meta }), + () => undefined, + ), + ], +}) + +logger.audit('audit line', { actor: 'user-1' }) +logger.error('error line', { error: new ProviderError('Request too large'), request: { route: '/chat' } }) +await new Promise((resolve) => setImmediate(resolve)) + +process.stdout.write( + JSON.stringify({ + transports: (logger as unknown as { transports: object[] }).transports.map((transport) => transport.constructor.name), + records: exporter.getFinishedLogRecords().map(({ body, severityNumber, severityText, attributes }) => ({ + body, + severityNumber, + severityText, + attributes, + })), + sibling, + }), +) diff --git a/src/winston-instrumentation.spec.ts b/src/winston-instrumentation.spec.ts new file mode 100644 index 0000000..5a0696f --- /dev/null +++ b/src/winston-instrumentation.spec.ts @@ -0,0 +1,51 @@ +/** + * The README's setup end to end: `WinstonInstrumentation` adds the OpenTelemetry transport to the + * logger, and `createLogger({ otel: true })` gives that transport the format. The fixture runs in its + * own process, because the instrumentation must be registered before winston loads. + */ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { beforeAll, describe, expect, it } from 'vitest' + +interface Exported { + transports: string[] + records: { body: string; severityNumber: number; severityText: string; attributes: Record }[] + sibling: Record[] +} + +const fixture = fileURLToPath(new URL('./tests/winston-instrumentation.fixture.ts', import.meta.url)) + +let exported: Exported + +beforeAll(() => { + const stdout = execFileSync(process.execPath, ['--import', 'tsx', fixture], { encoding: 'utf8' }) + exported = JSON.parse(stdout) as Exported +}, 60_000) + +describe('createLogger({ otel: true }) with WinstonInstrumentation', () => { + it('finds the transport the instrumentation adds', () => { + expect(exported.transports).toContain('OpenTelemetryTransportV3') + }) + + it('exports an audit record at INFO severity, with logLevel audit', () => { + const audit = exported.records.find((record) => record.body === 'audit line') + expect(audit).toMatchObject({ severityNumber: 9, attributes: { actor: 'user-1', logLevel: 'audit' } }) + }) + + it('records the error as the exception, with the rest of it under errorDetail', () => { + const error = exported.records.find((record) => record.body === 'error line') + expect(error?.attributes).toMatchObject({ + 'exception.type': 'ProviderError', + 'exception.message': 'Request too large', + errorDetail: '{"statusCode":413}', + request: '{"route":"/chat"}', + }) + }) + + it('leaves other transports nested, at their own level', () => { + expect(exported.sibling).toEqual([ + { level: 'audit', actor: 'user-1' }, + expect.objectContaining({ level: 'error', error: expect.objectContaining({ statusCode: 413 }), request: { route: '/chat' } }), + ]) + }) +}) From c85315e1b5abb6d4523147217c94a930c57c339f Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 10:46:55 +0800 Subject: [PATCH 09/13] docs: call audit the custom level, not winston's --- README.md | 2 +- src/map-audit-level-for-otel.ts | 2 +- src/otel-attributes-format.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 91bf789..1569283 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ Pass options as `otel: { … }`. ### Audit severity -OpenTelemetry's log spec has no severity for winston's `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity, and copies every record's level onto `logLevel` so that the original survives. In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. +OpenTelemetry's log spec has no severity for the custom `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity, and copies every record's level onto `logLevel` so that the original survives. In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. The format runs on the OpenTelemetry transport only, after that transport's level filter. A logger or transport set to `level: 'audit'` keeps audit records, and the console still shows `"level": "audit"`. diff --git a/src/map-audit-level-for-otel.ts b/src/map-audit-level-for-otel.ts index fce86f7..219af11 100644 --- a/src/map-audit-level-for-otel.ts +++ b/src/map-audit-level-for-otel.ts @@ -5,7 +5,7 @@ import { applyAuditLevelMapping } from './audit-level' * OTEL's log spec defines a fixed severity enumeration (trace/debug/info/warn/error/fatal). * `@opentelemetry/winston-transport` derives `severityText` / `severityNumber` from Winston's * triple-beam `LEVEL` symbol and strips the string `level` property before building attributes, - * so Winston's custom `audit` level arrives at an OTEL backend with no severity and no queryable + * so the custom `audit` level arrives at an OTEL backend with no severity and no queryable * record of the original level. Azure Monitor / Log Analytics ignores records without a mapped * severity, and a transport `logSeverity` filter drops them. * diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index d434c53..a3e8028 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -174,7 +174,7 @@ const splitException = (record: Record, key: string, error: Rec * turn them into nested map attributes, which most backends — Azure Log Analytics included — do not * index. Scalars are left alone. A `Date` becomes its ISO string, unquoted. * - * OpenTelemetry has no severity for winston's `audit` level, so by default the format exports an + * OpenTelemetry has no severity for the custom `audit` level, so by default the format exports an * `audit` record at `info` severity, with every record's level on `logLevel`. See * {@link OtelAttributesFormatOptions} to turn that off, cap the strings it writes, or pass a replacer. * From 419e92ec081d6bae1487a191b9804dee20b3717d Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 10:56:03 +0800 Subject: [PATCH 10/13] docs: drop the severityMapping alternative to mapAuditLevel Azure Monitor keeps the severity number but not severityText, so the alternative lost the audit/info distinction that logLevel keeps. --- README.md | 9 --------- src/otel-attributes-format.ts | 3 +-- src/otel-transport.spec.ts | 8 -------- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/README.md b/README.md index 1569283..39009e3 100644 --- a/README.md +++ b/README.md @@ -315,15 +315,6 @@ OpenTelemetry's log spec has no severity for the custom `audit` level. Azure Mon The format runs on the OpenTelemetry transport only, after that transport's level filter. A logger or transport set to `level: 'audit'` keeps audit records, and the console still shows `"level": "audit"`. -From `@opentelemetry/instrumentation-winston` 0.68 and transport 0.34, the transport can map the level itself and keep `severityText: 'audit'`. Give the instrumentation a `severityMapping`, and turn the format's mapping off: - -```ts -new WinstonInstrumentation({ severityMapping: { audit: SeverityNumber.INFO } }) // SeverityNumber from @opentelemetry/api-logs -createLogger({ otel: { mapAuditLevel: false } }) -``` - -Turn the mapping off only together with a `severityMapping` for `audit`. Without one, an `audit` record has no severity, and Azure Monitor or a `logSeverity` filter drops it. - ### Record size and privacy Three options control what a record holds, for errors from any library: diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index a3e8028..0c3ec01 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -18,8 +18,7 @@ export interface OtelAttributesFormatOptions { /** * Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. - * Defaults to `true`. See `mapAuditLevelForOtel` for why. Turn it off for a level map with no - * `audit`, or when the transport maps severity itself (`severityMapping`, from transport 0.34). + * Defaults to `true`. See `mapAuditLevelForOtel` for why. */ mapAuditLevel?: boolean diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index 27ac447..04ca7dc 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -215,14 +215,6 @@ describe('without otel on latest', () => { }) describe('transport severity options on latest', () => { - it('severityMapping keeps severityText audit when otel leaves the level alone', async () => { - const transport = new TransportLatest({ severityMapping: { audit: SeverityNumber.INFO } }) - const [record] = await emitted({ otel: { mapAuditLevel: false }, transports: [transport] }, (logger, message) => logger.audit(message)) - expect(record.severityNumber).toBe(SeverityNumber.INFO) - expect(record.severityText).toBe('audit') - expect(record.attributes).not.toHaveProperty('logLevel') - }) - it('logSeverity drops an audit record nothing maps', async () => { const transport = new TransportLatest({ logSeverity: SeverityNumber.INFO }) expect( From 2281309af988f0c91202c28b7a21abef62e256d0 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 10:59:32 +0800 Subject: [PATCH 11/13] feat!: remove the mapAuditLevel option otelAttributesFormat always writes logLevel and exports audit at info severity. The rewrite only touches a record at the audit level, so a logger without one has nothing rewritten. Turning it off only lost logLevel, which App Insights needs to tell audit from info and debug from verbose, or left audit records with no severity. --- README.md | 11 ++++++----- src/index.spec.ts | 15 ++++++++++----- src/otel-attributes-format.spec.ts | 8 +------- src/otel-attributes-format.ts | 17 +++++------------ src/otel-transport.spec.ts | 10 ++++------ 5 files changed, 26 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 39009e3..8fcc390 100644 --- a/README.md +++ b/README.md @@ -305,13 +305,14 @@ Pass options as `otel: { … }`. | Option | Default | Description | | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mapAuditLevel` | `true` | Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. See [Audit severity](#audit-severity). | | `maxAttributeLength` | none | The longest string the format writes. A longer one is cut short and ends in `[Truncated]`. By default every string is written whole. Strings the record already held are never cut. | | `replacer` | none | `JSON.stringify` replacer for every value the format stringifies, `errorDetail` included. It is called with the object that holds the value as `this`, so it can decide on a property from the other properties of that object. | ### Audit severity -OpenTelemetry's log spec has no severity for the custom `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity, and copies every record's level onto `logLevel` so that the original survives. In Azure Log Analytics, query it as `customDimensions.logLevel == "audit"`. +OpenTelemetry's log spec has no severity for the custom `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity. A logger whose levels have no `audit` has nothing to rewrite. + +The format also copies every record's level onto `logLevel`. App Insights stores only a coarse severity, which puts `audit` with `info` and `debug` with `verbose`, so `logLevel` is how to tell them apart. In Azure Log Analytics, query `customDimensions.logLevel == "audit"`. The format runs on the OpenTelemetry transport only, after that transport's level filter. A logger or transport set to `level: 'audit'` keeps audit records, and the console still shows `"level": "audit"`. @@ -494,7 +495,7 @@ Breaking changes: | 2.x | 3.0 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `flatten: 'otel'` or `flatten: true`, with `mapAuditLevelForOtel: true` | `otel: true` | -| `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: { mapAuditLevel: false }` for the same attributes, or `otel: true` to also map `audit` | +| `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: true`. Records also get `logLevel`, and `audit` is exported at `info` severity. | | `flattenReplacer: r` | `otel: { replacer: r }` | | An `OpenTelemetryTransportV3` you add yourself, with `otelAttributesFormat()` or `jsonStringifyValuesFormat()`, and `disableLogSending: true` on `WinstonInstrumentation` | Remove the transport and `disableLogSending`, and use `otel: true`. See [Setup](#setup). | | A transport before 0.29 | Move the transport and the SDK to 0.29 or later. If you cannot move yet, stay on 2.x. | @@ -502,13 +503,13 @@ Breaking changes: - `@opentelemetry/winston-transport` is an optional peer dependency at `>=0.29`. npm 7 and later refuse to install alongside an older transport, and pnpm and Yarn warn. Consumers that do not ship logs through OpenTelemetry are unaffected. - `otel` shapes the OpenTelemetry transport only. The console and other transports keep nested values and `level: 'audit'`, so a local `flatten: false` is no longer needed. A logger or transport set to `level: 'audit'` is allowed. -- `otelAttributesFormat` maps `audit` to `info` severity and writes `logLevel` by default. Pass `mapAuditLevel: false` to keep the 2.x output of `flatten: 'otel'` without `mapAuditLevelForOtel`. +- `otelAttributesFormat` always writes `logLevel` and exports `audit` at `info` severity. In 2.x, `flatten: 'otel'` did neither without `mapAuditLevelForOtel`. - A `Date` under `otel` is its ISO string, unquoted. `flatten: true` wrote it as a quoted JSON string. New functionality: - `otel` finds the transport `WinstonInstrumentation` adds, so the console stays readable while OpenTelemetry receives flat attributes. -- `otelAttributesFormat` takes `mapAuditLevel` and `maxAttributeLength` options, beside its `replacer`. +- `otelAttributesFormat` takes a `maxAttributeLength` option, beside its `replacer`. - `createErrorSerializer` builds an error serializer that can leave properties out of every error, or write a shared object once. ## Migrating from v1 diff --git a/src/index.spec.ts b/src/index.spec.ts index 9452aaa..289cdce 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -398,12 +398,17 @@ describe('createLogger otel audit level', () => { expect((transport.logs[0] as Record)[LEVEL]).toBe('warn') }) - it('maps nothing with mapAuditLevel: false', () => { + it('records logLevel and rewrites nothing for a logger whose own levels have no audit', () => { const transport = new OpenTelemetryTransportV3({}) - const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], otel: { mapAuditLevel: false } }) - logger.audit('audit-event') - expect((transport.logs[0] as Record)[LEVEL]).toBe('audit') - expect(transport.logs[0]).not.toHaveProperty('logLevel') + const logger = createLogger({ + consoleOptions: { silent: true }, + transports: [transport], + otel: true, + loggerOptions: { levels: { error: 0, info: 1, trace: 2 }, level: 'trace' }, + }) + logger.trace('trace-event') + expect((transport.logs[0] as Record)[LEVEL]).toBe('trace') + expect(transport.logs[0].logLevel).toBe('trace') }) it('maps audit after a loggerOptions.format that colorizes the string level', () => { diff --git a/src/otel-attributes-format.spec.ts b/src/otel-attributes-format.spec.ts index 992d4f5..071e56d 100644 --- a/src/otel-attributes-format.spec.ts +++ b/src/otel-attributes-format.spec.ts @@ -103,7 +103,7 @@ describe('otelAttributesFormat — other values', () => { }) describe('otelAttributesFormat — level', () => { - const at = (level: string, opts?: OtelAttributesFormatOptions) => run({ [LEVEL]: level, level }, opts) + const at = (level: string) => run({ [LEVEL]: level, level }) it('exports an audit record at info severity and keeps the original on logLevel', () => { const result = at('audit') @@ -127,12 +127,6 @@ describe('otelAttributesFormat — level', () => { it('keeps a string logLevel the record already has', () => { expect(run({ [LEVEL]: 'audit', level: 'audit', logLevel: 'custom' }).logLevel).toBe('custom') }) - - it('maps nothing with mapAuditLevel: false', () => { - const result = at('audit', { mapAuditLevel: false }) - expect(result[LEVEL]).toBe('audit') - expect(result).not.toHaveProperty('logLevel') - }) }) /** A serialized error holding other errors: a cause, a list of attempts, and the latest attempt again. */ diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 0c3ec01..5b290fa 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -16,12 +16,6 @@ export interface OtelAttributesFormatOptions { */ replacer?: Replacer - /** - * Exports an `audit` record at `info` severity and copies every record's level onto `logLevel`. - * Defaults to `true`. See `mapAuditLevelForOtel` for why. - */ - mapAuditLevel?: boolean - /** * The longest string this format writes. A longer one is cut short and ends in `[Truncated]`. Set * it to the backend's limit so that the backend does not cut the value without a marker. By @@ -39,13 +33,11 @@ export interface OtelAttributesFormatOptions { interface Settings { replacer: Replacer | undefined maxAttributeLength: number - mapAuditLevel: boolean } const resolveSettings = (opts: OtelAttributesFormatOptions): Settings => ({ replacer: opts.replacer, maxAttributeLength: opts.maxAttributeLength ?? Infinity, - mapAuditLevel: opts.mapAuditLevel ?? true, }) /** A format instance keeps one options object for its lifetime, so its settings are resolved once. */ @@ -173,9 +165,10 @@ const splitException = (record: Record, key: string, error: Rec * turn them into nested map attributes, which most backends — Azure Log Analytics included — do not * index. Scalars are left alone. A `Date` becomes its ISO string, unquoted. * - * OpenTelemetry has no severity for the custom `audit` level, so by default the format exports an - * `audit` record at `info` severity, with every record's level on `logLevel`. See - * {@link OtelAttributesFormatOptions} to turn that off, cap the strings it writes, or pass a replacer. + * OpenTelemetry has no severity for the custom `audit` level, so the format exports an `audit` + * record at `info` severity. It copies every record's level onto `logLevel`, which keeps `audit` + * apart from `info`, and `debug` apart from `verbose`, in a backend that stores only the severity. + * See {@link OtelAttributesFormatOptions} to cap the strings it writes or pass a replacer. * * Put it on the OpenTelemetry transport to keep other transports' output nested, or use * `createLogger({ otel: true })` when the transport is added for you, as `WinstonInstrumentation` @@ -187,7 +180,7 @@ const splitException = (record: Record, key: string, error: Rec */ export const otelAttributesFormat = format((info, opts) => { const settings = settingsFor(opts as OtelAttributesFormatOptions | undefined) - if (settings.mapAuditLevel) applyAuditLevelMapping(info) + applyAuditLevelMapping(info) const record = info as unknown as Record const lifted = EXCEPTION_KEYS.find((key) => isExceptionCandidate(record[key])) for (const key of Object.keys(record)) { diff --git a/src/otel-transport.spec.ts b/src/otel-transport.spec.ts index 04ca7dc..8275389 100644 --- a/src/otel-transport.spec.ts +++ b/src/otel-transport.spec.ts @@ -214,15 +214,13 @@ describe('without otel on latest', () => { }) }) -describe('transport severity options on latest', () => { - it('logSeverity drops an audit record nothing maps', async () => { +describe('logSeverity on latest', () => { + it('drops an audit record without otel, because nothing gives it a severity', async () => { const transport = new TransportLatest({ logSeverity: SeverityNumber.INFO }) - expect( - await emitted({ otel: { mapAuditLevel: false }, transports: [transport] }, (logger, message) => logger.audit(message)), - ).toHaveLength(0) + expect(await emitted({ transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(0) }) - it('logSeverity keeps an audit record otel maps', async () => { + it('keeps an audit record otel maps', async () => { const transport = new TransportLatest({ logSeverity: SeverityNumber.INFO }) expect(await emitted({ otel: true, transports: [transport] }, (logger, message) => logger.audit(message))).toHaveLength(1) }) From 9ff008d2277c24b14d579214db4105391437edb2 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 11:10:22 +0800 Subject: [PATCH 12/13] feat: add a quick start, and onlyLevels and closeTimeoutMs to CallbackTransport - README: a three-step quick start (instrument, create the logger, send audit records to their own table) replaces the environment-driven configuration example, the hand-written audit transport and the OpenTelemetry setup section. - CallbackTransport: onlyLevels forwards exact levels, because level is a threshold and level: 'audit' also forwards error and warn. closeTimeoutMs bounds how long close() waits for dispatches still running, and reports them through logError. --- README.md | 266 ++++++++++++++------------------- src/callback-transport.spec.ts | 58 +++++++ src/callback-transport.ts | 68 +++++++-- 3 files changed, 223 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 8fcc390..88c81aa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,103 @@ Requires Node.js `>=20`. See [Migrating from v2](#migrating-from-v2) and [Migrating from v1](#migrating-from-v1) at the bottom for breaking changes and new functionality. +## Quick start + +A typical service instruments itself for OpenTelemetry, creates one logger, and sends audit records to a store of their own. Steps 1 and 3 are optional. + +### 1. Instrument the app (optional) + +Install `@opentelemetry/sdk-node`, `@opentelemetry/instrumentation-winston` and `@opentelemetry/winston-transport`, and any other instrumentations you use. The winston instrumentation loads the transport at runtime but does not depend on it, and without it no record is sent. + +An instrumentation patches a module as that module loads, so tracing must start before anything imports winston. An entry point with no static imports enforces that order: + +```ts +// main.ts +await import('./tracing.js') +await import('./server.js') +``` + +```ts +// tracing.ts +import { register } from 'node:module' +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' +import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const instrumentations = [new HttpInstrumentation(), new WinstonInstrumentation()] + +// For an ES module app: winston is loaded by `import`, which only this hook can patch. +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) + +export const sdk = new NodeSDK({ instrumentations }) +sdk.start() +``` + +`NodeSDK` reads its exporters from the standard `OTEL_*` environment variables. Call `sdk.shutdown()` last on shutdown, to send what is still queued. + +### 2. Create the logger + +```ts +// logger.ts +import { isLocalDev } from '@makerx/node-common' +import { createLogger } from '@makerx/node-winston' + +export const logger = createLogger({ + consoleFormat: isLocalDev ? 'pretty' : 'json', + loggerOptions: { + level: process.env.LOG_LEVEL ?? 'info', + defaultMeta: { service: 'my-api' }, + }, + redactPaths: ['headers.authorization', 'token'], + otel: true, +}) +``` + +`otel: true` shapes what the OpenTelemetry transport from step 1 receives. The console keeps nested JSON, or coloured YAML locally. Without step 1, or where the SDK does not run, as in most tests, `otel` changes nothing. To silence the console in tests, pass `consoleOptions: { silent: true }`. Every option is listed under [Options](#options). + +### 3. Send audit records to their own table (optional) + +`logger.audit()` writes at the custom `audit` level (see [Log levels](#log-levels)). To keep audit records in a Log Analytics table of their own as well, send them through a [`CallbackTransport`](#callbacktransport). This example uses the Azure Monitor Logs Ingestion API, with a data collection rule for the custom table: + +```ts +// audit.ts +import { DefaultAzureCredential } from '@azure/identity' +import { LogsIngestionClient } from '@azure/monitor-ingestion' +import { CallbackTransport, type LogError } from '@makerx/node-winston' + +const client = new LogsIngestionClient(process.env.AUDIT_ENDPOINT_URL!, new DefaultAzureCredential()) + +export const createAuditTransport = (logError: LogError) => + new CallbackTransport( + ({ message, meta }) => + client.upload(process.env.AUDIT_RULE_ID!, process.env.AUDIT_STREAM_NAME!, [ + { TimeGenerated: new Date().toISOString(), Message: message, Info: meta }, + ]), + logError, + { onlyLevels: ['audit'], closeTimeoutMs: 5_000 }, + ) +``` + +```ts +// logger.ts +import { createAuditTransport } from './audit.js' + +const auditTransport = createAuditTransport((message, ...args) => logger.error(message, ...args)) + +export const logger = createLogger({ + // ...the options from step 2 + transports: [auditTransport], +}) + +/** Call on shutdown, before `sdk.shutdown()`. Waits up to 5 seconds for audit uploads still running. */ +export const closeAuditTransport = () => auditTransport.close() +``` + +- `onlyLevels: ['audit']` forwards `audit` records only. The `level` option would also forward `error` and `warn`. +- An upload that fails goes to `logError`, which logs it as an error. Errors never reach this transport, so a failing endpoint cannot loop. +- Winston does not wait for a transport to close, so await `close()` yourself. `closeTimeoutMs` bounds the wait, and reports through `logError` the uploads still running. +- The record columns match the custom table's data collection rule. + ## Creating a Logger `createLogger` builds a winston `Logger` with a pre-configured `Console` transport and a set of logger-level formats that apply to every transport. @@ -51,7 +148,7 @@ Formats are applied in layers: { error: 0, warn: 1, audit: 2, info: 3, debug: 4, verbose: 5 } ``` -`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else, route them with a transport such as [`CallbackTransport`](#callbacktransport). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so with `otel` an `audit` record is exported at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). +`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else as well, see [step 3 of the quick start](#3-send-audit-records-to-their-own-table-optional). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so with `otel` an `audit` record is exported at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). Colours for the default levels (including `audit`) are registered on the first `createLogger` call that uses them, so `colorize` / pretty output works out of the box. @@ -82,55 +179,6 @@ const logger = createLogger({ logger.http('GET /items') // typed; logger.audit would be a type error ``` -### Example: environment-driven configuration - -At MakerX we typically drive logger configuration from config files, varying output by environment: - -`logger.ts`: - -```ts -import { isLocalDev } from '@makerx/node-common' -import { createLogger } from '@makerx/node-winston' -import config from 'config' - -const logger = createLogger({ - consoleFormat: isLocalDev ? 'pretty' : 'json', - consoleOptions: config.get('logging.consoleOptions'), - loggerOptions: config.get('logging.loggerOptions'), - omitPaths: config.get('logging.omitPaths'), -}) - -export default logger -``` - -Runtime configurations for different environments might look like: - -```ts -// local development — coloured YAML, verbose level, strip redundant defaultMeta -const logger = createLogger({ - consoleFormat: 'pretty', - loggerOptions: { - defaultMeta: { service: 'my-application-name' }, - level: 'verbose', - }, - omitPaths: ['service'], -}) - -// deployed — structured JSON, info level -const logger = createLogger({ - consoleFormat: 'json', - loggerOptions: { - defaultMeta: { service: 'my-application-name' }, - level: 'info', - }, -}) - -// integration tests — silence console output -const logger = createLogger({ - consoleOptions: { silent: true }, -}) -``` - ## Transports `createLogger` creates a `Console` transport by default. Add [other transports](https://github.com/winstonjs/winston/blob/master/docs/transports.md) via the `transports` option — they share the logger-level formats: @@ -150,116 +198,23 @@ const logger = createLogger({ }) ``` -### Custom transport as an integration point - -A custom `winston-transport` is a clean place to fan selected log records out to an external destination — an audit endpoint, a queue, an analytics service. Subclass `Transport`, override `log` to filter and dispatch, and override `close` to drain in-flight work on shutdown so records aren't lost when the process exits. - -The library exports `extractTransformableInfo`, a small helper that strips winston's internal `triple-beam` symbols (`LEVEL`, `SPLAT`) from the `TransformableInfo` and splits out `level`, `message` and the remaining metadata: - -```ts -import Transport from 'winston-transport' -import { type TransformableInfo } from 'logform' -import { extractTransformableInfo } from '@makerx/node-winston' - -export type LogError = (message: string, ...args: any[]) => void - -class AuditTransport extends Transport { - private readonly inFlight = new Set>() - - constructor(private readonly logError: LogError) { - super() - } - - override log(info: TransformableInfo, next: () => void): void { - const { level, message, meta } = extractTransformableInfo(info) - - if (level !== 'audit') { - next() - return - } - - const promise = Promise.resolve() - .then(() => sendToAuditEndpoint({ level, message, ...meta })) - .catch((error) => this.logError('Error in AuditTransport:', { error })) - .finally(() => this.inFlight.delete(promise)) - - this.inFlight.add(promise) - - next() - } - - override async close(): Promise { - await Promise.allSettled([...this.inFlight]) - } -} - -const logger = createLogger({ - transports: [new AuditTransport((message, ...args) => logger.error(message, ...args))], -}) -``` - -A few things worth calling out: +### `CallbackTransport` -- Always call `next()` exactly once per record, including when the level filter rejects it — winston-transport is a `Writable` stream, and skipping the callback stalls the pipeline. -- Don't `await` the dispatch inside `log`. Track the promise in a `Set` so `close()` can wait on it later, and let the dispatch run in the background. -- Inject an error sink (`logError`) rather than logging dispatch failures via the same logger this transport is attached to — otherwise a failing endpoint produces an audit record that produces another failing dispatch. -- Override `close()` and `Promise.allSettled` the in-flight set so a failing dispatch doesn't reject the drain. Winston calls `close` from `logger.close()` / `logger.end()`, so transports get a chance to flush before exit. +`CallbackTransport` sends records to a promise-returning handler, such as an audit table, a queue or an analytics service. It takes the handler, a `LogError` sink for handler failures, and options: -#### `CallbackTransport` +| Option | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onlyLevels` | Forwards only records at these levels. | +| `level` | Forwards a level and every level above it. It compares the string `info.level`, so it stays accurate when a logger-level format such as `mapAuditLevelForOtel` rewrites the triple-beam `LEVEL` symbol. | +| `closeTimeoutMs` | The longest `close()` waits for dispatches still running. By default it waits for all of them. | -For the common case where the only thing you'd customise is the dispatch itself, the library exports `CallbackTransport` — a ready-made subclass that wraps the boilerplate above. Construct it with a promise-returning `LogHandler` (receives a `TransformedInfo` — the same `{ level, message, meta }` shape `extractTransformableInfo` returns) and a `LogError` sink for dispatch failures, and pass any standard winston-transport options (e.g. `level`) through the third argument: - -```ts -import { CallbackTransport, createLogger } from '@makerx/node-winston' - -const logger = createLogger({ - transports: [ - new CallbackTransport( - ({ level, message, meta }) => sendToAuditEndpoint({ level, message, ...meta }), - (message, ...args) => logger.error(message, ...args), - { level: 'audit' }, - ), - ], -}) -``` - -In-flight dispatches are tracked internally and drained via `Promise.allSettled` on `close()`, and any rejection from the handler is routed to the supplied `logError` so a failing endpoint can't recurse back through this transport. - -`CallbackTransport` filters on the string `info.level` rather than the triple-beam `LEVEL` symbol, so the `level` option stays accurate when a logger-level format such as `mapAuditLevelForOtel` rewrites the symbol. The transport keeps its `level` option and does not pass it to the base `winston-transport` class. +The handler receives `{ level, message, meta }`, the shape `extractTransformableInfo` returns. A handler that rejects is reported to `logError` and never fails the log call. Give it a `logError` whose records this transport does not forward, or a failing endpoint produces a record that fails again. [Step 3 of the quick start](#3-send-audit-records-to-their-own-table-optional) is a complete example. ## OpenTelemetry -Send logs through OpenTelemetry with [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) and `createLogger({ otel: true })`. The instrumentation adds an [`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) transport to every logger and stamps trace and span ids on each record. `otel: true` gives that transport `otelAttributesFormat`, which shapes each record into the attributes the logs SDK and most backends need. The console and every other transport are unchanged. - -Install both packages. The instrumentation loads the transport at runtime but does not depend on it, and without it no record is sent. The transport is an optional peer dependency of this library, at 0.29 or later. - -### Setup - -Register `WinstonInstrumentation` with your other instrumentations, in a module that runs before anything imports winston. An instrumentation patches a module as it loads, so a logger created from a winston that loaded first is never instrumented. - -```ts -// tracing.ts: load first, for example with `node --import ./tracing.js ./main.js` -import { register } from 'node:module' -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' -import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston' -import { NodeSDK } from '@opentelemetry/sdk-node' - -const instrumentations = [new HttpInstrumentation(), new WinstonInstrumentation()] - -// For an ES module app: winston is loaded by `import`, which only this hook can patch. -register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) - -new NodeSDK({ instrumentations }).start() -``` - -```ts -// logger.ts -import { createLogger } from '@makerx/node-winston' - -export const logger = createLogger({ otel: true }) -``` +Send logs through OpenTelemetry with [`@opentelemetry/instrumentation-winston`](https://www.npmjs.com/package/@opentelemetry/instrumentation-winston) and `createLogger({ otel: true })`, as in steps [1](#1-instrument-the-app-optional) and [2](#2-create-the-logger) of the quick start. The instrumentation adds an [`@opentelemetry/winston-transport`](https://www.npmjs.com/package/@opentelemetry/winston-transport) transport to every logger and stamps trace and span ids on each record. `otel: true` gives that transport `otelAttributesFormat`, which shapes each record into the attributes the logs SDK and most backends need. The console and every other transport are unchanged. -Where the SDK does not run, as in most tests, the logger has no OpenTelemetry transport and `otel` changes nothing. Do not also add an `OpenTelemetryTransportV3` yourself while the instrumentation sends logs: every record would be exported twice. +Do not also add an `OpenTelemetryTransportV3` yourself while the instrumentation sends logs: every record would be exported twice. `otel` also shapes a transport passed in `transports`, but a transport added later with `logger.add` gets no format. ### Supported transport lines @@ -486,7 +441,7 @@ format.combine(serializeErrorFormat({ serializer }), format.json({ replacer: cre ## Migrating from v2 -v3 has one OpenTelemetry setup: `WinstonInstrumentation` adds the transport, and `createLogger({ otel: true })` shapes what it receives. It supports `@opentelemetry/winston-transport` 0.29 and later only. 2.x gets fixes only, published under the `latest-2` dist-tag. +v3 has one OpenTelemetry setup, shown in the [quick start](#quick-start): `WinstonInstrumentation` adds the transport, and `createLogger({ otel: true })` shapes what it receives. It supports `@opentelemetry/winston-transport` 0.29 and later only. 2.x gets fixes only, published under the `latest-2` dist-tag. Breaking changes: @@ -497,7 +452,7 @@ Breaking changes: | `flatten: 'otel'` or `flatten: true`, with `mapAuditLevelForOtel: true` | `otel: true` | | `flatten: 'otel'` without `mapAuditLevelForOtel` | `otel: true`. Records also get `logLevel`, and `audit` is exported at `info` severity. | | `flattenReplacer: r` | `otel: { replacer: r }` | -| An `OpenTelemetryTransportV3` you add yourself, with `otelAttributesFormat()` or `jsonStringifyValuesFormat()`, and `disableLogSending: true` on `WinstonInstrumentation` | Remove the transport and `disableLogSending`, and use `otel: true`. See [Setup](#setup). | +| An `OpenTelemetryTransportV3` you add yourself, with `otelAttributesFormat()` or `jsonStringifyValuesFormat()`, and `disableLogSending: true` on `WinstonInstrumentation` | Remove the transport and `disableLogSending`, and use `otel: true`. See the [quick start](#quick-start). | | A transport before 0.29 | Move the transport and the SDK to 0.29 or later. If you cannot move yet, stay on 2.x. | | A hand-written `errorSerializer` that walks `serializeError`'s output to drop properties | `createErrorSerializer({ omit })`, with the rule as the predicate. See [the AI SDK example](#example-ai-sdk-errors-in-app-insights). | @@ -511,6 +466,7 @@ New functionality: - `otel` finds the transport `WinstonInstrumentation` adds, so the console stays readable while OpenTelemetry receives flat attributes. - `otelAttributesFormat` takes a `maxAttributeLength` option, beside its `replacer`. - `createErrorSerializer` builds an error serializer that can leave properties out of every error, or write a shared object once. +- `CallbackTransport` takes `onlyLevels`, to forward only some levels, and `closeTimeoutMs`, to bound how long `close()` waits. ## Migrating from v1 diff --git a/src/callback-transport.spec.ts b/src/callback-transport.spec.ts index 850a60d..23c8cc0 100644 --- a/src/callback-transport.spec.ts +++ b/src/callback-transport.spec.ts @@ -171,4 +171,62 @@ describe('CallbackTransport', () => { expect(received.map((info) => info.message)).toEqual(['audit-event']) }) + + it('forwards only the levels in onlyLevels', async () => { + const received: TransformedInfo[] = [] + const transport = attachLevels( + new CallbackTransport( + async (info) => { + received.push(info) + }, + () => {}, + { onlyLevels: ['audit'] }, + ), + ) + + for (const level of ['error', 'warn', 'audit', 'info']) transport.log!(buildInfo(level, `${level}-event`), () => {}) + await transport.close!() + + expect(received.map((info) => info.message)).toEqual(['audit-event']) + }) + + it('forwards only audit records through createLogger with onlyLevels, so an error it logs is not sent back to it', async () => { + const { createLogger } = await import('./index') + const received: TransformedInfo[] = [] + const transport = new CallbackTransport( + async (info) => { + received.push(info) + }, + (message, ...args) => logger.error(message, ...args), + { onlyLevels: ['audit'] }, + ) + const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] }) + + logger.error('error-event') + logger.audit('audit-event', { actor: 'a-1' }) + logger.info('info-event') + await transport.close!() + + expect(received).toEqual([{ level: 'audit', message: 'audit-event', meta: { actor: 'a-1' } }]) + }) + + it('stops waiting at closeTimeoutMs and reports the dispatches still running', async () => { + const logError = vi.fn() + const transport = new CallbackTransport(() => new Promise(() => {}), logError, { closeTimeoutMs: 20 }) + + transport.log!(buildInfo('info', 'never-finishes'), () => {}) + await transport.close!() + + expect(logError).toHaveBeenCalledWith('CallbackTransport closed after 20 ms with 1 dispatch(es) still running') + }) + + it('reports nothing when dispatches finish within closeTimeoutMs', async () => { + const logError = vi.fn() + const transport = new CallbackTransport(() => Promise.resolve(), logError, { closeTimeoutMs: 1_000 }) + + transport.log!(buildInfo('info', 'quick'), () => {}) + await transport.close!() + + expect(logError).not.toHaveBeenCalled() + }) }) diff --git a/src/callback-transport.ts b/src/callback-transport.ts index bd0d16e..9bec700 100644 --- a/src/callback-transport.ts +++ b/src/callback-transport.ts @@ -16,42 +16,65 @@ export type LogError = (message: string, ...args: any[]) => void */ export type LogHandler = (info: TransformedInfo) => Promise +export interface CallbackTransportOptions extends Transport.TransportStreamOptions { + /** + * Forwards only records at these levels. `level` forwards a level and every level above it, so + * `level: 'audit'` also forwards `error` and `warn`. `onlyLevels: ['audit']` forwards `audit` + * records alone. + */ + onlyLevels?: readonly string[] + + /** + * The longest `close()` waits for dispatches that are still running, in milliseconds. When it + * stops waiting, it reports how many are still running through `logError`. By default `close()` + * waits for every dispatch to finish. + */ + closeTimeoutMs?: number +} + /** * Custom winston transport that forwards each log record to a promise-returning callback, with * built-in tracking of in-flight dispatches so `close()` can drain them on shutdown. Construct * with the dispatch handler, an error sink for dispatch failures, and (optionally) the standard - * winston-transport options. + * winston-transport options and {@link CallbackTransportOptions}. * * The stock winston-transport `level` filter compares the triple-beam `LEVEL` symbol. This transport * filters on the string `info.level` instead, so its `level` option stays accurate when a * logger-level format such as `mapAuditLevelForOtel` rewrites the `LEVEL` symbol. The constructor * keeps the option and does not pass it to the base class, so the base filter never runs. * + * Winston does not wait for a transport's `close()`. Await it yourself on shutdown, so that + * dispatches still running can finish before the process exits. + * * @example * ```ts - * const logger = createLogger({ - * transports: [ - * new CallbackTransport( - * ({ level, message, meta }) => sendToAuditEndpoint({ level, message, ...meta }), - * (message, ...args) => logger.error(message, ...args), - * { level: 'audit' }, - * ), - * ], - * }) + * const auditTransport = new CallbackTransport( + * ({ message, meta }) => sendToAuditEndpoint({ message, ...meta }), + * (message, ...args) => logger.error(message, ...args), + * { onlyLevels: ['audit'], closeTimeoutMs: 5_000 }, + * ) + * const logger = createLogger({ transports: [auditTransport] }) + * + * // on shutdown + * await auditTransport.close() * ``` */ export class CallbackTransport extends Transport { private readonly inFlight = new Set>() private readonly minLevel: string | undefined + private readonly onlyLevels: ReadonlySet | undefined + private readonly closeTimeoutMs: number | undefined constructor( private readonly handler: LogHandler, private readonly logError: LogError, - options?: Transport.TransportStreamOptions, + options?: CallbackTransportOptions, ) { - const { level, ...rest } = options ?? {} + const { level, onlyLevels, closeTimeoutMs, ...rest } = options ?? {} super(rest) this.minLevel = level + this.onlyLevels = onlyLevels ? new Set(onlyLevels) : undefined + this.closeTimeoutMs = closeTimeoutMs } override log(info: TransformableInfo, next: () => void): void { @@ -60,7 +83,8 @@ export class CallbackTransport extends Transport { // `levels` is set on the transport by winston when it's piped from a logger; the stock // `winston-transport` type definitions don't declare it, so we read it via cast. const levels = (this as unknown as { levels?: Record }).levels - if (this.minLevel !== undefined && levels && levels[this.minLevel] < levels[transformed.level]) { + const belowMinLevel = this.minLevel !== undefined && levels && levels[this.minLevel] < levels[transformed.level] + if (belowMinLevel || (this.onlyLevels && !this.onlyLevels.has(transformed.level))) { next() return } @@ -76,6 +100,22 @@ export class CallbackTransport extends Transport { } override async close(): Promise { - await Promise.allSettled([...this.inFlight]) + const settled = Promise.allSettled([...this.inFlight]) + if (this.closeTimeoutMs === undefined) { + await settled + return + } + + let timer: NodeJS.Timeout | undefined + const timedOut = await Promise.race([ + settled.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), this.closeTimeoutMs) + }), + ]) + clearTimeout(timer) + if (timedOut) { + this.logError(`CallbackTransport closed after ${this.closeTimeoutMs} ms with ${this.inFlight.size} dispatch(es) still running`) + } } } From 22d4e446fd9c754afc505fe91c38bf354e4d60f1 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 17 Oct 2026 11:19:10 +0800 Subject: [PATCH 13/13] docs: review pass over the v3 changeset - Quick start: name @opentelemetry/instrumentation for the ESM hook, say what a CommonJS app does instead, and tighten the audit notes. - Replace the claim that Azure Monitor ignores a record with no severity: the collector files it as Information and the JS exporter sends none. - Describe otel as shaping every OpenTelemetry transport on the logger, and stop presenting otelAttributesFormat on a transport as the first choice. - Remove a repeated sentence from Log levels, and stale wording in the formats table, the ESLint comment and a test title. --- README.md | 40 +++++++++++++++--------------- eslint.config.mjs | 3 ++- src/map-audit-level-for-otel.ts | 4 +-- src/otel-attributes-format.spec.ts | 2 +- src/otel-attributes-format.ts | 8 +++--- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 88c81aa..5f44596 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Node Winston -A set of [winston](https://github.com/winstonjs/winston) [formats](https://github.com/winstonjs/winston#formats), a console transport and a logger factory that simplify structured logging in Node.js services — with colourised YAML output for local development and support for OTEL integration. +A set of [winston](https://github.com/winstonjs/winston) [formats](https://github.com/winstonjs/winston#formats), a console transport and a logger factory that simplify structured logging in Node.js services, with colourised YAML output for local development and records shaped for OpenTelemetry. Start with the [quick start](#quick-start). ## Installation @@ -10,7 +10,7 @@ npm install @makerx/node-winston `winston`, `logform`, `winston-transport`, `triple-beam` and `es-toolkit` are peer dependencies so the package shares a single instance with your app's `winston`. npm 7+ and pnpm install them automatically; on Yarn Classic, install them yourself. -`@opentelemetry/winston-transport` `>=0.29` is an optional peer dependency. Install it only when you ship logs through OpenTelemetry: see [OpenTelemetry](#opentelemetry). +`@opentelemetry/winston-transport` `>=0.29` is an optional peer dependency. Install it only when you ship logs through OpenTelemetry: see [step 1 of the quick start](#1-instrument-the-app-optional). Requires Node.js `>=20`. @@ -24,9 +24,9 @@ A typical service instruments itself for OpenTelemetry, creates one logger, and ### 1. Instrument the app (optional) -Install `@opentelemetry/sdk-node`, `@opentelemetry/instrumentation-winston` and `@opentelemetry/winston-transport`, and any other instrumentations you use. The winston instrumentation loads the transport at runtime but does not depend on it, and without it no record is sent. +Install `@opentelemetry/sdk-node`, `@opentelemetry/instrumentation-winston` and `@opentelemetry/winston-transport`, and the other instrumentations you use, such as `@opentelemetry/instrumentation-http` below. The winston instrumentation loads the transport at runtime but does not depend on it, and without it no record is sent. An ES module app also needs `@opentelemetry/instrumentation`, for the hook that `tracing.ts` registers. -An instrumentation patches a module as that module loads, so tracing must start before anything imports winston. An entry point with no static imports enforces that order: +An instrumentation patches a module as that module loads, so tracing must start before anything imports winston. In an ES module app, an entry point with no static imports enforces that order: ```ts // main.ts @@ -50,7 +50,7 @@ export const sdk = new NodeSDK({ instrumentations }) sdk.start() ``` -`NodeSDK` reads its exporters from the standard `OTEL_*` environment variables. Call `sdk.shutdown()` last on shutdown, to send what is still queued. +A CommonJS app can `require('./tracing')` first instead, and needs no `register` call. `NodeSDK` reads its exporters from the standard `OTEL_*` environment variables. Call `sdk.shutdown()` last on shutdown, to send what is still queued. ### 2. Create the logger @@ -112,8 +112,8 @@ export const closeAuditTransport = () => auditTransport.close() - `onlyLevels: ['audit']` forwards `audit` records only. The `level` option would also forward `error` and `warn`. - An upload that fails goes to `logError`, which logs it as an error. Errors never reach this transport, so a failing endpoint cannot loop. -- Winston does not wait for a transport to close, so await `close()` yourself. `closeTimeoutMs` bounds the wait, and reports through `logError` the uploads still running. -- The record columns match the custom table's data collection rule. +- Winston does not wait for a transport to close, so await `close()` yourself. `closeTimeoutMs` bounds the wait, and any uploads still running are reported through `logError`. +- The record's columns, `TimeGenerated`, `Message` and `Info`, must match the stream that the data collection rule declares. ## Creating a Logger @@ -136,7 +136,7 @@ Formats are applied in layers: | `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | | `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. A plain key matches at every depth, inside serialized errors too. Applied at the logger level, so affects all transports. | | `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | -| `otel` | `boolean \| OtelAttributesFormatOptions` | Gives the OpenTelemetry transport that `@opentelemetry/instrumentation-winston` adds the `otelAttributesFormat`. Other transports are unchanged. `true` uses the format's defaults, and an object is passed to it as its options. See [OpenTelemetry](#opentelemetry). | +| `otel` | `boolean \| OtelAttributesFormatOptions` | Gives each OpenTelemetry transport on the logger, such as the one `@opentelemetry/instrumentation-winston` adds, the `otelAttributesFormat`. Other transports are unchanged. `true` uses the format's defaults, and an object is passed to the format as its options. See [OpenTelemetry](#opentelemetry). | | `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. Build one with `createErrorSerializer` to leave properties out of every error. | | `loggerOptions` | `LoggerOptions` | Winston logger options, for example `level` and `defaultMeta`. A `format` supplied here is appended after the library's logger-level formats. | @@ -148,7 +148,7 @@ Formats are applied in layers: { error: 0, warn: 1, audit: 2, info: 3, debug: 4, verbose: 5 } ``` -`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else as well, see [step 3 of the quick start](#3-send-audit-records-to-their-own-table-optional). A logger at `info` or more verbose writes `audit` records, and a logger at `warn` drops them. OpenTelemetry has no `audit` severity, so with `otel` an `audit` record is exported at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). +`audit` is for audit-trail events: a sign-in, a role change, a data export. It is a log level, not an audit store, and it adds no delivery or retention guarantee. To send audit records somewhere else as well, see [step 3 of the quick start](#3-send-audit-records-to-their-own-table-optional). OpenTelemetry has no `audit` severity, so with `otel` an `audit` record is exported at `info` severity with `logLevel: 'audit'`. See [Audit severity](#audit-severity). Colours for the default levels (including `audit`) are registered on the first `createLogger` call that uses them, so `colorize` / pretty output works out of the box. @@ -265,7 +265,7 @@ Pass options as `otel: { … }`. ### Audit severity -OpenTelemetry's log spec has no severity for the custom `audit` level. Azure Monitor ignores a record without a severity, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity. A logger whose levels have no `audit` has nothing to rewrite. +OpenTelemetry's log spec has no severity for the custom `audit` level. Without one, a backend stores the record with no severity or a default one, and a `logSeverity` filter drops it. So the format exports an `audit` record at `info` severity. A logger whose levels have no `audit` has nothing to rewrite. The format also copies every record's level onto `logLevel`. App Insights stores only a coarse severity, which puts `audit` with `info` and `debug` with `verbose`, so `logLevel` is how to tell them apart. In Azure Log Analytics, query `customDimensions.logLevel == "audit"`. @@ -309,16 +309,16 @@ With no `err` or `error` on a record, the transport takes the first metadata obj Every format used by `createLogger` is also exported for direct use with your own winston setup. -| Format | Purpose | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | -| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | -| `omitNilFormat` | Removes top-level `null` or `undefined` values. | -| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | -| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. By default it also exports `audit` at `info` severity. `otel` puts it on the OpenTelemetry transport. See [OpenTelemetry](#opentelemetry). | -| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | -| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | -| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel` so custom levels survive OTEL's severity enumeration. `otelAttributesFormat` already does this; use it in a chain you build yourself. | +| Format | Purpose | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serializeErrorFormat` | Walks the log info (including nested objects and arrays) and replaces `Error` instances with plain objects that include the normally non-enumerable `message`/`stack`. A record that is itself an error, or holds one under `message`, is nested under `error`. | +| `omitFormat` | Removes fields by dot-notation path via [es-toolkit's compat `omit`](https://es-toolkit.dev/reference/compat/object/omit.html) (lodash-compatible). | +| `omitNilFormat` | Removes top-level `null` or `undefined` values. | +| `redactFormat` | Recursively replaces values at the given paths with `redactedValue` (default `''`). | +| `otelAttributesFormat` | Shapes a record into OpenTelemetry log attributes for `@opentelemetry/winston-transport` 0.29 and later: the error stays an object the transport records as the exception, the rest of it goes to `errorDetail`, and every other object value becomes a JSON string. It also writes `logLevel` and exports `audit` at `info` severity. `otel` puts it on the OpenTelemetry transport. See [OpenTelemetry](#opentelemetry). | +| `jsonStringifyValuesFormat` | Serialises every top-level value to a JSON string, producing a flat `{ key: string }` shape. Accepts an optional `replacer`. For a sink other than the OpenTelemetry transport; from transport 0.29 use `otelAttributesFormat`. | +| `prettyConsoleFormat` | Applies `colorize` and `timestamp`, then renders logs as coloured YAML using [`yamlify-object`](https://www.npmjs.com/package/yamlify-object). | +| `mapAuditLevelForOtel` | Rewrites the triple-beam `LEVEL` symbol from `audit` to `info` and copies the original onto `logLevel`, so the custom `audit` level gets an OpenTelemetry severity. `otelAttributesFormat` already does this; use it in a chain you build yourself. | `redactFormat` paths accept plain keys (`email`, matched at every level), dot-notation paths (`user.email`), and `[*]` array wildcards to iterate every element of an array segment — for example `files[*].name`, `users[*].addresses[*].zip`, or `tags[*]` to redact each element of a primitive array. diff --git a/eslint.config.mjs b/eslint.config.mjs index f5abf4c..675d5ea 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,7 +38,8 @@ export default [ // The OpenTelemetry transport is an optional peer. Importing it, or `@opentelemetry/api-logs`, from // the library would make it required and can add a second `api-logs` copy to an app. A transport // that gets its logger from a second copy before the SDK registers its provider writes every line - // to a no-op logger. Specs drive the real transport under npm aliases instead. + // to a no-op logger. Specs and the fixture under src/tests import the real packages, which are + // dev dependencies. files: ['src/**/*.ts'], ignores: ['src/**/*.spec.ts', 'src/tests/**'], rules: { diff --git a/src/map-audit-level-for-otel.ts b/src/map-audit-level-for-otel.ts index 219af11..113c6bd 100644 --- a/src/map-audit-level-for-otel.ts +++ b/src/map-audit-level-for-otel.ts @@ -6,8 +6,8 @@ import { applyAuditLevelMapping } from './audit-level' * `@opentelemetry/winston-transport` derives `severityText` / `severityNumber` from Winston's * triple-beam `LEVEL` symbol and strips the string `level` property before building attributes, * so the custom `audit` level arrives at an OTEL backend with no severity and no queryable - * record of the original level. Azure Monitor / Log Analytics ignores records without a mapped - * severity, and a transport `logSeverity` filter drops them. + * record of the original level. A backend stores such a record with no severity or a default one, + * and a transport `logSeverity` filter drops it. * * This format: * - rewrites `info[LEVEL]` from `audit` to `info` so OTEL maps to a known severity tier diff --git a/src/otel-attributes-format.spec.ts b/src/otel-attributes-format.spec.ts index 071e56d..f5365bc 100644 --- a/src/otel-attributes-format.spec.ts +++ b/src/otel-attributes-format.spec.ts @@ -199,7 +199,7 @@ describe('otelAttributesFormat — length cap', () => { }) describe('otelAttributesFormat — shared state', () => { - it('changes no nested value, so a transport placement leaves its siblings their own objects', () => { + it('changes no nested value, so other transports keep their own objects', () => { const error = withAttempts() const ctx = { inner: { a: 1 }, list: [{ b: 2 }] } const before = structuredClone({ error, ctx }) diff --git a/src/otel-attributes-format.ts b/src/otel-attributes-format.ts index 5b290fa..3e6e393 100644 --- a/src/otel-attributes-format.ts +++ b/src/otel-attributes-format.ts @@ -170,10 +170,10 @@ const splitException = (record: Record, key: string, error: Rec * apart from `info`, and `debug` apart from `verbose`, in a backend that stores only the severity. * See {@link OtelAttributesFormatOptions} to cap the strings it writes or pass a replacer. * - * Put it on the OpenTelemetry transport to keep other transports' output nested, or use - * `createLogger({ otel: true })` when the transport is added for you, as `WinstonInstrumentation` - * does. On the transport the audit mapping runs after the transport's level filter, so it can never - * drop a record. + * `createLogger({ otel: true })` puts it on the OpenTelemetry transport, so other transports keep + * their nested output. Use it directly only in a format chain you build yourself, on that + * transport's own format: there the audit mapping runs after the transport's level filter, so it + * never drops a record. * * Mutates `info` in place, so winston's Symbol-keyed routing properties are preserved. Nested * values are never changed.