Skip to content
Closed
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const DEPENDENTS: Dependent[] = [
'NODE_VERSION',
'childProcessIntegration',
'systemErrorIntegration',
'pinoIntegration',
],
},
{
Expand Down
1 change: 1 addition & 0 deletions dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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()],
});
18 changes: 18 additions & 0 deletions dev-packages/node-integration-tests/suites/pino/scenario.mjs
Original file line number Diff line number Diff line change
@@ -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);
98 changes: 98 additions & 0 deletions dev-packages/node-integration-tests/suites/pino/test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export {
onUnhandledRejectionIntegration,
openAIIntegration,
parameterize,
pinoIntegration,
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export {
mysql2Integration,
redisIntegration,
tediousIntegration,
pinoIntegration,
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type InternalGlobal = {
*/
_sentryModuleMetadata?: Record<string, any>;
_sentryEsmLoaderHookRegistered?: boolean;
_sentryInjectLoaderHookRegister?: () => void;
_sentryInjectLoaderHookRegistered?: boolean;
} & Carrier;

/** Get's the global object for the current JavaScript runtime */
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export {
mysql2Integration,
redisIntegration,
tediousIntegration,
pinoIntegration,
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
Expand Down
2 changes: 2 additions & 0 deletions packages/node-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/node-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
135 changes: 135 additions & 0 deletions packages/node-core/src/integrations/pino.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, key?: string): Record<string, unknown> {
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;
11 changes: 11 additions & 0 deletions packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
functionToStringIntegration,
getCurrentScope,
getIntegrationsToSetup,
GLOBAL_OBJ,
hasSpansEnabled,
inboundFiltersIntegration,
linkedErrorsIntegration,
Expand Down Expand Up @@ -131,6 +132,8 @@ function _init(

client.init();

GLOBAL_OBJ._sentryInjectLoaderHookRegister?.();

debug.log(`SDK initialized from ${isCjs() ? 'CommonJS' : 'ESM'}`);

client.startClientReportTracking();
Expand Down
Loading