Sentry: Error Tracking and Monitoring
For .NET engineers who know: Application Insights — SDK telemetry, structured logging, dependency tracking, Live Metrics, and the Azure portal investigation workflow You’ll learn: How Sentry covers the error-tracking half of Application Insights with a sharper focus on developer experience — source maps, release tracking, and issue grouping — and how to configure it across our NestJS, Next.js, and Vue layers Time: 15-20 min read
The .NET Way (What You Already Know)
Application Insights is Microsoft’s unified observability platform. You call builder.Services.AddApplicationInsightsTelemetry(), set APPLICATIONINSIGHTS_CONNECTION_STRING, and it automatically captures exceptions, request telemetry, dependency calls (SQL, HTTP, queues), and custom events. The SDK hooks into the CLR at a deep level — it patches HttpClient, SqlClient, and the ASP.NET Core middleware pipeline without you touching exception handling code.
When something breaks in production, your workflow is: Azure portal, Application Insights, Failures blade, find the exception, read the stack trace, correlate with the operation ID to see what happened before and after. Alerts are configured in the portal.
// Program.cs
builder.Services.AddApplicationInsightsTelemetry();
// Custom telemetry anywhere in the app
public class OrderService
{
private readonly TelemetryClient _telemetry;
public OrderService(TelemetryClient telemetry)
{
_telemetry = telemetry;
}
public async Task<Order> PlaceOrderAsync(CreateOrderDto dto)
{
using var operation = _telemetry.StartOperation<RequestTelemetry>("PlaceOrder");
try
{
var order = await _repository.CreateAsync(dto);
_telemetry.TrackEvent("OrderPlaced", new Dictionary<string, string>
{
["orderId"] = order.Id.ToString(),
["customerId"] = dto.CustomerId.ToString(),
});
return order;
}
catch (Exception ex)
{
_telemetry.TrackException(ex);
throw;
}
}
}
Application Insights is metrics-first. It wants to show you dashboards, availability tests, and performance trends. If you just want to know “what broke, where, and why,” you end up navigating three blades to get to a readable stack trace.
The Sentry Way
Sentry is error-first. Its primary mental model is: an error happened, here is the complete context to understand and fix it — stack trace, user, browser/OS, request data, and a breadcrumb trail of what happened in the seconds before the error. Metrics and performance monitoring exist, but the product is built around the exception.
The other meaningful difference: Sentry is language-agnostic and installed as an npm package, not as a platform SDK. You configure it once per layer (frontend, backend) and the same Sentry project dashboard shows errors from all layers.
Installing the SDK
Our stack uses three layers, each with its own SDK package:
# NestJS backend
pnpm add @sentry/node @sentry/profiling-node
# Next.js frontend (use the Next.js-specific package)
pnpm add @sentry/nextjs
# Vue 3 frontend
pnpm add @sentry/vue
NestJS Integration
Sentry must be initialized before any other import in your application. This is the one setup rule that causes the most issues (covered in Gotchas).
// src/instrument.ts — MUST be imported before everything else
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV, // 'development' | 'staging' | 'production'
release: process.env.SENTRY_RELEASE, // e.g. 'api@1.4.2' — set by CI
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
profilesSampleRate: 1.0, // Profile 100% of sampled transactions
integrations: [
nodeProfilingIntegration(),
],
// Filter out noise before it reaches Sentry
beforeSend(event, hint) {
const error = hint.originalException;
// Don't send 404s and validation errors — those are expected
if (error instanceof Error && error.name === 'NotFoundException') {
return null;
}
return event;
},
});
// src/main.ts — import instrument.ts FIRST, before NestFactory
import './instrument'; // ← This line must come before any other import
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as Sentry from '@sentry/node';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
// Sentry's request handler middleware — captures request context with each error
app.use(Sentry.Handlers.requestHandler());
// Must come BEFORE error handling middleware
app.use(Sentry.Handlers.tracingHandler());
await app.listen(3000);
}
bootstrap();
For NestJS, the cleanest way to capture unhandled exceptions and attach them to Sentry is a global exception filter:
// src/common/filters/sentry-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { Request, Response } from 'express';
@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Only send 500-level errors to Sentry — 4xx are expected application behavior
if (status >= 500) {
Sentry.captureException(exception, {
extra: {
url: request.url,
method: request.method,
body: request.body,
},
});
}
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// src/main.ts — register the filter globally
import { SentryExceptionFilter } from './common/filters/sentry-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new SentryExceptionFilter());
// ...
}
Next.js Integration
The @sentry/nextjs package ships a wizard that does most of the configuration for you, but it’s worth understanding what it produces:
# Run the wizard — it modifies next.config.js and creates sentry.*.config.ts files
npx @sentry/wizard@latest -i nextjs
The wizard creates:
sentry.client.config.ts— client-side initialization (browser)sentry.server.config.ts— server-side initialization (Node.js runtime)sentry.edge.config.ts— Edge runtime initialization (middleware, edge routes)
// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.NEXT_PUBLIC_SENTRY_RELEASE,
// Performance: capture 10% of transactions in production
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
// Session replay: capture 10% of sessions, 100% of sessions with errors
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration(),
],
// Don't send errors caused by browser extensions or network issues
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'ChunkLoadError', // Next.js chunk loading on route change
/^Network request failed/,
],
});
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.SENTRY_RELEASE,
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
});
Vue 3 Integration
// src/main.ts
import { createApp } from 'vue';
import * as Sentry from '@sentry/vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE,
integrations: [
Sentry.browserTracingIntegration({ router }), // Tracks route changes as transactions
Sentry.replayIntegration(),
],
tracesSampleRate: import.meta.env.PROD ? 0.1 : 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
tracePropagationTargets: [
'localhost',
/^https:\/\/api\.yourapp\.com/, // Attach trace headers to API calls
],
});
app.use(router);
app.mount('#app');
Source Maps: Making Stack Traces Readable
Without source maps, your production stack traces look like this:
TypeError: Cannot read property 'id' of undefined
at e (main.a3f8c9.js:1:2847)
at t (main.a3f8c9.js:1:14203)
With source maps uploaded to Sentry, the same error looks like:
TypeError: Cannot read property 'id' of undefined
at OrderService.findOne (src/orders/orders.service.ts:34:18)
at OrdersController.getOrder (src/orders/orders.controller.ts:22:30)
Configure source map upload as part of your build:
# Install the Sentry CLI
pnpm add -D @sentry/cli
// vite.config.ts — for Vue/Vite projects
import { defineConfig } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
export default defineConfig({
build: {
sourcemap: true, // Generate source maps
},
plugins: [
sentryVitePlugin({
org: 'your-org-slug',
project: 'your-project-slug',
authToken: process.env.SENTRY_AUTH_TOKEN,
release: {
name: process.env.SENTRY_RELEASE,
},
// Delete source maps from the server after upload
// (they're only needed by Sentry, not end users)
sourcemaps: {
filesToDeleteAfterUpload: ['dist/**/*.map'],
},
}),
],
});
// next.config.js — @sentry/nextjs handles this automatically via the wizard
// but the relevant section looks like:
const { withSentryConfig } = require('@sentry/nextjs');
module.exports = withSentryConfig(nextConfig, {
org: 'your-org-slug',
project: 'your-project-slug',
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: true, // Suppress verbose output during build
widenClientFileUpload: true,
});
User Context and Breadcrumbs
Setting user context is the equivalent of Application Insights’s TelemetryContext.User. In Sentry, you set it after authentication:
// NestJS — set user context in an auth guard or middleware
import * as Sentry from '@sentry/node';
// In your auth middleware after validating the JWT:
Sentry.setUser({
id: user.id,
email: user.email,
username: user.username,
});
// Clear on logout
Sentry.setUser(null);
// Vue — set after Clerk/auth resolves the user
import * as Sentry from '@sentry/vue';
import { useUser } from '@clerk/vue';
// In a composable or app-level setup:
const { user } = useUser();
watch(user, (currentUser) => {
if (currentUser) {
Sentry.setUser({
id: currentUser.id,
email: currentUser.emailAddresses[0]?.emailAddress,
});
} else {
Sentry.setUser(null);
}
});
Breadcrumbs are the trail of events leading up to an error. Sentry collects them automatically (console logs, network requests, UI interactions in the browser), but you can add custom ones:
import * as Sentry from '@sentry/node';
// Custom breadcrumb — shows up in the event timeline
Sentry.addBreadcrumb({
category: 'order',
message: `Payment intent created for order ${orderId}`,
level: 'info',
data: {
orderId,
amount: amountCents,
},
});
Manual Error Capture
For expected errors you want to track without letting them bubble up as unhandled exceptions:
import * as Sentry from '@sentry/node';
try {
await externalPaymentService.charge(dto);
} catch (error) {
// Capture with additional context
Sentry.captureException(error, {
tags: {
feature: 'payments',
gateway: 'stripe',
},
extra: {
orderId: dto.orderId,
amount: dto.amountCents,
},
level: 'error',
});
// Then handle gracefully — don't re-throw if you want to swallow it
throw new InternalServerErrorException('Payment processing failed');
}
// Track non-exception events
Sentry.captureMessage('Payment gateway timeout — falling back to retry queue', 'warning');
Release Tracking and Environments
Release tracking is what turns “an error occurred” into “this error was introduced in release v1.4.2 and affects 12 users.” Set the release in your CI pipeline:
# .github/workflows/deploy.yml (GitHub Actions)
- name: Build and deploy
env:
SENTRY_RELEASE: ${{ github.repository }}@${{ github.sha }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
pnpm build
# Source maps are uploaded automatically by the Sentry build plugin
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: your-org-slug
SENTRY_PROJECT: your-project-slug
with:
environment: production
version: ${{ github.repository }}@${{ github.sha }}
Performance Monitoring
Sentry’s performance monitoring works similarly to Application Insights distributed tracing. Each incoming request creates a transaction, and spans within it capture the time taken by database calls, HTTP requests, etc.
// Manual span for custom operations
import * as Sentry from '@sentry/node';
async function processLargeExport(jobId: string) {
return Sentry.startSpan(
{
name: 'processLargeExport',
op: 'job',
attributes: { jobId },
},
async (span) => {
const data = await fetchData(); // Automatically traced if using Prisma/fetch
span.setAttribute('rowCount', data.length);
const result = await transformData(data);
return result;
},
);
}
Alerts and Issue Triage
Configure alerts in the Sentry UI under Project Settings → Alerts. Common patterns:
- Alert when a new issue is first seen (requires a new release to ship it)
- Alert when error rate exceeds N per minute
- Alert when a resolved issue regresses (re-appears after being marked fixed)
Slack integration is configured per-organization under Settings → Integrations → Slack. You can route different projects or alert rules to different Slack channels.
Issue grouping uses fingerprinting — Sentry groups errors by stack trace signature. If grouping is wrong (two distinct bugs merged, or one bug split across multiple issues), you can customize fingerprints:
Sentry.captureException(error, {
fingerprint: ['payment-gateway-timeout', dto.gateway],
// Groups all payment timeout errors by gateway, instead of by stack trace
});
Key Differences
| Concern | Application Insights | Sentry | Notes |
|---|---|---|---|
| Primary focus | Metrics + traces + errors (unified) | Errors first, performance secondary | AI is better for dashboards; Sentry is better for debugging |
| Setup | SDK + connection string, mostly automatic | Per-layer SDK init, explicit | More setup, more control |
| Stack traces | Readable with symbol server or PDBs | Readable with uploaded source maps | Source maps must be uploaded at build time |
| Error grouping | Basic — same exception type | Smart fingerprinting by stack trace signature | Sentry’s grouping is significantly better |
| User context | TelemetryContext.User | Sentry.setUser() | Identical concept |
| Breadcrumbs | Custom events via TrackEvent | Automatic + addBreadcrumb() | Sentry auto-captures console, network, clicks |
| Performance | Distributed tracing, dependency maps | Transactions + spans | AI is richer for infra; Sentry is simpler to use |
| Alerting | Azure Monitor alert rules | Per-project rules in Sentry UI | Both support Slack/email |
| Cost model | Per GB of data ingested | Per event, with generous free tier | Sentry’s free tier is usable for side projects |
| Self-hostable | No (Azure only) | Yes (open source) | Relevant if you have data residency requirements |
Gotchas for .NET Engineers
Gotcha 1: Import Order Breaks Instrumentation
This is the most common setup mistake. Sentry’s Node.js SDK patches module internals at init() time using Node’s module system. If you import express, pg, axios, or any other module before calling Sentry.init(), Sentry cannot instrument them. Your stack traces will be incomplete and database spans will be missing.
// WRONG — NestFactory import loads express and http before Sentry can patch them
import { NestFactory } from '@nestjs/core';
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN }); // Too late — express already loaded
// CORRECT — instrument.ts is imported first, before any framework imports
// src/instrument.ts
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN });
// src/main.ts
import './instrument'; // ← First line in the file
import { NestFactory } from '@nestjs/core';
There is no equivalent gotcha in .NET because Application Insights hooks into the CLR, not module loading.
Gotcha 2: Source Maps Not Uploaded = Unreadable Production Errors
In development, stack traces are readable because you’re running unminified code. In production, your bundler minifies and renames everything. If you have not configured source map upload in your build pipeline, every production error arrives with obfuscated stack traces referencing e, t, and n instead of your actual function names.
Source maps must be uploaded to Sentry at build time, and they must be deleted from your deployment artifacts afterward. Shipping source maps publicly exposes your source code. The Sentry Vite and webpack plugins handle both upload and deletion automatically — use them.
Verify source maps are working: deliberately throw an error in a component, check Sentry, confirm the stack trace shows your file names and line numbers. Do this during your initial setup, not after the first production incident.
Gotcha 3: Sending 4xx Errors as Exceptions
Application Insights records everything. Sentry bills per event and its value is in signal-to-noise ratio. If you let every NotFoundException (404) and UnauthorizedException (401) flow to Sentry, you end up with thousands of meaningless events that bury real errors.
Filter these out in beforeSend (Sentry-wide) or in your exception filter (NestJS-specific):
// In your SentryExceptionFilter — only capture 500s
if (status >= 500) {
Sentry.captureException(exception);
}
// Or in Sentry.init() — filter before the event is sent
beforeSend(event, hint) {
const error = hint.originalException;
if (error instanceof HttpException && error.getStatus() < 500) {
return null; // Drop this event
}
return event;
},
Gotcha 4: Environment and Release Are Not Automatic
In Application Insights, the environment is inferred from your Azure deployment slot. In Sentry, it is whatever string you pass to environment in Sentry.init(). If you ship to production without setting SENTRY_DSN, NODE_ENV, and SENTRY_RELEASE in your deployment environment, Sentry receives events tagged as development from a null release, which makes issue tracking and regression detection useless.
Set these in your deployment platform (Render, Vercel, etc.) and validate them in app startup:
// src/instrument.ts — fail fast if misconfigured in production
if (process.env.NODE_ENV === 'production' && !process.env.SENTRY_DSN) {
console.error('SENTRY_DSN is not set in production — errors will not be tracked');
}
Gotcha 5: tracesSampleRate of 1.0 in Production Will Bankrupt You
tracesSampleRate: 1.0 means capture 100% of transactions for performance monitoring. This is correct for development. In production on any real traffic volume, it creates enormous event volume. Use 0.1 (10%) or lower in production, or use tracesSampler to sample dynamically based on the route:
tracesSampler: (samplingContext) => {
// Always trace health checks at 0% — they're useless noise
if (samplingContext.name === 'GET /health') return 0;
// Always trace errors at 100%
if (samplingContext.parentSampled) return 1.0;
// Default: 10%
return 0.1;
},
Hands-On Exercise
Set up Sentry end-to-end in your NestJS project.
-
Create a free Sentry account at sentry.io and create a project (select Node.js for the backend, TypeScript).
-
Install
@sentry/nodeand createsrc/instrument.tswith the initialization configuration. SetSENTRY_DSNin your.envfile. -
Import
instrument.tsas the first line insrc/main.ts. -
Create the
SentryExceptionFilterglobal exception filter and register it inmain.ts. -
Add
Sentry.setUser()to your auth guard or JWT validation middleware. -
Deliberately trigger a 500 error in a controller (throw a plain
new Error('test error')) and verify it appears in Sentry with the correct user context. -
Add
beforeSendto filter out 4xx errors, then verify a 404 does not appear in Sentry. -
If you have a Vite frontend, add
@sentry/vite-pluginto yourvite.config.ts, configure source map upload, and verify that a frontend error shows readable TypeScript file references in Sentry.
Quick Reference
| Task | Code |
|---|---|
| Initialize (Node.js) | Sentry.init({ dsn, environment, release, tracesSampleRate }) |
| Initialize (Vue) | Sentry.init({ app, dsn, integrations: [browserTracingIntegration({ router })] }) |
| Capture exception | Sentry.captureException(error, { tags, extra }) |
| Capture message | Sentry.captureMessage('text', 'warning') |
| Set user context | Sentry.setUser({ id, email }) |
| Clear user context | Sentry.setUser(null) |
| Add breadcrumb | Sentry.addBreadcrumb({ category, message, level, data }) |
| Custom span | Sentry.startSpan({ name, op }, async (span) => { ... }) |
| Custom fingerprint | captureException(err, { fingerprint: ['custom-key', variable] }) |
| Filter events | beforeSend(event, hint) { return null to drop } |
| Flush before process exit | await Sentry.flush(2000) |
Environment Variables
| Variable | Description | Required |
|---|---|---|
SENTRY_DSN | Project DSN from Sentry settings | Yes |
SENTRY_RELEASE | Release identifier (e.g., api@abc1234) | Recommended |
SENTRY_AUTH_TOKEN | CLI token for source map upload | Build only |
NODE_ENV | Maps to Sentry environment | Yes |
NEXT_PUBLIC_SENTRY_DSN | Client-side DSN for Next.js | Yes (frontend) |
Package Reference
| Layer | Package |
|---|---|
| NestJS / Node.js | @sentry/node, @sentry/profiling-node |
| Next.js | @sentry/nextjs |
| Vue 3 (Vite) | @sentry/vue, @sentry/vite-plugin |
| Vite build plugin | @sentry/vite-plugin |
| Next.js build | handled by @sentry/nextjs |
Further Reading
- Sentry Node.js Documentation — Official SDK reference including all configuration options
- Sentry Next.js Documentation — Next.js-specific setup, App Router integration, and source map configuration
- Sentry Source Maps Guide — Covers Vite, webpack, and manual upload workflows
- Sentry Performance Monitoring — Transaction tracing, span instrumentation, and performance alerting