Skip to content

Commit

Permalink
refactor(back): rework sentry integration
Browse files Browse the repository at this point in the history
  • Loading branch information
tsa96 committed Jul 7, 2024
1 parent 697a170 commit f414646
Show file tree
Hide file tree
Showing 11 changed files with 123 additions and 158 deletions.
37 changes: 6 additions & 31 deletions apps/backend/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino';
import * as Sentry from '@sentry/node';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FastifyMulterModule } from '@nest-lab/fastify-multer';
import { ExceptionHandlerFilter } from './filters/exception-handler.filter';
import { ConfigFactory, Environment, validate } from './config';
import { SentryModule } from './modules/sentry/sentry.module';
import { AuthModule } from './modules/auth/auth.module';
import { ActivitiesModule } from './modules/activities/activities.module';
import { AdminModule } from './modules/admin/admin.module';
Expand All @@ -22,6 +20,7 @@ import { MapReviewModule } from './modules/map-review/map-review.module';
import { DbModule } from './modules/database/db.module';
import { KillswitchModule } from './modules/killswitch/killswitch.module';
import { HealthcheckModule } from './modules/healthcheck/healthcheck.module';
import { setupNestInterceptor } from '../instrumentation';

@Module({
imports: [
Expand All @@ -31,34 +30,6 @@ import { HealthcheckModule } from './modules/healthcheck/healthcheck.module';
isGlobal: true,
validate
}),
// We use Sentry in production for error logging and performance tracing.
// This is a small wrapper module around @sentry/node that only inits in
// production if a valid DSN is set.
SentryModule.forRootAsync({
useFactory: async (config: ConfigService) => {
// Whether to enable SentryInterceptor. If enabled, we run a transaction
// for the lifetime of tracesSampleRate * all HTTP requests. This
// provides more detailed error
const enableTracing = config.getOrThrow('sentry.enableTracing');
return {
environment: config.getOrThrow('env'),
enableTracing,
sentryOpts: {
// If this isn't set in prod we won't init Sentry.
dsn: config.getOrThrow('sentry.dsn'),
enableTracing,
environment: config.getOrThrow('sentry.env'),
tracesSampleRate: config.getOrThrow('sentry.tracesSampleRate'),
integrations: config.getOrThrow('sentry.tracePrisma')
? [Sentry.prismaIntegration()]
: undefined,
debug: false
}
};
},
imports: [DbModule.forRoot()],
inject: [ConfigService]
}),
// Pino is a highly performant logger that outputs logs as JSON, which we
// then export to Grafana Loki. This module sets up `pino-http` which logs
// all HTTP requests (so no need for a Nest interceptor).
Expand Down Expand Up @@ -100,6 +71,10 @@ import { HealthcheckModule } from './modules/healthcheck/healthcheck.module';
{
provide: APP_FILTER,
useClass: ExceptionHandlerFilter
},
{
provide: APP_INTERCEPTOR,
useFactory: setupNestInterceptor
}
]
})
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/app/config/config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface ConfigInterface {
sentry: {
dsn: string;
enableTracing: boolean;
enableNodeProfiling: boolean;
tracesSampleRate: number;
tracePrisma: boolean;
env: 'production' | 'staging';
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export const ConfigFactory = (): ConfigInterface => {
enableTracing: process.env['SENTRY_ENABLE_TRACING'] === 'true' || false,
tracesSampleRate: +process.env['SENTRY_TRACE_SAMPLE_RATE'] || 0,
tracePrisma: process.env['SENTRY_TRACE_PRISMA'] === 'true' || false,
env: (process.env['SENTRY_ENV'] || '') as 'production' | 'staging'
env:
((process.env['SENTRY_ENV'] || '') as 'production' | 'staging') ||
'staging',
enableNodeProfiling:
process.env['SENTRY_ENABLE_NODE_PROFILING'] === 'true' || false
},
sessionSecret: process.env['SESSION_SECRET'] || '',
steam: {
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/app/config/config.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export class ConfigValidation {
@IsBoolean()
readonly SENTRY_TRACE_PRISMA?: boolean;

@IsOptional()
@IsBoolean()
readonly SENTRY_ENABLE_NODE_PROFILING?: boolean;

@IsOptional()
@IsIn(['production', 'staging'])
readonly SENTRY_ENV?: 'production' | 'staging';
Expand Down
15 changes: 6 additions & 9 deletions apps/backend/src/app/filters/exception-handler.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,21 @@ import {
ExceptionFilter,
HttpException,
HttpStatus,
Inject,
Logger
Logger,
ServiceUnavailableException
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { ConfigService } from '@nestjs/config';
import '@sentry/tracing'; // Required according to https://github.com/getsentry/sentry-javascript/issues/4731#issuecomment-1075410543
import * as Sentry from '@sentry/node';
import { SENTRY_INIT_STATE } from '../modules/sentry/sentry.const';
import { SentryInitState } from '../modules/sentry/sentry.interface';
import { Environment } from '../config';

@Catch()
export class ExceptionHandlerFilter implements ExceptionFilter {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly configService: ConfigService,
@Inject(SENTRY_INIT_STATE) private readonly sentryEnabled: SentryInitState
private readonly configService: ConfigService
) {}

private readonly logger = new Logger('Exception Filter');
Expand Down Expand Up @@ -69,9 +66,9 @@ export class ExceptionHandlerFilter implements ExceptionFilter {
message: exception.message
};

// In production, send to Sentry so long as it's enabled (if the DSN is
// invalid/empty it'll be disabled).
if (this.sentryEnabled) {
// In production, send to Sentry so long as it's enabled
// TODO: maybe still want to inject sentry init state token
if (Sentry.isInitialized()) {
eventID = Sentry.captureException(exception);
msg.eventID = eventID;
}
Expand Down
48 changes: 22 additions & 26 deletions apps/backend/src/app/interceptors/sentry.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
import * as Sentry from '@sentry/node';
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Observable } from 'rxjs';
import { getIsolationScope } from '@sentry/node';
import { getDefaultIsolationScope } from '@sentry/core';

@Injectable()
export class SentryInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
if (getIsolationScope() === getDefaultIsolationScope()) {
Logger.warn(
'Isolation scope is still the default isolation scope, skipping setting transactionName.'
);
return next.handle();
}

// Based on https://github.com/ericjeker/nestjs-sentry-example/blob/main/src/sentry/sentry.interceptor.ts,
// but updated for Sentry 7, which majorly changed how transactions/spans are handled.
return Sentry.startSpan(
{
op: 'http.server',
name: `${method} ${url}`
},
(rootSpan: Sentry.Span) =>
Sentry.startSpan(
{
op: 'http.handler',
name: `${context.getClass().name}.${context.getHandler().name}`
},
(span: Sentry.Span) =>
next.handle().pipe(
tap(() => {
span.end();
rootSpan.end();
})
)
)
);
if (context.getType() !== 'http') {
return next.handle();
}

const req = context.switchToHttp().getRequest();
if (req.route) {
getIsolationScope().setTransactionName(
`${req.method?.toUpperCase() ?? 'UNKNOWN'} ${req.route.path}`
);
}

return next.handle();
}
}
2 changes: 0 additions & 2 deletions apps/backend/src/app/modules/sentry/sentry.const.ts

This file was deleted.

17 changes: 0 additions & 17 deletions apps/backend/src/app/modules/sentry/sentry.interface.ts

This file was deleted.

72 changes: 0 additions & 72 deletions apps/backend/src/app/modules/sentry/sentry.module.ts

This file was deleted.

78 changes: 78 additions & 0 deletions apps/backend/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint @typescript-eslint/naming-convention: 0 */
import * as Sentry from '@sentry/node';
import {
NodeOptions,
nestIntegration,
prismaIntegration,
spanToJSON,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN
} from '@sentry/node';
import { Integration } from '@sentry/types';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { pino } from 'pino';
import { SentryInterceptor } from './app/interceptors/sentry.interceptor';
import { Environment } from './app/config';

// Sentry now has a dedicated Nest in integration but doesn't let us integrate
// nicely with our exception filter, also it's in an alpha state.
// Mostly following their approach from
// https://github.com/getsentry/sentry-javascript/blob/develop/packages/node/src/integrations/tracing/nest.ts

const dsn = process.env['SENTRY_DSN'];
const enableTracing = process.env['SENTRY_ENABLE_TRACING'] === 'true';
const sampleRate = +process.env['SENTRY_TRACE_SAMPLE_RATE'];

const integrations: Integration[] = [nestIntegration()];

const logger = pino();

if (process.env['SENTRY_TRACE_PRISMA'] === 'true') {
integrations.push(prismaIntegration());
}

//https://docs.sentry.io/platforms/javascript/guides/node/profiling/#runtime-flags
if (process.env['SENTRY_ENABLE_NODE_PROFILING'] === 'true') {
integrations.push(nodeProfilingIntegration());
}

const opts: NodeOptions = {
dsn,
environment: process.env['SENTRY_ENV'],
enableTracing,
tracesSampleRate: sampleRate,
profilesSampleRate: sampleRate,
debug: false,
integrations
};

if (process.env['NODE_ENV'] === Environment.PRODUCTION && dsn) {
logger.info('Initializing Sentry');
Sentry.init(opts);
}

export function setupNestInterceptor() {
if (!Sentry.isInitialized() || !process.env['SENTRY_DSN']) return;

const client = Sentry.getClient();
if (!client) return;

client.on('spanStart', (span) => {
const attributes = spanToJSON(span).data || {};

// this is one of: app_creation, request_context, handler
const type = attributes['nestjs.type'];

// If this is already set, or we have no nest.js span, no need to process again...
if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] || !type) {
return;
}

span.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${type}.nestjs`
});
});

return new SentryInterceptor();
}
1 change: 1 addition & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './instrumentation'; // This must be first import!!
import { NestFactory, Reflector } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import {
Expand Down

0 comments on commit f414646

Please sign in to comment.