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
+
+
+
+
+
-[](https://www.npmjs.com/package/@sentry/pino-transport)
-[](https://www.npmjs.com/package/@sentry/pino-transport)
-[](https://www.npmjs.com/package/@sentry/pino-transport)
+# Official Sentry Pino Transport
-**This package is currently in alpha. Breaking changes may still occur.**
+[](https://www.npmjs.com/package/@sentry/solid)
+[](https://www.npmjs.com/package/@sentry/solid)
+[](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"