diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 596109c0a596..17c6f714c499 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -53,6 +53,7 @@ const DEPENDENTS: Dependent[] = [ 'NODE_VERSION', 'childProcessIntegration', 'systemErrorIntegration', + 'pinoIntegration', ], }, { diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 3deeb1ae0df4..787c7850a736 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -62,6 +62,7 @@ "node-schedule": "^2.1.1", "openai": "5.18.1", "pg": "8.16.0", + "pino": "9.9.4", "postgres": "^3.4.7", "prisma": "6.15.0", "proxy": "^2.1.1", diff --git a/dev-packages/node-integration-tests/suites/pino/instrument.mjs b/dev-packages/node-integration-tests/suites/pino/instrument.mjs new file mode 100644 index 000000000000..f57a519a2331 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs new file mode 100644 index 000000000000..3ff6c0b5e08d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts new file mode 100644 index 000000000000..1efc16094fc3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -0,0 +1,98 @@ +import { join } from 'path'; +import { expect, test } from 'vitest'; +import { conditionalTest } from '../../utils'; +import { createRunner } from '../../utils/runner'; + +conditionalTest({ min: 20 })('Pino integration', () => { + test('has different trace ids for logs from different spans', async () => { + // expect.assertions(1); + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .ignore('event') + .expect({ + log: log => { + const traceId1 = log.items?.[0]?.trace_id; + const traceId2 = log.items?.[1]?.trace_id; + expect(traceId1).not.toBe(traceId2); + }, + }) + .start() + .completed(); + }); + + test('captures event and logs', async () => { + // expect.assertions(1); + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'oh no', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario', + context_line: " logger.error(new Error('oh no'));", + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + 'something.more': { value: 3, type: 'integer' }, + 'something.complex': { value: 'nope', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 5abf8d51633d..fa30eebfb553 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -90,6 +90,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, parameterize, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 541f8a97a410..21d495255bd3 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -104,6 +104,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index e2f1ad5fc2b2..2eb7f39f3a24 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -48,6 +48,8 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; + _sentryInjectLoaderHookRegister?: () => void; + _sentryInjectLoaderHookRegistered?: boolean; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index e8042e4260a8..1f54dfa1df9a 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -104,6 +104,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 7009b61a5feb..0011766ef7f1 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -66,11 +66,13 @@ "@opentelemetry/semantic-conventions": "^1.37.0" }, "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.2.0", "@sentry/core": "10.12.0", "@sentry/opentelemetry": "10.12.0", "import-in-the-middle": "^1.14.2" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.7.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 87f96f09ab8e..3b176de2cf55 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -24,6 +24,7 @@ export { spotlightIntegration } from './integrations/spotlight'; export { systemErrorIntegration } from './integrations/systemError'; export { childProcessIntegration } from './integrations/childProcess'; export { createSentryWinstonTransport } from './integrations/winston'; +export { pinoIntegration } from './integrations/pino'; export { SentryContextManager } from './otel/contextManager'; export { setupOpenTelemetryLogger } from './otel/logger'; diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts new file mode 100644 index 000000000000..507953cc6978 --- /dev/null +++ b/packages/node-core/src/integrations/pino.ts @@ -0,0 +1,135 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import { + _INTERNAL_captureLog, + addExceptionMechanism, + captureException, + captureMessage, + defineIntegration, + severityLevelFromString, + withScope, +} from '@sentry/core'; +import { addInstrumentationConfig } from '../sdk/injectLoader'; + +type LevelMapping = { + // Fortunately pino uses the same levels as Sentry + labels: { [level: number]: LogSeverityLevel }; +}; + +type Pino = { + levels: LevelMapping; +}; + +type MergeObject = { + [key: string]: unknown; + err?: Error; +}; + +type PinoHookArgs = [MergeObject, string, number]; + +type Options = { + /** + * Levels that trigger capturing of events. + * + * @default ["error", "fatal"] + */ + eventLevels?: LogSeverityLevel[]; + /** + * By default, Sentry will mark captured console messages as handled. + * Set this to `false` if you want to mark them as unhandled instead. + * + * @default true + */ + handled?: boolean; +}; + +function attributesFromObject(obj: object, attr: Record, key?: string): Record { + for (const [k, v] of Object.entries(obj)) { + const newKey = key ? `${key}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Error)) { + attributesFromObject(v as object, attr, newKey); + } else { + attr[newKey] = v; + } + } + return attr; +} + +const DEFAULT_OPTIONS: Options = { eventLevels: ['error', 'fatal'], handled: true }; + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + */ +export const pinoIntegration = defineIntegration((options: Options = DEFAULT_OPTIONS) => { + return { + name: 'Pino', + setup: client => { + const enableLogs = !!client.getOptions().enableLogs; + + addInstrumentationConfig({ + channelName: 'pino-log', + // From Pino v9.10.0 a tracing channel is available directly from Pino: + // https://github.com/pinojs/pino/pull/2281 + module: { name: 'pino', versionRange: '>=8.0.0 < 9.10.0', filePath: 'lib/tools.js' }, + functionQuery: { + functionName: 'asJson', + kind: 'Sync', + }, + }); + + const injectedChannel = tracingChannel('orchestrion:pino:pino-log'); + const integratedChannel = tracingChannel('tracing:pino_asJson'); + + function onPinoStart(self: Pino, args: PinoHookArgs): void { + const [obj, message, levelNumber] = args; + const level = self?.levels?.labels?.[levelNumber] || 'info'; + + const attributes = attributesFromObject(obj, { + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': levelNumber, + }); + + if (enableLogs) { + _INTERNAL_captureLog({ level, message, attributes }); + } + + if (options.eventLevels?.includes(level)) { + const captureContext = { + level: severityLevelFromString(level), + }; + + withScope(scope => { + scope.addEventProcessor(event => { + event.logger = 'pino'; + + addExceptionMechanism(event, { + handled: !!options.handled, + type: 'pino', + }); + + return event; + }); + + if (obj.err) { + captureException(obj.err, captureContext); + return; + } + + captureMessage(message, captureContext); + }); + } + } + + injectedChannel.start.subscribe(data => { + const { self, arguments: args } = data as { self: Pino; arguments: PinoHookArgs }; + onPinoStart(self, args); + }); + + integratedChannel.start.subscribe(data => { + const { instance, arguments: args } = data as { instance: Pino; arguments: PinoHookArgs }; + onPinoStart(instance, args); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..c4ae4897678d --- /dev/null +++ b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts @@ -0,0 +1,11 @@ +declare module '@apm-js-collab/tracing-hooks' { + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type PatchConfig = { instrumentations: InstrumentationConfig[] }; + + /** Hooks require */ + export default class ModulePatch { + public constructor(config: PatchConfig): ModulePatch; + public patch(): void; + } +} diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index c4a16d76a1d0..d53f5d4faefb 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -7,6 +7,7 @@ import { functionToStringIntegration, getCurrentScope, getIntegrationsToSetup, + GLOBAL_OBJ, hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, @@ -131,6 +132,8 @@ function _init( client.init(); + GLOBAL_OBJ._sentryInjectLoaderHookRegister?.(); + debug.log(`SDK initialized from ${isCjs() ? 'CommonJS' : 'ESM'}`); client.startClientReportTracking(); diff --git a/packages/node-core/src/sdk/injectLoader.ts b/packages/node-core/src/sdk/injectLoader.ts new file mode 100644 index 000000000000..667996ebbe53 --- /dev/null +++ b/packages/node-core/src/sdk/injectLoader.ts @@ -0,0 +1,46 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import * as moduleModule from 'module'; +import { supportsEsmLoaderHooks } from '../utils/detection'; + +let instrumentationConfigs: InstrumentationConfig[] | undefined; + +/** + * Add an instrumentation config to be used by the injection loader. + */ +export function addInstrumentationConfig(config: InstrumentationConfig): void { + if (!supportsEsmLoaderHooks()) { + return; + } + + if (!instrumentationConfigs) { + instrumentationConfigs = []; + } + + instrumentationConfigs.push(config); + + GLOBAL_OBJ._sentryInjectLoaderHookRegister = () => { + if (GLOBAL_OBJ._sentryInjectLoaderHookRegistered) { + return; + } + + GLOBAL_OBJ._sentryInjectLoaderHookRegistered = true; + + const instrumentations = instrumentationConfigs || []; + + // Patch require to support CJS modules + const requirePatch = new ModulePatch({ instrumentations }); + requirePatch.patch(); + + // Add ESM loader to support ESM modules + try { + // @ts-expect-error register is available in these versions + moduleModule.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations }, + }); + } catch (error) { + debug.warn("Failed to register '@apm-js-collab/tracing-hooks' hook", error); + } + }; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 84603db7e575..ed111fc9bcaf 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -167,6 +167,7 @@ export { disableAnrDetectionForCallback, spotlightIntegration, childProcessIntegration, + pinoIntegration, createSentryWinstonTransport, SentryContextManager, systemErrorIntegration, diff --git a/yarn.lock b/yarn.lock index ebdb2b198675..847de1ef3387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -328,6 +328,20 @@ resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== +"@apm-js-collab/code-transformer@^0.7.0", "@apm-js-collab/code-transformer@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.7.2.tgz#8c848f9d28f8389c92cdd08bcdae938bc681acb6" + integrity sha512-waZeIZFmPCf5/nD3O1gMHlHEPMRwgnaTfVTaDbkclg5OByy8IowR/dUDij5tC77VYH/ieXIujSx9vPFvhx35Fg== + +"@apm-js-collab/tracing-hooks@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.2.0.tgz#b34f0857055895400e1ff0c15be5fd98cc378530" + integrity sha512-tBTkPVXDRQa5wrH18UHqgGowHF33+v6B4LeixmaeBCpwOwnCjNs21lHZDlP8JAkvWLRBJ4O2zFLQnJk6B7IM+w== + dependencies: + "@apm-js-collab/code-transformer" "^0.7.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -22422,10 +22436,10 @@ module-definition@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -module-details-from-path@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" - integrity sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A== +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== module-lookup-amd@^8.0.5: version "8.0.5" @@ -24728,10 +24742,10 @@ pino-std-serializers@^7.0.0: resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^9.0.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-9.7.0.tgz#ff7cd86eb3103ee620204dbd5ca6ffda8b53f645" - integrity sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg== +pino@9.9.4, pino@^9.0.0: + version "9.9.4" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.9.4.tgz#21ed2c27cc177f797e3249c99d340f0bcd6b248e" + integrity sha512-d1XorUQ7sSKqVcYdXuEYs2h1LKxejSorMEJ76XoZ0pPDf8VzJMe7GlPXpMBZeQ9gE4ZPIp5uGD+5Nw7scxiigg== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" @@ -28770,7 +28784,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"