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:
Sase Dev
2026-05-10 09:10:18 +00:00
parent 48166b4cf3
commit 963e787344
10 changed files with 864 additions and 18 deletions

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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>();

View 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 };

View 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",
});
}

View File

@@ -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";

View File

@@ -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);
}
}