diff --git a/README.md b/README.md index f92fd89ec716..8b22dafb0c63 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,6 @@ below: Provides the integration for Session Replay. - [`@sentry/core`](https://github.com/getsentry/sentry-javascript/tree/master/packages/core): The base for all JavaScript SDKs with interfaces, type definitions and base classes. -- [`@sentry/pino-transport`](https://github.com/getsentry/sentry-javascript/tree/master/packages/pino-transport): Pino - transport for automatically sending log messages to Sentry. ## Bug Bounty Program diff --git a/packages/pino-transport/README.md b/packages/pino-transport/README.md index 45480f802cb1..0bb6aeed81ed 100644 --- a/packages/pino-transport/README.md +++ b/packages/pino-transport/README.md @@ -1,30 +1,267 @@ -# @sentry/pino-transport +

+ + Sentry + +

-[![npm version](https://img.shields.io/npm/v/@sentry/pino-transport.svg)](https://www.npmjs.com/package/@sentry/pino-transport) -[![npm dm](https://img.shields.io/npm/dm/@sentry/pino-transport.svg)](https://www.npmjs.com/package/@sentry/pino-transport) -[![npm dt](https://img.shields.io/npm/dt/@sentry/pino-transport.svg)](https://www.npmjs.com/package/@sentry/pino-transport) +# Official Sentry Pino Transport -**This package is currently in alpha. Breaking changes may still occur.** +[![npm version](https://img.shields.io/npm/v/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) +[![npm dm](https://img.shields.io/npm/dm/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) +[![npm dt](https://img.shields.io/npm/dt/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) -A Pino transport for integrating [Pino](https://github.com/pinojs/pino) logging with [Sentry](https://sentry.io). This transport automatically captures log messages as Sentry events and breadcrumbs, making it easy to monitor your application's logs in Sentry. +**WARNING**: This transport is in a **pre-release alpha**. The API is unstable and may change at any time. + +A Pino transport for sending logs to Sentry using the Sentry JavaScript SDK. + +This transport forwards Pino logs to Sentry, allowing you to view and analyze your application logs alongside your errors and performance data in Sentry. ## Installation ```bash -npm install @sentry/node @sentry/pino-transport +npm install @sentry/pino-transport pino # or -yarn add @sentry/node @sentry/pino-transport +yarn add @sentry/pino-transport pino +# or +pnpm add @sentry/pino-transport pino ``` -## Usage +## Requirements + +- Node.js 18+ +- Pino v8 or v9 +- `@sentry/node` SDK with `_experiments.enableLogs: true` -TODO: Add usage instructions +## Setup -## Requirements +First, make sure Sentry is initialized with logging enabled: + +```javascript +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'YOUR_DSN', + _experiments: { + enableLogs: true, + }, +}); +``` + +Then create a Pino logger with the Sentry transport: + +```javascript +import pino from 'pino'; + +const logger = pino({ + transport: { + target: '@sentry/pino-transport', + options: { + // Optional: filter which log levels to send to Sentry + levels: ['error', 'fatal'], // defaults to all levels + }, + }, +}); + +// Now your logs will be sent to Sentry +logger.info('This is an info message'); +logger.error('This is an error message'); +``` + +## Configuration Options + +The transport accepts the following options: + +### `logLevels` + +**Type:** `Array<'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'>` + +**Default:** `['trace', 'debug', 'info', 'warn', 'error', 'fatal']` (all log levels) + +Use this option to filter which log severity levels should be sent to Sentry. + +```javascript +const transport = pino.transport({ + target: '@sentry/pino-transport', + options: { + logLevels: ['warn', 'error', 'fatal'], // Only send warnings and above + }, +}); +``` + +## Log Level Mapping + +Pino log levels are automatically mapped to Sentry log severity levels: + +| Pino Level | Pino Numeric | Sentry Level | +| ---------- | ------------ | ------------ | +| trace | 10 | trace | +| debug | 20 | debug | +| info | 30 | info | +| warn | 40 | warn | +| error | 50 | error | +| fatal | 60 | fatal | + +### Custom Levels Support + +Custom numeric levels are mapped to Sentry levels using ranges, so levels like `11`, `23`, or `42` will map correctly: + +- `0-19` → `trace` +- `20-29` → `debug` +- `30-39` → `info` +- `40-49` → `warn` +- `50-59` → `error` +- `60+` → `fatal` + +```javascript +import pino from 'pino'; + +const logger = pino({ + customLevels: { + critical: 55, // Maps to 'fatal' (55+ range) + notice: 35, // Maps to 'warn' (35-44 range) + verbose: 11, // Maps to 'trace' (0-14 range) + }, + transport: { + target: '@sentry/pino-transport', + }, +}); + +logger.critical('Critical issue occurred'); // → Sent as 'fatal' to Sentry +logger.notice('Important notice'); // → Sent as 'warn' to Sentry +logger.verbose('Detailed information'); // → Sent as 'trace' to Sentry +``` + +#### Custom Level Attributes + +When using custom string levels, the original level name is preserved as `sentry.pino.level` attribute for better traceability: + +```javascript +// Log entry in Sentry will include: +// { +// level: 'warn', // Mapped Sentry level +// message: 'Audit event', +// attributes: { +// 'sentry.pino.level': 'audit', // Original custom level name +// 'sentry.origin': 'auto.logging.pino', +// // ... other log attributes +// } +// } +``` + +### Custom Message Key + +The transport respects Pino's `messageKey` configuration: + +```javascript +const logger = pino({ + messageKey: 'message', // Use 'message' instead of default 'msg' + transport: { + target: '@sentry/pino-transport', + }, +}); + +logger.info({ message: 'Hello world' }); // Works correctly with custom messageKey +``` + +### Nested Key Support + +The transport automatically supports Pino's `nestedKey` configuration, which is used to avoid property conflicts by nesting logged objects under a specific key. When `nestedKey` is configured, the transport flattens these nested properties using dot notation for better searchability in Sentry. + +```javascript +const logger = pino({ + nestedKey: 'payload', // Nest logged objects under 'payload' key + transport: { + target: '@sentry/pino-transport', + }, +}); + +const conflictingObject = { + level: 'hi', // Conflicts with Pino's level + time: 'never', // Conflicts with Pino's time + foo: 'bar', + userId: 123, +}; + +logger.info(conflictingObject); + +// Without nestedKey, this would cause property conflicts +// With nestedKey, Pino creates: { level: 30, time: 1234567890, payload: conflictingObject } +// The transport flattens it to: +// { +// level: 'info', +// message: undefined, +// attributes: { +// 'payload.level': 'hi', // Flattened nested properties +// 'payload.time': 'never', +// 'payload.foo': 'bar', +// 'payload.userId': 123, +// 'sentry.origin': 'auto.logging.pino', +// } +// } +``` + +This flattening ensures that no property conflicts occur between logged objects and Pino's internal properties. + +## Usage Examples + +### Basic Logging + +```javascript +import pino from 'pino'; + +const logger = pino({ + transport: { + target: '@sentry/pino-transport', + }, +}); + +logger.trace('Starting application'); +logger.debug('Debug information', { userId: 123 }); +logger.info('User logged in', { userId: 123, username: 'john_doe' }); +logger.warn('Deprecated API used', { endpoint: '/old-api' }); +logger.error('Database connection failed', { error: 'Connection timeout' }); +logger.fatal('Application crashed', { reason: 'Out of memory' }); +``` + +### Multiple Transports + +```javascript +import pino from 'pino'; + +const logger = pino({ + transport: { + targets: [ + { + target: 'pino-pretty', + options: { colorize: true }, + level: 'debug', + }, + { + target: '@sentry/pino-transport', + options: { + logLevels: ['warn', 'error', 'fatal'], + }, + level: 'warn', + }, + ], + }, +}); +``` + +## Troubleshooting + +### Logs not appearing in Sentry + +1. Ensure `_experiments.enableLogs: true` is set in your Sentry configuration. +2. Check that your DSN is correct and the SDK is properly initialized. +3. Verify the log level is included in the `levels` configuration. +4. Check your Sentry organization stats page to see if logs are being received by Sentry. + +## Related Documentation -- Node.js 18 or higher -- Pino 8.0.0 or higher -- @sentry/node must be configured in your application +- [Sentry Logs Documentation](https://docs.sentry.io/platforms/javascript/guides/node/logs/) +- [Pino Documentation](https://getpino.io/) +- [Pino Transports](https://getpino.io/#/docs/transports) ## License diff --git a/packages/pino-transport/package.json b/packages/pino-transport/package.json index be2445ce2afd..59b1153d6321 100644 --- a/packages/pino-transport/package.json +++ b/packages/pino-transport/package.json @@ -39,7 +39,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.31.0" + "@sentry/node": "9.31.0", + "@sentry/core": "9.31.0", + "pino-abstract-transport": "^2.0.0" }, "peerDependencies": { "pino": "^8.0.0 || ^9.0.0" diff --git a/packages/pino-transport/src/debug-build.ts b/packages/pino-transport/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/pino-transport/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/pino-transport/src/index.ts b/packages/pino-transport/src/index.ts index 849aa99b4f7c..34cfe9ecde86 100644 --- a/packages/pino-transport/src/index.ts +++ b/packages/pino-transport/src/index.ts @@ -1,2 +1,244 @@ -// TODO: Implement this -export {}; +import type { LogSeverityLevel } from '@sentry/core'; +import { _INTERNAL_captureLog, isPrimitive, logger, normalize } from '@sentry/core'; +import type buildType from 'pino-abstract-transport'; +import * as pinoAbstractTransport from 'pino-abstract-transport'; +import { DEBUG_BUILD } from './debug-build'; + +// Handle both CommonJS and ES module exports +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any +const build = (pinoAbstractTransport as any).default || pinoAbstractTransport; + +/** + * The default log levels that will be captured by the Sentry Pino transport. + */ +const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + +/** + * Options for the Sentry Pino transport. + */ +export interface SentryPinoTransportOptions { + /** + * Use this option to filter which levels should be captured as logs. + * By default, all levels are captured as logs. + * + * @example + * ```ts + * const logger = pino({ + * transport: { + * target: '@sentry/pino-transport', + * options: { + * logLevels: ['error', 'warn'], // Only capture error and warn logs + * }, + * }, + * }); + * ``` + */ + logLevels?: Array; +} + +/** + * Pino source configuration passed to the transport. + * This interface represents the configuration options that Pino provides to transports. + */ +interface PinoSourceConfig { + /** + * Custom levels configuration from Pino. + * Contains the mapping of custom level names to numeric values. + * + * @default undefined + * @example { values: { critical: 55, notice: 35 } } + */ + levels?: unknown; + + /** + * The property name used for the log message. + * Pino allows customizing which property contains the main log message. + * + * @default 'msg' + * @example 'message' when configured with messageKey: 'message' + * @see https://getpino.io/#/docs/api?id=messagekey-string + */ + messageKey?: string; + + /** + * The property name used for error objects. + * Pino allows customizing which property contains error information. + * + * @default 'err' + * @example 'error' when configured with errorKey: 'error' + * @see https://getpino.io/#/docs/api?id=errorkey-string + */ + errorKey?: string; + + /** + * The property name used to nest logged objects to avoid conflicts. + * When set, Pino nests all logged objects under this key to prevent + * conflicts with Pino's internal properties (level, time, pid, etc.). + * The transport flattens these nested properties using dot notation. + * + * @default undefined (no nesting) + * @example 'payload' - objects logged will be nested under { payload: {...} } + * @see https://getpino.io/#/docs/api?id=nestedkey-string + */ + nestedKey?: string; +} + +/** + * Creates a new Sentry Pino transport that forwards logs to Sentry. Requires `_experiments.enableLogs` to be enabled. + * + * Supports Pino v8 and v9. + * + * @param options - Options for the transport. + * @returns A Pino transport that forwards logs to Sentry. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + */ +export function createSentryPinoTransport(options?: SentryPinoTransportOptions): ReturnType { + DEBUG_BUILD && logger.log('Initializing Sentry Pino transport'); + const capturedLogLevels = new Set(options?.logLevels ?? DEFAULT_CAPTURED_LEVELS); + + return build( + async function (source: AsyncIterable & PinoSourceConfig) { + for await (const log of source) { + try { + if (!isObject(log)) { + continue; + } + + // Use Pino's messageKey if available, fallback to 'msg' + const messageKey = source.messageKey || 'msg'; + const message = log[messageKey]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [messageKey]: _, level, time, ...attributes } = log; + + // Handle nestedKey flattening if configured + if (source.nestedKey && attributes[source.nestedKey] && isObject(attributes[source.nestedKey])) { + const nestedObject = attributes[source.nestedKey] as Record; + // Remove the nested object and flatten its properties + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[source.nestedKey]; + + // Flatten nested properties with dot notation + for (const [key, value] of Object.entries(nestedObject)) { + attributes[`${source.nestedKey}.${key}`] = value; + } + } + + const logSeverityLevel = mapPinoLevelToSentryLevel(log.level, source.levels); + + if (capturedLogLevels.has(logSeverityLevel)) { + const logAttributes: Record = { + ...attributes, + 'sentry.origin': 'auto.logging.pino', + }; + + // Attach custom level as an attribute if it's a string (custom level) + if (typeof log.level === 'string') { + logAttributes['sentry.pino.level'] = log.level; + } + + _INTERNAL_captureLog({ + level: logSeverityLevel, + message: formatMessage(message), + attributes: logAttributes, + }); + } + } catch { + // Silently ignore errors to prevent breaking the logging pipeline + } + } + }, + { + expectPinoConfig: true, + }, + ); +} + +function formatMessage(message: unknown): string { + if (message === undefined) { + return ''; + } + + if (isPrimitive(message)) { + return String(message); + } + return JSON.stringify(normalize(message)); +} + +/** + * Maps a Pino log level (numeric or custom string) to a Sentry log severity level. + * + * Handles both standard and custom levels, including when `useOnlyCustomLevels` is enabled. + * Uses range-based mapping for numeric levels to handle custom values (e.g., 11 -> trace). + */ +function mapPinoLevelToSentryLevel(level: unknown, levelsConfig?: unknown): LogSeverityLevel { + // Handle numeric levels + if (typeof level === 'number') { + return mapNumericLevelToSentryLevel(level); + } + + // Handle custom string levels + if ( + typeof level === 'string' && + isObject(levelsConfig) && + 'values' in levelsConfig && + isObject(levelsConfig.values) + ) { + // Map custom string levels to numeric then to Sentry levels + const numericLevel = levelsConfig.values[level]; + if (typeof numericLevel === 'number') { + return mapNumericLevelToSentryLevel(numericLevel); + } + } + + // Default fallback + return 'info'; +} + +/** + * Maps a numeric level to the closest Sentry severity level using range-based mapping. + * Handles both standard Pino levels and custom numeric levels. + * + * - `0-19` -> `trace` + * - `20-29` -> `debug` + * - `30-39` -> `info` + * - `40-49` -> `warn` + * - `50-59` -> `error` + * - `60+` -> `fatal` + * + * @see https://github.com/pinojs/pino/blob/116b1b17935630b97222fbfd1c053d199d18ca4b/lib/constants.js#L6-L13 + */ +function mapNumericLevelToSentryLevel(numericLevel: number): LogSeverityLevel { + // 0-19 -> trace + if (numericLevel < 20) { + return 'trace'; + } + // 20-29 -> debug + if (numericLevel < 30) { + return 'debug'; + } + // 30-39 -> info + if (numericLevel < 40) { + return 'info'; + } + // 40-49 -> warn + if (numericLevel < 50) { + return 'warn'; + } + // 50-59 -> error + if (numericLevel < 60) { + return 'error'; + } + // 60+ -> fatal + return 'fatal'; +} + +/** + * Type guard to check if a value is an object. + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value != null; +} + +export default createSentryPinoTransport; diff --git a/packages/pino-transport/test/index.test.ts b/packages/pino-transport/test/index.test.ts index 9329d9cbaede..a93d56f340cd 100644 --- a/packages/pino-transport/test/index.test.ts +++ b/packages/pino-transport/test/index.test.ts @@ -1,8 +1,708 @@ -import { describe, expect, it } from 'vitest'; -import * as index from '../src'; +import { _INTERNAL_captureLog } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryPinoTransport } from '../src'; + +// Mock the _INTERNAL_captureLog function +vi.mock('@sentry/core', async actual => { + const actualModule = (await actual()) as any; + return { + ...actualModule, + _INTERNAL_captureLog: vi.fn(), + }; +}); + +const mockCaptureLog = vi.mocked(_INTERNAL_captureLog); + +describe('createSentryPinoTransport', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); -describe('pino-transport', () => { it('should be defined', () => { - expect(index).toBeDefined(); + expect(createSentryPinoTransport).toBeDefined(); + }); + + it('should create a transport that forwards logs to Sentry', async () => { + const transport = await createSentryPinoTransport(); + expect(transport).toBeDefined(); + expect(typeof transport.write).toBe('function'); + }); + + it('should capture logs with correct level mapping', async () => { + const transport = await createSentryPinoTransport(); + + // Simulate a Pino log entry + const testLog = { + level: 30, // info level in Pino + msg: 'Test message', + time: Date.now(), + hostname: 'test-host', + pid: 12345, + }; + + // Write the log to the transport + transport.write(`${JSON.stringify(testLog)}\n`); + + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test message', + attributes: expect.objectContaining({ + hostname: 'test-host', + pid: 12345, + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should map all Pino log levels correctly', async () => { + const transport = await createSentryPinoTransport(); + + const testCases = [ + { pinoLevel: 10, expectedSentryLevel: 'trace' }, + { pinoLevel: 20, expectedSentryLevel: 'debug' }, + { pinoLevel: 30, expectedSentryLevel: 'info' }, + { pinoLevel: 40, expectedSentryLevel: 'warn' }, + { pinoLevel: 50, expectedSentryLevel: 'error' }, + { pinoLevel: 60, expectedSentryLevel: 'fatal' }, + ]; + + for (const { pinoLevel, expectedSentryLevel } of testCases) { + const testLog = { + level: pinoLevel, + msg: `Test ${expectedSentryLevel} message`, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + } + + // Give it a moment to process all logs + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledTimes(6); + + testCases.forEach(({ expectedSentryLevel }, index) => { + expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { + level: expectedSentryLevel, + message: `Test ${expectedSentryLevel} message`, + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + }); + + it('should respect level filtering', async () => { + const transport = await createSentryPinoTransport({ + logLevels: ['error', 'fatal'], + }); + + const testLogs = [ + { level: 30, msg: 'Info message' }, // Should be filtered out + { level: 50, msg: 'Error message' }, // Should be captured + { level: 60, msg: 'Fatal message' }, // Should be captured + ]; + + for (const testLog of testLogs) { + transport.write(`${JSON.stringify(testLog)}\n`); + } + + // Give it a moment to process all logs + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledTimes(2); + expect(mockCaptureLog).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + level: 'error', + message: 'Error message', + }), + ); + expect(mockCaptureLog).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + level: 'fatal', + message: 'Fatal message', + }), + ); + }); + + it('should handle unknown levels gracefully', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 999, // Unknown level + msg: 'Unknown level message', + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'fatal', // 999 maps to fatal (55+ range) + message: 'Unknown level message', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle non-numeric levels gracefully', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 'invalid', // Non-numeric level + msg: 'Invalid level message', + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', // Default fallback + message: 'Invalid level message', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': 'invalid', + }), + }); + }); + + it('should handle malformed JSON gracefully', async () => { + const transport = await createSentryPinoTransport(); + + // Write invalid JSON + transport.write('{ invalid json \n'); + + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 10)); + + // Should not crash and should not call captureLog + expect(mockCaptureLog).not.toHaveBeenCalled(); + }); + + it('should handle non-object logs gracefully', async () => { + const transport = await createSentryPinoTransport(); + + // Write a string instead of an object + transport.write('"just a string"\n'); + + // Give it a moment to process + await new Promise(resolve => setTimeout(resolve, 10)); + + // pino-abstract-transport parses JSON, so this actually becomes an object + // The transport should handle it gracefully by logging it + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', // Default fallback since no level provided + message: '', // Empty string for undefined message + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle string levels gracefully when no custom levels config is available', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 'custom', // String level without custom levels config + msg: 'Custom string level message', + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', // Should fallback to info for unknown string levels + message: 'Custom string level message', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': 'custom', + }), + }); + }); + + it('should attach custom level name as attribute for string levels', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 'critical', // Custom string level + msg: 'Critical level message', + time: Date.now(), + userId: 123, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', // Mapped level + message: 'Critical level message', + attributes: expect.objectContaining({ + userId: 123, + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': 'critical', // Original custom level name preserved + }), + }); + }); + + it('should not attach custom level attribute for numeric levels', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, // Standard numeric level + msg: 'Standard level message', + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Standard level message', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + // Should NOT have 'sentry.pino.level' for numeric levels + }), + }); + + // Explicitly check that the custom level attribute is not present + const capturedCall = mockCaptureLog.mock.calls[0][0]; + expect(capturedCall.attributes).not.toHaveProperty('sentry.pino.level'); + }); + + it('should handle custom numeric levels with range-based mapping', async () => { + const transport = await createSentryPinoTransport(); + + const testCases = [ + { level: 11, expectedSentryLevel: 'trace' }, // 11 is in trace range (0-14) + { level: 23, expectedSentryLevel: 'debug' }, // 23 is in debug range (15-24) + { level: 33, expectedSentryLevel: 'info' }, // 33 is in info range (25-34) + { level: 42, expectedSentryLevel: 'warn' }, // 42 is in warn range (35-44) + { level: 52, expectedSentryLevel: 'error' }, // 52 is in error range (45-54) + { level: 75, expectedSentryLevel: 'fatal' }, // 75 is in fatal range (55+) + ]; + + for (const { level } of testCases) { + const testLog = { + level, + msg: `Custom numeric level ${level}`, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledTimes(6); + + testCases.forEach(({ level, expectedSentryLevel }, index) => { + expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { + level: expectedSentryLevel, + message: `Custom numeric level ${level}`, + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + }); + + it('should handle nested keys', async () => { + const transport = await createSentryPinoTransport(); + + // Test with logs that include a nested object structure as Pino would create + // when nestedKey is configured (we'll test by manually checking the flattening logic) + const testLog = { + level: 30, + msg: 'Test message with nested payload', + time: Date.now(), + payload: { + level: 'hi', // Conflicting with Pino's level + time: 'never', // Conflicting with Pino's time + foo: 'bar', + userId: 123, + }, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Without nestedKey configuration, the nested object should remain as-is + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test message with nested payload', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + payload: { + level: 'hi', + time: 'never', + foo: 'bar', + userId: 123, + }, // Should remain nested without nestedKey config + }), + }); + }); + + it('should handle logs without conflicting nested objects', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 40, + msg: 'Warning with simple nested data', + time: Date.now(), + data: { + errorCode: 'E001', + module: 'auth', + details: 'Invalid credentials', + }, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'warn', + message: 'Warning with simple nested data', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + data: { + errorCode: 'E001', + module: 'auth', + details: 'Invalid credentials', + }, // Should remain as nested object + }), + }); + }); + + it('should handle logs with multiple nested objects', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: 'Test message with multiple nested objects', + time: Date.now(), + user: { + id: 123, + name: 'John Doe', + }, + request: { + method: 'POST', + url: '/api/users', + headers: { + 'content-type': 'application/json', + }, + }, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test message with multiple nested objects', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + user: { + id: 123, + name: 'John Doe', + }, + request: { + method: 'POST', + url: '/api/users', + headers: { + 'content-type': 'application/json', + }, + }, + }), + }); + }); + + it('should handle null nested objects', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: 'Test message with null values', + time: Date.now(), + data: null, + user: undefined, + config: { + setting: null, + }, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test message with null values', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + data: null, + config: { + setting: null, + }, + }), + }); + }); + + it('should work normally with mixed data types', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: 'Mixed data types log', + time: Date.now(), + stringValue: 'test', + numberValue: 42, + booleanValue: true, + arrayValue: [1, 2, 3], + objectValue: { nested: 'value' }, + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Mixed data types log', + attributes: expect.objectContaining({ + stringValue: 'test', + numberValue: 42, + booleanValue: true, + arrayValue: [1, 2, 3], + objectValue: { nested: 'value' }, + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle string messages', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: 'This is a string message', + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'This is a string message', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle number messages', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: 42, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: '42', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle boolean messages', async () => { + const transport = await createSentryPinoTransport(); + + const testCases = [{ msg: true }, { msg: false }]; + + for (const { msg } of testCases) { + const testLog = { + level: 30, + msg, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledTimes(2); + expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { + level: 'info', + message: 'true', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { + level: 'info', + message: 'false', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle null and undefined messages', async () => { + const transport = await createSentryPinoTransport(); + + const testCases = [{ msg: null }, { msg: undefined }]; + + for (const { msg } of testCases) { + const testLog = { + level: 30, + msg, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledTimes(2); + expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { + level: 'info', + message: 'null', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { + level: 'info', + message: '', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle object messages', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: { key: 'value', nested: { prop: 123 } }, + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: '{"key":"value","nested":{"prop":123}}', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle array messages', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + msg: [1, 'two', { three: 3 }], + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: '[1,"two",{"three":3}]', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle circular object messages gracefully', async () => { + const transport = await createSentryPinoTransport(); + + // Create a test log with a circular object as the message + // We can't use JSON.stringify directly, so we'll simulate what happens + const testLog = { + level: 30, + msg: { name: 'test', circular: true }, // Simplified object that represents circular data + time: Date.now(), + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: '{"name":"test","circular":true}', // The object should be serialized normally + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.logging.pino', + }), + }); + }); + + it('should handle missing message gracefully', async () => { + const transport = await createSentryPinoTransport(); + + const testLog = { + level: 30, + // No msg property + time: Date.now(), + someOtherData: 'value', + }; + + transport.write(`${JSON.stringify(testLog)}\n`); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: '', // Empty string for undefined message + attributes: expect.objectContaining({ + someOtherData: 'value', + 'sentry.origin': 'auto.logging.pino', + }), + }); }); }); diff --git a/packages/pino-transport/vite.config.ts b/packages/pino-transport/vite.config.ts index 4ac6027d5789..ff64487a9265 100644 --- a/packages/pino-transport/vite.config.ts +++ b/packages/pino-transport/vite.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; export default defineConfig({ + ...baseConfig, test: { + ...baseConfig.test, environment: 'node', }, }); diff --git a/yarn.lock b/yarn.lock index 8c4b670fd328..c5749e9c4cc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24541,6 +24541,11 @@ quick-format-unescaped@^4.0.3: resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"