Skip to content

feat(node-experimental): Move errorHandler #10728

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 2 commits into from
Feb 20, 2024
Merged
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 @@ -34,6 +34,6 @@ app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/
res.send({ response: 'response 4' });
});

app.use(Sentry.Handlers.errorHandler());
app.use(Sentry.errorHandler());

startExpressServerAndSendPortToRunner(app);
7 changes: 4 additions & 3 deletions packages/node-experimental/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
export { errorHandler } from './sdk/handlers/errorHandler';

export { httpIntegration } from './integrations/http';
export { nativeNodeFetchIntegration } from './integrations/node-fetch';
export { expressIntegration } from './integrations/tracing/express';
export { fastifyIntegration } from './integrations/tracing/fastify';
export { graphqlIntegration } from './integrations/tracing/graphql';
export { httpIntegration } from './integrations/http';
export { mongoIntegration } from './integrations/tracing/mongo';
export { mongooseIntegration } from './integrations/tracing/mongoose';
export { mysqlIntegration } from './integrations/tracing/mysql';
export { mysql2Integration } from './integrations/tracing/mysql2';
export { nestIntegration } from './integrations/tracing/nest';
export { nativeNodeFetchIntegration } from './integrations/node-fetch';
export { postgresIntegration } from './integrations/tracing/postgres';
export { prismaIntegration } from './integrations/tracing/prisma';

export { init, getDefaultIntegrations } from './sdk/init';
export { getAutoPerformanceIntegrations } from './integrations/tracing';
export * as Handlers from './sdk/handlers';
export type { Span } from './types';

export { startSpan, startSpanManual, startInactiveSpan, getActiveSpan, withActiveSpan } from '@sentry/opentelemetry';
Expand Down
14 changes: 13 additions & 1 deletion packages/node-experimental/src/integrations/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { addBreadcrumb, defineIntegration, getIsolationScope, isSentryRequestUrl
import { _INTERNAL, getClient, getSpanKind, setSpanMetadata } from '@sentry/opentelemetry';
import type { IntegrationFn } from '@sentry/types';

import type { NodeClient } from '../sdk/client';
import { setIsolationScope } from '../sdk/scope';
import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module';
import { addOriginToSpan } from '../utils/addOriginToSpan';
Expand Down Expand Up @@ -86,14 +87,25 @@ const _httpIntegration = ((options: HttpOptions = {}) => {
if (getSpanKind(span) === SpanKind.SERVER) {
const isolationScope = getIsolationScope().clone();
isolationScope.setSDKProcessingMetadata({ request: req });
isolationScope.setRequestSession({ status: 'ok' });

const client = getClient<NodeClient>();
if (client && client.getOptions().autoSessionTracking) {
isolationScope.setRequestSession({ status: 'ok' });
}
setIsolationScope(isolationScope);
}
},
responseHook: (span, res) => {
if (_breadcrumbs) {
_addRequestBreadcrumb(span, res);
}

const client = getClient<NodeClient>();
if (client && client.getOptions().autoSessionTracking) {
setImmediate(() => {
client['_captureRequestSession']();
});
}
},
}),
];
Expand Down
5 changes: 0 additions & 5 deletions packages/node-experimental/src/sdk/handlers.ts

This file was deleted.

76 changes: 76 additions & 0 deletions packages/node-experimental/src/sdk/handlers/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type * as http from 'http';
import { captureException, getClient, getIsolationScope } from '@sentry/core';
import type { NodeClient } from '../client';

interface MiddlewareError extends Error {
status?: number | string;
statusCode?: number | string;
status_code?: number | string;
output?: {
statusCode?: number | string;
};
}

/**
* An Express-compatible error handler.
*/
export function errorHandler(options?: {
/**
* Callback method deciding whether error should be captured and sent to Sentry
* @param error Captured middleware error
*/
shouldHandleError?(this: void, error: MiddlewareError): boolean;
}): (
error: MiddlewareError,
req: http.IncomingMessage,
res: http.ServerResponse,
next: (error: MiddlewareError) => void,
) => void {
return function sentryErrorMiddleware(
error: MiddlewareError,
_req: http.IncomingMessage,
res: http.ServerResponse,
next: (error: MiddlewareError) => void,
): void {
const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError;

if (shouldHandleError(error)) {
const client = getClient<NodeClient>();
if (client && client.getOptions().autoSessionTracking) {
// Check if the `SessionFlusher` is instantiated on the client to go into this branch that marks the
// `requestSession.status` as `Crashed`, and this check is necessary because the `SessionFlusher` is only
// instantiated when the the`requestHandler` middleware is initialised, which indicates that we should be
// running in SessionAggregates mode
const isSessionAggregatesMode = client['_sessionFlusher'] !== undefined;
if (isSessionAggregatesMode) {
const requestSession = getIsolationScope().getRequestSession();
// If an error bubbles to the `errorHandler`, then this is an unhandled error, and should be reported as a
// Crashed session. The `_requestSession.status` is checked to ensure that this error is happening within
// the bounds of a request, and if so the status is updated
if (requestSession && requestSession.status !== undefined) {
requestSession.status = 'crashed';
}
}
}

const eventId = captureException(error, { mechanism: { type: 'middleware', handled: false } });
(res as { sentry?: string }).sentry = eventId;
next(error);

return;
}

next(error);
};
}

function getStatusCodeFromResponse(error: MiddlewareError): number {
const statusCode = error.status || error.statusCode || error.status_code || (error.output && error.output.statusCode);
return statusCode ? parseInt(statusCode as string, 10) : 500;
}

/** Returns true if response code is internal server error */
function defaultShouldHandleError(error: MiddlewareError): boolean {
const status = getStatusCodeFromResponse(error);
return status >= 500;
}
6 changes: 6 additions & 0 deletions packages/node-experimental/src/sdk/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
endSession,
getClient,
getCurrentScope,
getIntegrationsToSetup,
getIsolationScope,
Expand Down Expand Up @@ -184,6 +185,11 @@ function updateScopeFromEnvVariables(): void {
* Enable automatic Session Tracking for the node process.
*/
function startSessionTracking(): void {
const client = getClient<NodeClient>();
if (client && client.getOptions().autoSessionTracking) {
client.initSessionFlusher();
}

startSession();

// Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because
Expand Down
137 changes: 137 additions & 0 deletions packages/node-experimental/test/sdk/handlers/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as http from 'http';
import { getCurrentScope, getIsolationScope, setAsyncContextStrategy, setCurrentClient, withScope } from '@sentry/core';
import type { Scope } from '@sentry/types';
import { NodeClient } from '../../../src/sdk/client';
import { errorHandler } from '../../../src/sdk/handlers/errorHandler';
import { getDefaultNodeClientOptions } from '../../helpers/getDefaultNodePreviewClientOptions';

describe('errorHandler()', () => {
beforeEach(() => {
getCurrentScope().clear();
getIsolationScope().clear();

setAsyncContextStrategy(undefined);
});

const headers = { ears: 'furry', nose: 'wet', tongue: 'spotted', cookie: 'favorite=zukes' };
const method = 'wagging';
const protocol = 'mutualsniffing';
const hostname = 'the.dog.park';
const path = '/by/the/trees/';
const queryString = 'chase=me&please=thankyou';

const sentryErrorMiddleware = errorHandler();

let req: http.IncomingMessage, res: http.ServerResponse, next: () => undefined;
let client: NodeClient;

function createNoOpSpy() {
const noop = { noop: () => undefined }; // this is wrapped in an object so jest can spy on it
return jest.spyOn(noop, 'noop') as any;
}

beforeEach(() => {
req = {
headers,
method,
protocol,
hostname,
originalUrl: `${path}?${queryString}`,
} as unknown as http.IncomingMessage;
res = new http.ServerResponse(req);
next = createNoOpSpy();
});

afterEach(() => {
if (client['_sessionFlusher']) {
clearInterval(client['_sessionFlusher']['_intervalId']);
}
jest.restoreAllMocks();
});
it('when autoSessionTracking is disabled, does not set requestSession status on Crash', done => {
const options = getDefaultNodeClientOptions({ autoSessionTracking: false, release: '3.3' });
client = new NodeClient(options);
// It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised
// by the`requestHandler`)
client.initSessionFlusher();

setCurrentClient(client);

jest.spyOn<any, any>(client, '_captureRequestSession');

getIsolationScope().setRequestSession({ status: 'ok' });

let isolationScope: Scope;
sentryErrorMiddleware({ name: 'error', message: 'this is an error' }, req, res, () => {
isolationScope = getIsolationScope();
return next();
});

setImmediate(() => {
expect(isolationScope.getRequestSession()).toEqual({ status: 'ok' });
done();
});
});

it('autoSessionTracking is enabled + requestHandler is not used -> does not set requestSession status on Crash', done => {
const options = getDefaultNodeClientOptions({ autoSessionTracking: false, release: '3.3' });
client = new NodeClient(options);
setCurrentClient(client);

jest.spyOn<any, any>(client, '_captureRequestSession');

getIsolationScope().setRequestSession({ status: 'ok' });

let isolationScope: Scope;
sentryErrorMiddleware({ name: 'error', message: 'this is an error' }, req, res, () => {
isolationScope = getIsolationScope();
return next();
});

setImmediate(() => {
expect(isolationScope.getRequestSession()).toEqual({ status: 'ok' });
done();
});
});

it('when autoSessionTracking is enabled, should set requestSession status to Crashed when an unhandled error occurs within the bounds of a request', () => {
const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.1' });
client = new NodeClient(options);
// It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised
// by the`requestHandler`)
client.initSessionFlusher();

setCurrentClient(client);

jest.spyOn<any, any>(client, '_captureRequestSession');

withScope(() => {
getIsolationScope().setRequestSession({ status: 'ok' });
sentryErrorMiddleware({ name: 'error', message: 'this is an error' }, req, res, () => {
expect(getIsolationScope().getRequestSession()).toEqual({ status: 'crashed' });
});
});
});

it('when autoSessionTracking is enabled, should not set requestSession status on Crash when it occurs outside the bounds of a request', done => {
const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '2.2' });
client = new NodeClient(options);
// It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised
// by the`requestHandler`)
client.initSessionFlusher();
setCurrentClient(client);

jest.spyOn<any, any>(client, '_captureRequestSession');

let isolationScope: Scope;
sentryErrorMiddleware({ name: 'error', message: 'this is an error' }, req, res, () => {
isolationScope = getIsolationScope();
return next();
});

setImmediate(() => {
expect(isolationScope.getRequestSession()).toEqual(undefined);
done();
});
});
});