Skip to content

feat(node): Add support for winston logger #15983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 14, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
@@ -67,6 +67,7 @@
"reflect-metadata": "0.2.1",
"rxjs": "^7.8.1",
"tedious": "^18.6.1",
"winston": "^3.17.0",
"yargs": "^16.2.0"
},
"devDependencies": {
73 changes: 73 additions & 0 deletions dev-packages/node-integration-tests/suites/winston/subject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as Sentry from '@sentry/node';
import winston from 'winston';
import Transport from 'winston-transport';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0.0',
environment: 'test',
_experiments: {
enableLogs: true,
},
transport: loggingTransport,
});

async function run(): Promise<void> {
// Create a custom transport that extends winston-transport
const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport);

// Create logger with default levels
const logger = winston.createLogger({
transports: [new SentryWinstonTransport()],
});

// Test basic logging
logger.info('Test info message');
logger.error('Test error message');

// If custom levels are requested
if (process.env.CUSTOM_LEVELS === 'true') {
const customLevels = {
levels: {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
silly: 6,
},
colors: {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
verbose: 'cyan',
debug: 'blue',
silly: 'grey',
},
};

const customLogger = winston.createLogger({
levels: customLevels.levels,
transports: [new SentryWinstonTransport()],
});

customLogger.info('Test info message');
customLogger.error('Test error message');
}

// If metadata is requested
if (process.env.WITH_METADATA === 'true') {
logger.info('Test message with metadata', {
foo: 'bar',
number: 42,
});
}

await Sentry.flush();
}

// eslint-disable-next-line @typescript-eslint/no-floating-promises
void run();
307 changes: 307 additions & 0 deletions dev-packages/node-integration-tests/suites/winston/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { afterAll, describe, test, expect } from 'vitest';
import { cleanupChildProcesses, createRunner } from '../../utils/runner';

describe('winston integration', () => {
afterAll(() => {
cleanupChildProcesses();
});

test('should capture winston logs with default levels', async () => {
const runner = createRunner(__dirname, 'subject.ts')
.expect({
otel_log: {
severityText: 'info',
body: {
stringValue: 'Test info message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.expect({
otel_log: {
severityText: 'error',
body: {
stringValue: 'Test error message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.start();

await runner.completed();
});

test('should capture winston logs with custom levels', async () => {
const runner = createRunner(__dirname, 'subject.ts')
.withEnv({ CUSTOM_LEVELS: 'true' })
.expect({
otel_log: {
severityText: 'info',
body: {
stringValue: 'Test info message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.expect({
otel_log: {
severityText: 'error',
body: {
stringValue: 'Test error message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.start();

await runner.completed();
});

test('should capture winston logs with metadata', async () => {
const runner = createRunner(__dirname, 'subject.ts')
.withEnv({ WITH_METADATA: 'true' })
.expect({
otel_log: {
severityText: 'info',
body: {
stringValue: 'Test info message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.expect({
otel_log: {
severityText: 'error',
body: {
stringValue: 'Test error message',
},
attributes: [
{
key: 'sentry.origin',
value: {
stringValue: 'auto.logging.winston',
},
},
{
key: 'sentry.release',
value: {
stringValue: '1.0.0',
},
},
{
key: 'sentry.environment',
value: {
stringValue: 'test',
},
},
{
key: 'sentry.sdk.name',
value: {
stringValue: 'sentry.javascript.node',
},
},
{
key: 'sentry.sdk.version',
value: {
stringValue: expect.any(String),
},
},
{
key: 'server.address',
value: {
stringValue: expect.any(String),
},
},
],
},
})
.start();

await runner.completed();
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -85,6 +85,7 @@ export {
postgresIntegration,
prismaIntegration,
childProcessIntegration,
createSentryWinstonTransport,
redisIntegration,
requestDataIntegration,
rewriteFramesIntegration,
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
@@ -100,6 +100,7 @@ export {
postgresIntegration,
prismaIntegration,
childProcessIntegration,
createSentryWinstonTransport,
hapiIntegration,
setupHapiErrorHandler,
spotlightIntegration,
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
@@ -134,6 +134,7 @@ export {
vercelAIIntegration,
logger,
consoleLoggingIntegration,
createSentryWinstonTransport,
} from '@sentry/node';

export {
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
@@ -112,6 +112,7 @@ export {
profiler,
amqplibIntegration,
childProcessIntegration,
createSentryWinstonTransport,
vercelAIIntegration,
logger,
consoleLoggingIntegration,
3 changes: 2 additions & 1 deletion packages/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -33,6 +33,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader';
export { amqplibIntegration } from './integrations/tracing/amqplib';
export { vercelAIIntegration } from './integrations/tracing/vercelai';
export { childProcessIntegration } from './integrations/childProcess';
export { createSentryWinstonTransport } from './integrations/winston';

export { SentryContextManager } from './otel/contextManager';
export { generateInstrumentOnce } from './otel/instrument';
@@ -152,6 +153,6 @@ export type {
Span,
} from '@sentry/core';

import * as logger from './log';
import * as logger from './logs/exports';

export { logger };
162 changes: 162 additions & 0 deletions packages/node/src/integrations/winston.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { LogSeverityLevel } from '@sentry/core';
import { captureLog } from '../logs/capture';

const DEFAULT_CAPTURED_LEVELS: Array<LogSeverityLevel> = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];

// See: https://github.com/winstonjs/triple-beam
const LEVEL_SYMBOL = Symbol.for('level');
const MESSAGE_SYMBOL = Symbol.for('message');
const SPLAT_SYMBOL = Symbol.for('splat');

/**
* Options for the Sentry Winston transport.
*/
interface WinstonTransportOptions {
/**
* Use this option to filter which levels should be captured. By default, all levels are captured.
*
* @example
* ```ts
* const transport = Sentry.createSentryWinstonTransport(Transport, {
* // Only capture error and warn logs
* levels: ['error', 'warn'],
* });
* ```
*/
levels?: Array<LogSeverityLevel>;
}

/**
* Creates a new Sentry Winston transport that fowards logs to Sentry. Requires `_experiments.enableLogs` to be enabled.
*
* Supports Winston 3.x.x.
*
* @param TransportClass - The Winston transport class to extend.
* @returns The extended transport class.
*
* @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.
*
* @example
* ```ts
* const winston = require('winston');
* const Transport = require('winston-transport');
*
* const transport = Sentry.createSentryWinstonTransport(Transport);
*
* const logger = winston.createLogger({
* transports: [transport],
* });
* ```
*/
export function createSentryWinstonTransport<TransportStreamInstance extends object>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TransportClass: new (options?: any) => TransportStreamInstance,
sentryWinstonOptions?: WinstonTransportOptions,
): typeof TransportClass {
// @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass
class SentryWinstonTransport extends TransportClass {
private _levels: Set<LogSeverityLevel>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public constructor(options?: any) {
super(options);
this._levels = new Set(sentryWinstonOptions?.levels ?? DEFAULT_CAPTURED_LEVELS);
}

/**
* Forwards a winston log to the Sentry SDK.
*/
public log(info: unknown, callback: () => void): void {
try {
setImmediate(() => {
// @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass
this.emit('logged', info);
});

if (!isObject(info)) {
return;
}

const levelFromSymbol = info[LEVEL_SYMBOL];

// See: https://github.com/winstonjs/winston?tab=readme-ov-file#streams-objectmode-and-info-objects
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { level, message, timestamp, ...attributes } = info;
// Remove all symbols from the remaining attributes
attributes[LEVEL_SYMBOL] = undefined;
attributes[MESSAGE_SYMBOL] = undefined;
attributes[SPLAT_SYMBOL] = undefined;

const logSeverityLevel = WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP[levelFromSymbol as string] ?? 'info';
if (this._levels.has(logSeverityLevel)) {
captureLog(logSeverityLevel, message as string, {
...attributes,
'sentry.origin': 'auto.logging.winston',
});
}
} catch {
// do nothing
}

if (callback) {
callback();
}
}
}

return SentryWinstonTransport as typeof TransportClass;
}

function isObject(anything: unknown): anything is Record<string | symbol, unknown> {
return typeof anything === 'object' && anything != null;
}

// npm
// {
// error: 0,
// warn: 1,
// info: 2,
// http: 3,
// verbose: 4,
// debug: 5,
// silly: 6
// }
//
// syslog
// {
// emerg: 0,
// alert: 1,
// crit: 2,
// error: 3,
// warning: 4,
// notice: 5,
// info: 6,
// debug: 7,
// }
const WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP: Record<string, LogSeverityLevel> = {
// npm
silly: 'trace',
// npm and syslog
debug: 'debug',
// npm
verbose: 'debug',
// npm
http: 'debug',
// npm and syslog
info: 'info',
// syslog
notice: 'info',
// npm
warn: 'warn',
// syslog
warning: 'warn',
// npm and syslog
error: 'error',
// syslog
emerg: 'fatal',
// syslog
alert: 'fatal',
// syslog
crit: 'fatal',
};
30 changes: 30 additions & 0 deletions packages/node/src/logs/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { format } from 'node:util';

import type { LogSeverityLevel, Log, ParameterizedString } from '@sentry/core';
import { _INTERNAL_captureLog } from '@sentry/core';

export type CaptureLogArgs =
| [message: ParameterizedString, attributes?: Log['attributes']]
| [messageTemplate: string, messageParams: Array<unknown>, attributes?: Log['attributes']];

/**
* Capture a log with the given level.
*
* @param level - The level of the log.
* @param message - The message to log.
* @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100.
*/
export function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void {
const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args;
if (Array.isArray(paramsOrAttributes)) {
const attributes = { ...maybeAttributes };
attributes['sentry.message.template'] = messageOrMessageTemplate;
paramsOrAttributes.forEach((param, index) => {
attributes[`sentry.message.parameter.${index}`] = param;
});
const message = format(messageOrMessageTemplate, ...paramsOrAttributes);
_INTERNAL_captureLog({ level, message, attributes });
} else {
_INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes });
}
}
31 changes: 1 addition & 30 deletions packages/node/src/log.ts → packages/node/src/logs/exports.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,4 @@
import { format } from 'node:util';

import type { LogSeverityLevel, Log, ParameterizedString } from '@sentry/core';
import { _INTERNAL_captureLog } from '@sentry/core';

type CaptureLogArgs =
| [message: ParameterizedString, attributes?: Log['attributes']]
| [messageTemplate: string, messageParams: Array<unknown>, attributes?: Log['attributes']];

/**
* Capture a log with the given level.
*
* @param level - The level of the log.
* @param message - The message to log.
* @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100.
*/
function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void {
const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args;
if (Array.isArray(paramsOrAttributes)) {
const attributes = { ...maybeAttributes };
attributes['sentry.message.template'] = messageOrMessageTemplate;
paramsOrAttributes.forEach((param, index) => {
attributes[`sentry.message.parameter.${index}`] = param;
});
const message = format(messageOrMessageTemplate, ...paramsOrAttributes);
_INTERNAL_captureLog({ level, message, attributes });
} else {
_INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes });
}
}
import { captureLog, type CaptureLogArgs } from './capture';

/**
* @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as sentryCore from '@sentry/core';
import * as nodeLogger from '../src/log';
import * as nodeLogger from '../../src/logs/exports';

// Mock the core functions
vi.mock('@sentry/core', async () => {
1 change: 1 addition & 0 deletions packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -114,6 +114,7 @@ export {
zodErrorsIntegration,
logger,
consoleLoggingIntegration,
createSentryWinstonTransport,
} from '@sentry/node';

// Keeping the `*` exports for backwards compatibility and types
1 change: 1 addition & 0 deletions packages/solidstart/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -117,6 +117,7 @@ export {
zodErrorsIntegration,
logger,
consoleLoggingIntegration,
createSentryWinstonTransport,
} from '@sentry/node';

// We can still leave this for the carrier init and type exports
1 change: 1 addition & 0 deletions packages/sveltekit/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -119,6 +119,7 @@ export {
zodErrorsIntegration,
logger,
consoleLoggingIntegration,
createSentryWinstonTransport,
} from '@sentry/node';

// We can still leave this for the carrier init and type exports
19 changes: 18 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
@@ -29526,7 +29526,7 @@ wildcard@^2.0.0:
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==

winston-transport@^4.7.0:
winston-transport@^4.7.0, winston-transport@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9"
integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==
@@ -29552,6 +29552,23 @@ winston@3.13.0:
triple-beam "^1.3.0"
winston-transport "^4.7.0"

winston@^3.17.0:
version "3.17.0"
resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423"
integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==
dependencies:
"@colors/colors" "^1.6.0"
"@dabh/diagnostics" "^2.0.2"
async "^3.2.3"
is-stream "^2.0.0"
logform "^2.7.0"
one-time "^1.0.0"
readable-stream "^3.4.0"
safe-stable-stringify "^2.3.1"
stack-trace "0.0.x"
triple-beam "^1.3.0"
winston-transport "^4.9.0"

word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.4"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"