diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d43f37b2d3b2..819493a3aea6 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -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": { diff --git a/dev-packages/node-integration-tests/suites/winston/subject.ts b/dev-packages/node-integration-tests/suites/winston/subject.ts new file mode 100644 index 000000000000..aff667aa64ca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/winston/subject.ts @@ -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://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + _experiments: { + enableLogs: true, + }, + transport: loggingTransport, +}); + +async function run(): Promise { + // 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(); diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts new file mode 100644 index 000000000000..60eeb7242154 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -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(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d89503eb9dfb..78bf958ce243 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -85,6 +85,7 @@ export { postgresIntegration, prismaIntegration, childProcessIntegration, + createSentryWinstonTransport, redisIntegration, requestDataIntegration, rewriteFramesIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 59465831a734..7dd6bcb597ca 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -100,6 +100,7 @@ export { postgresIntegration, prismaIntegration, childProcessIntegration, + createSentryWinstonTransport, hapiIntegration, setupHapiErrorHandler, spotlightIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index a1c26d5a2819..c8d11b4d101d 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -134,6 +134,7 @@ export { vercelAIIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; export { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 54ae30fb5c8c..5e6b81e9c68b 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -112,6 +112,7 @@ export { profiler, amqplibIntegration, childProcessIntegration, + createSentryWinstonTransport, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8467f3e3727d..8d999343a1ae 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -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 }; diff --git a/packages/node/src/integrations/winston.ts b/packages/node/src/integrations/winston.ts new file mode 100644 index 000000000000..74af701d7144 --- /dev/null +++ b/packages/node/src/integrations/winston.ts @@ -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 = ['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; +} + +/** + * 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( + // 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; + // 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 { + 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 = { + // 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', +}; diff --git a/packages/node/src/logs/capture.ts b/packages/node/src/logs/capture.ts new file mode 100644 index 000000000000..d4fdd11e99fb --- /dev/null +++ b/packages/node/src/logs/capture.ts @@ -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, 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 }); + } +} diff --git a/packages/node/src/log.ts b/packages/node/src/logs/exports.ts similarity index 75% rename from packages/node/src/log.ts rename to packages/node/src/logs/exports.ts index e66d8a24fd17..7c9299dc2660 100644 --- a/packages/node/src/log.ts +++ b/packages/node/src/logs/exports.ts @@ -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, 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. diff --git a/packages/node/test/log.test.ts b/packages/node/test/logs/exports.test.ts similarity index 98% rename from packages/node/test/log.test.ts rename to packages/node/test/logs/exports.test.ts index 6ad6678d12f1..7a7a67a1b777 100644 --- a/packages/node/test/log.test.ts +++ b/packages/node/test/logs/exports.test.ts @@ -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 () => { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 6c5319349294..69daf708dd31 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -114,6 +114,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da00b43a4fde..1753b6252517 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -117,6 +117,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f50420fd2937..ce2c3c476b56 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -119,6 +119,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/yarn.lock b/yarn.lock index 9160308494b2..e428498e05e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"