feat: add user to sentry (#3467)

* feat: wip add user to sentry

* feat: wip interceptor

* feat: wip add user to sentry

* feat: add user into sentry errors

* fix: hide stack trace in production

* fix: properly log commands and handle exceptions

* fix: filter command exceptions

* feat: handle jobs errors
This commit is contained in:
Jérémy M
2024-02-01 16:14:08 +01:00
committed by GitHub
parent 7adb5cc00d
commit cdc51add7d
20 changed files with 581 additions and 264 deletions

View File

@ -1,16 +1,26 @@
import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface';
import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface';
import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces';
export class ExceptionHandlerConsoleDriver
implements ExceptionHandlerDriverInterface
{
captureException(exception: unknown) {
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
) {
console.group('Exception Captured');
console.error(exception);
console.info(options);
console.error(exceptions);
console.groupEnd();
return [];
}
captureMessage(message: string): void {
captureMessage(message: string, user?: ExceptionHandlerUser): void {
console.group('Message Captured');
console.info(user);
console.info(message);
console.groupEnd();
}

View File

@ -1,6 +1,9 @@
import * as Sentry from '@sentry/node';
import { ProfilingIntegration } from '@sentry/profiling-node';
import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface';
import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface';
import {
ExceptionHandlerDriverInterface,
ExceptionHandlerSentryDriverFactoryOptions,
@ -30,11 +33,69 @@ export class ExceptionHandlerSentryDriver
});
}
captureException(exception: Error) {
Sentry.captureException(exception);
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
) {
const eventIds: string[] = [];
Sentry.withScope((scope) => {
if (options?.operation) {
scope.setTag('operation', options.operation.name);
scope.setTag('operationName', options.operation.name);
}
if (options?.document) {
scope.setExtra('document', options.document);
}
for (const exception of exceptions) {
const errorPath = (exception.path ?? [])
.map((v: string | number) => (typeof v === 'number' ? '$index' : v))
.join(' > ');
if (errorPath) {
scope.addBreadcrumb({
category: 'execution-path',
message: errorPath,
level: 'debug',
});
}
const eventId = Sentry.captureException(exception, {
fingerprint: [
'graphql',
errorPath,
options?.operation?.name,
options?.operation?.type,
],
contexts: {
GraphQL: {
operationName: options?.operation?.name,
operationType: options?.operation?.type,
},
},
});
eventIds.push(eventId);
}
});
return eventIds;
}
captureMessage(message: string) {
Sentry.captureMessage(message);
captureMessage(message: string, user?: ExceptionHandlerUser) {
Sentry.captureMessage(message, (scope) => {
if (user) {
scope.setUser({
id: user.id,
ip_address: user.ipAddress,
email: user.email,
username: user.username,
});
}
return scope;
});
}
}

View File

@ -1,8 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces';
import { ExceptionHandlerOptions } from 'src/integrations/exception-handler/interfaces/exception-handler-options.interface';
import { EXCEPTION_HANDLER_DRIVER } from './exception-handler.constants';
import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces';
import { EXCEPTION_HANDLER_DRIVER } from 'src/integrations/exception-handler/exception-handler.constants';
@Injectable()
export class ExceptionHandlerService {
@ -11,7 +12,10 @@ export class ExceptionHandlerService {
private driver: ExceptionHandlerDriverInterface,
) {}
captureException(exception: unknown) {
this.driver.captureException(exception);
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
): string[] {
return this.driver.captureExceptions(exceptions, options);
}
}

View File

@ -0,0 +1,150 @@
import { GraphQLError, Kind, OperationDefinitionNode, print } from 'graphql';
import * as express from 'express';
import {
getDocumentString,
handleStreamOrSingleExecutionResult,
OnExecuteDoneHookResultOnNextHook,
Plugin,
} from '@envelop/core';
import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { TokenService } from 'src/core/auth/services/token.service';
import {
convertExceptionToGraphQLError,
filterException,
} from 'src/filters/utils/global-exception-handler.util';
export type ExceptionHandlerPluginOptions = {
/**
* The driver to use to handle exceptions.
*/
exceptionHandlerService: ExceptionHandlerService;
/**
* The token service to use to get the token from the request.
*/
tokenService: TokenService;
/**
* The key of the event id in the error's extension. `null` to disable.
* @default exceptionEventId
*/
eventIdKey?: string | null;
};
export const useExceptionHandler = <
PluginContext extends Record<string, any> = object,
>(
options: ExceptionHandlerPluginOptions,
): Plugin<PluginContext> => {
const exceptionHandlerService = options.exceptionHandlerService;
const tokenService = options.tokenService;
const eventIdKey = options.eventIdKey === null ? null : 'exceptionEventId';
function addEventId(
err: GraphQLError,
eventId: string | undefined | null,
): GraphQLError {
if (eventIdKey !== null && eventId) {
err.extensions[eventIdKey] = eventId;
}
return err;
}
return {
async onExecute({ args }) {
const rootOperation = args.document.definitions.find(
(o) => o.kind === Kind.OPERATION_DEFINITION,
) as OperationDefinitionNode;
const operationType = rootOperation.operation;
const document = getDocumentString(args.document, print);
const request = args.contextValue.req as express.Request;
const opName =
args.operationName ||
rootOperation.name?.value ||
'Anonymous Operation';
let user: ExceptionHandlerUser | undefined;
if (tokenService.isTokenPresent(request)) {
try {
const data = await tokenService.validateToken(request);
user = {
id: data.user?.id,
email: data.user?.email,
};
} catch {}
}
return {
onExecuteDone(payload) {
const handleResult: OnExecuteDoneHookResultOnNextHook<object> = ({
result,
setResult,
}) => {
if (result.errors && result.errors.length > 0) {
const exceptions = result.errors.reduce<{
filtered: any[];
unfiltered: any[];
}>(
(acc, err) => {
// Filter out exceptions that we don't want to be captured by exception handler
if (filterException(err)) {
acc.filtered.push(err);
} else {
acc.unfiltered.push(err);
}
return acc;
},
{
filtered: [],
unfiltered: [],
},
);
if (exceptions.unfiltered.length > 0) {
const eventIds = exceptionHandlerService.captureExceptions(
exceptions.unfiltered,
{
operation: {
name: opName,
type: operationType,
},
document,
user,
},
);
exceptions.unfiltered.map((err, i) =>
addEventId(err, eventIds?.[i]),
);
}
const concatenatedErrors = [
...exceptions.filtered,
...exceptions.unfiltered,
];
const errors = concatenatedErrors.map((err) => {
// Properly convert errors to GraphQLErrors
const graphQLError = convertExceptionToGraphQLError(
err.originalError,
);
return graphQLError;
});
setResult({
...result,
errors,
});
}
};
return handleStreamOrSingleExecutionResult(payload, handleResult);
},
};
},
};
};

View File

@ -1,4 +1,10 @@
import { ExceptionHandlerOptions } from './exception-handler-options.interface';
import { ExceptionHandlerUser } from './exception-handler-user.interface';
export interface ExceptionHandlerDriverInterface {
captureException(exception: unknown): void;
captureMessage(message: string): void;
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
): string[];
captureMessage(message: string, user?: ExceptionHandlerUser): void;
}

View File

@ -0,0 +1,12 @@
import { OperationTypeNode } from 'graphql';
import { ExceptionHandlerUser } from './exception-handler-user.interface';
export interface ExceptionHandlerOptions {
operation?: {
type: OperationTypeNode;
name: string;
};
document?: string;
user?: ExceptionHandlerUser;
}

View File

@ -0,0 +1,6 @@
export interface ExceptionHandlerUser {
id?: string;
ipAddress?: string;
email?: string;
username?: string;
}