feat(observability): add Sentry error tracking to API and worker
Wires @sentry/nestjs into the API (instrument.ts + SentryModule + decorator on HttpExceptionFilter) and the BullMQ worker (instrument-worker.ts + captureException on each queue's failed handler, flush on shutdown). Sentry's OpenTelemetry pipeline is conditionally skipped when our custom OTel is enabled (OTEL_ENABLED=true) so the two don't double-instrument; in that mode tracing/profiling sample rates fall to 0 but error capture still works. Adds optional SENTRY_DSN to the env schema and approves the native CPU profiler build script in pnpm.onlyBuiltDependencies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,8 @@
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||
"@sase/config": "workspace:*",
|
||||
"@sase/shared": "workspace:*",
|
||||
"@sentry/nestjs": "^10.52.0",
|
||||
"@sentry/profiling-node": "^10.52.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"bullmq": "^5.30.0",
|
||||
"bullmq-otel": "^1.2.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { SentryModule } from "@sentry/nestjs/setup";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { AnalyticsModule } from "./analytics/analytics.module";
|
||||
@@ -35,6 +36,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import { SentryExceptionCaptured } from "@sentry/nestjs";
|
||||
import { Response } from "express";
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
|
||||
@SentryExceptionCaptured()
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
29
apps/api/src/instrument-worker.ts
Normal file
29
apps/api/src/instrument-worker.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// MUST be imported before anything else in worker.ts so Sentry can patch modules
|
||||
// before they're loaded.
|
||||
|
||||
import "dotenv/config";
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
const dsn =
|
||||
process.env.SENTRY_DSN ||
|
||||
"https://931a9c8d2bcc49918e3ba4d11510f910@o4511360959250432.ingest.de.sentry.io/4511360960823376";
|
||||
|
||||
const customOtelEnabled = process.env.OTEL_ENABLED === "true";
|
||||
|
||||
if (dsn && process.env.NODE_ENV !== "test") {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
serverName: "sase-worker",
|
||||
sendDefaultPii: true,
|
||||
enableLogs: true,
|
||||
skipOpenTelemetrySetup: customOtelEnabled,
|
||||
integrations: customOtelEnabled ? [] : [nodeProfilingIntegration()],
|
||||
tracesSampleRate: customOtelEnabled ? 0 : 1.0,
|
||||
profileSessionSampleRate: customOtelEnabled ? 0 : 1.0,
|
||||
profileLifecycle: "trace",
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
30
apps/api/src/instrument.ts
Normal file
30
apps/api/src/instrument.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// MUST be imported before anything else in main.ts so Sentry can patch modules
|
||||
// before they're loaded. See https://docs.sentry.io/platforms/javascript/guides/nestjs/
|
||||
|
||||
import "dotenv/config";
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
import { nodeProfilingIntegration } from "@sentry/profiling-node";
|
||||
|
||||
const dsn =
|
||||
process.env.SENTRY_DSN ||
|
||||
"https://931a9c8d2bcc49918e3ba4d11510f910@o4511360959250432.ingest.de.sentry.io/4511360960823376";
|
||||
|
||||
// We ship a custom OpenTelemetry SDK in src/telemetry/tracing.ts. When it's enabled,
|
||||
// Sentry must NOT register its own OTel pipeline or we'd double-instrument
|
||||
// HTTP/Express/Nest. The trade-off: Sentry tracing + profiling rely on its OTel,
|
||||
// so they're effectively disabled when the custom pipeline is on.
|
||||
const customOtelEnabled = process.env.OTEL_ENABLED === "true";
|
||||
|
||||
if (dsn && process.env.NODE_ENV !== "test") {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
sendDefaultPii: true,
|
||||
enableLogs: true,
|
||||
skipOpenTelemetrySetup: customOtelEnabled,
|
||||
integrations: customOtelEnabled ? [] : [nodeProfilingIntegration()],
|
||||
tracesSampleRate: customOtelEnabled ? 0 : 1.0,
|
||||
profileSessionSampleRate: customOtelEnabled ? 0 : 1.0,
|
||||
profileLifecycle: "trace",
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./telemetry/tracing"; // MUST be first — instruments modules before they load
|
||||
import "./instrument"; // MUST be first — initializes Sentry before any other imports
|
||||
import "./telemetry/tracing"; // MUST be early — instruments modules before they load
|
||||
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./telemetry/worker-tracing"; // MUST be first — instruments modules before they load
|
||||
import { Sentry } from "./instrument-worker"; // MUST be first — initializes Sentry
|
||||
import "./telemetry/worker-tracing"; // MUST be early — instruments modules before they load
|
||||
|
||||
import "dotenv/config";
|
||||
import { Worker } from "bullmq";
|
||||
@@ -72,6 +73,9 @@ emexScrapeWorker.on("failed", (job, err) => {
|
||||
console.error(
|
||||
`[worker] emex-scrape job ${job?.id} failed (attempt ${job?.attemptsMade}): ${err.message}`,
|
||||
);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.EMEX_SCRAPE, jobId: job?.id, attempt: job?.attemptsMade },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(emexScrapeWorker);
|
||||
@@ -95,6 +99,9 @@ subscriptionExpiryWorker.on("completed", (job) => {
|
||||
|
||||
subscriptionExpiryWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] subscription-expiry job ${job?.id} failed: ${err.message}`);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.SUBSCRIPTION_EXPIRY, jobId: job?.id },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(subscriptionExpiryWorker);
|
||||
@@ -118,6 +125,9 @@ queryCleanupWorker.on("completed", (job) => {
|
||||
|
||||
queryCleanupWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] query-cleanup job ${job?.id} failed: ${err.message}`);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.QUERY_CLEANUP, jobId: job?.id },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(queryCleanupWorker);
|
||||
@@ -159,6 +169,9 @@ if (openrouterApiKey) {
|
||||
});
|
||||
translationWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] translation job ${job?.id} failed: ${err.message}`);
|
||||
Sentry.captureException(err, {
|
||||
tags: { queue: QUEUE_NAMES.TRANSLATION, jobId: job?.id },
|
||||
});
|
||||
});
|
||||
|
||||
workers.push(translationWorker);
|
||||
@@ -194,9 +207,14 @@ async function shutdown(signal: string) {
|
||||
await sql.end();
|
||||
console.log("[worker] Database connection closed");
|
||||
|
||||
// 4. Flush pending Sentry events
|
||||
await Sentry.close(2000);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("[worker] Error during shutdown:", error);
|
||||
Sentry.captureException(error);
|
||||
await Sentry.close(2000);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user