Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/architecture/sentry-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ await Sentry.startSpan(
// Metrics
Sentry.metrics.count("api.request", 1, { attributes: { endpoint: "/users" } });
Sentry.metrics.distribution("response_time", 150, { unit: "millisecond" });

// Structured logging
Sentry.logger.info("User subscribed to feed", { feedId: "123", userId: "456" });
Sentry.logger.warn("Rate limit approaching", { current: 95, limit: 100 });
Sentry.logger.error("Failed to fetch feed", { url: feed.url, error: err.message });

// Parameterized logs (recommended - makes values searchable)
const username = "john_doe";
Sentry.logger.info(Sentry.logger.fmt`User '${username}' logged in`);
```

### `startSpan` Behavior
Expand Down Expand Up @@ -298,10 +307,54 @@ Ensure `vitest.config.ts` has the Sentry alias configured before other `@/utils`

In Node.js/tests, metrics are no-ops. They only work in Cloudflare Workers with a valid `SENTRY_DSN`.

## Structured Logging

Sentry structured logs are enabled via `enableLogs: true` in the Sentry config.

### Log Levels

Six log levels available: `trace`, `debug`, `info`, `warn`, `error`, `fatal`

```typescript
Sentry.logger.trace("Entering function", { step: "init" });
Sentry.logger.debug("Cache miss", { key: "user:123" });
Sentry.logger.info("User action completed", { action: "subscribe" });
Sentry.logger.warn("Rate limit approaching", { current: 95, limit: 100 });
Sentry.logger.error("Operation failed", { error: err.message });
Sentry.logger.fatal("Critical system failure", { component: "database" });
```

### Parameterized Logs

Use `Sentry.logger.fmt` for searchable parameter values:

```typescript
const user = "john_doe";
const action = "subscribed";
Sentry.logger.info(Sentry.logger.fmt`User '${user}' ${action} to feed`);

// Automatically creates searchable attributes:
// - message.template: "User %s %s to feed"
// - message.parameter.0: "john_doe"
// - message.parameter.1: "subscribed"
```

### Viewing Logs

1. Navigate to **Explore → Logs** in Sentry UI
2. Filter by service, environment, level, or attributes
3. Search by message text or parameter values
4. Correlate logs with errors and traces

**See:** [Sentry Logging Guide](../sentry-logging-guide.md) for complete documentation

## Best Practices

1. **Always use the wrapper**: Import from `@/utils/sentry`, never directly from SDK
2. **Don't await sync methods**: `setUser`, `addBreadcrumb`, `captureException` are void
3. **Use spans for async operations**: Wrap database queries, API calls, etc.
4. **Add breadcrumbs for debugging**: They help trace issues in production
5. **Tag errors appropriately**: Use `tags` for filtering, `extra` for context
6. **Use appropriate log levels**: Don't log everything as `error`
7. **Add context with attributes**: More searchable than string interpolation
8. **Use parameterized logs**: Better for analysis and alerting
4 changes: 4 additions & 0 deletions packages/api/src/config/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function getSentryConfig(env: Env): Record<string, unknown> | null {
// Enable logs for better debugging
enableLogs: true,

// Send default PII (request headers, IP) for better context
// Safe to enable because we filter sensitive fields via beforeSend callbacks
sendDefaultPii: true,

// Debug mode (verbose logging - useful for development)
debug: environment === "development",

Expand Down
12 changes: 11 additions & 1 deletion packages/api/src/entries/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,14 @@ export default Sentry.withSentry((env: Env) => {
const existingIntegrations = Array.isArray(config.integrations)
? (config.integrations as unknown[])
: [];
config.integrations = [...existingIntegrations, Sentry.vercelAIIntegration()];
config.integrations = [
...existingIntegrations,
Sentry.vercelAIIntegration(),
// Automatically capture console.log, console.warn, and console.error as logs
Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] }),
// Hono error capturing integration (enabled by default, but explicit for clarity)
Sentry.honoIntegration(),
];

// Log Sentry initialization in development
const environment = (env.SENTRY_ENVIRONMENT ||
Expand All @@ -135,6 +142,9 @@ export default Sentry.withSentry((env: Env) => {
release: config.release,
hasDsn: !!config.dsn,
aiTracking: true,
consoleLogging: true,
httpTracing: true,
trpcTracing: true,
});
}

Expand Down
8 changes: 7 additions & 1 deletion packages/api/src/entries/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,15 @@ if (env.SENTRY_DSN) {
recordInputs: true, // Safe: only used for pro/enterprise users with opt-in
recordOutputs: true, // Captures structured category suggestions
}),
// Automatically capture console.log, console.warn, and console.error as logs
Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] }),
// Hono error capturing integration
Sentry.honoIntegration(),
],
});
console.log("✅ Sentry initialized (with metrics and AI tracking enabled)");
console.log(
"✅ Sentry initialized (metrics, AI tracking, console logging, HTTP tracing, tRPC tracing enabled)"
);
}
}

Expand Down
35 changes: 35 additions & 0 deletions packages/api/src/hono/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ export function createHonoApp(config: HonoAppConfig) {
await next();
});

// Sentry HTTP tracing middleware
// Creates spans for all HTTP requests with proper transaction names
app.use("*", async (c, next) => {
const Sentry = c.get("sentry");
const env = c.get("env");

// Only create spans if Sentry is configured
if (!Sentry || !env.SENTRY_DSN) {
return await next();
}

// Create transaction name from method and path
const method = c.req.method;
const path = c.req.path;

// Use Sentry.startSpan to create a trace for this HTTP request
return await Sentry.startSpan(
{
name: `${method} ${path}`,
op: "http.server",
attributes: {
"http.method": method,
"http.route": path,
"http.url": c.req.url,
},
},
async (span) => {
await next();

// Add response status to span using the provided span parameter
span.setAttribute("http.status_code", c.res.status);
}
);
});

// CORS middleware (must be before routes)
const corsOrigins = getCorsOrigins(config.env);
console.log("🔧 CORS Configuration:", {
Expand Down
51 changes: 25 additions & 26 deletions packages/api/src/trpc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,37 +56,33 @@ const t = initTRPC.context<Context>().create({
export const router = t.router;

/**
* Sentry tRPC middleware (optional)
* Sentry tRPC middleware
* Creates spans and improves error capturing for tRPC handlers
* See: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations/trpc
*
* The middleware is created at module load time, but will only create spans
* if Sentry is initialized (checked internally by Sentry).
* Uses build-time aliased @/utils/sentry which resolves to:
* - @sentry/cloudflare in Workers
* - @sentry/node in Node.js
* - noop in tests
*/
let sentryMiddleware: ReturnType<typeof t.middleware> | null = null;
try {
// Try to import Sentry and create middleware
// This will work in Cloudflare Workers where @sentry/cloudflare is available
// In Node.js, this will fail gracefully and we'll continue without it
const SentryModule = await import("@sentry/cloudflare");
if (SentryModule.trpcMiddleware) {
sentryMiddleware = t.middleware(
SentryModule.trpcMiddleware({
attachRpcInput: true, // Include RPC input in error context for debugging
const baseProcedure = (() => {
// Check if Sentry has trpcMiddleware (available in Cloudflare and Node.js)
if (typeof Sentry.trpcMiddleware === "function") {
const sentryMiddleware = t.middleware(
Sentry.trpcMiddleware({
Comment on lines +68 to +72

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing purposes: This was an auto-detected error by Sentry. Not automatically triggered.

Copy link
Contributor Author

@KyleTryon KyleTryon Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude response validates issue:

Why This Happened

I created baseProcedure and exported it as publicProcedure for "compatibility", but forgot to update the >downstream procedure definitions. They're still chaining from t.procedure instead of baseProcedure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result: ALL procedures now have Sentry tracing! The Sentry bot saved us from deploying a "fix" that would have done almost nothing. 🙏

Deploy and test - you should now see traces for feed subscriptions and all other operations!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a big issue Claude code left behind by mistake. It nearly got it right, but dropepd the ball at the end. Somewhat expected. Good example of a really important catch by Seer.

attachRpcInput: true, // Include RPC input in spans and error context
})
);
return t.procedure.use(sentryMiddleware);
}
} catch {
// Sentry not available (e.g., in Node.js environment or not installed)
// Continue without Sentry middleware - it's optional
sentryMiddleware = null;
}

// Base procedure with Sentry middleware if available
// The middleware will only create spans if Sentry is initialized at runtime
export const publicProcedure = sentryMiddleware
? t.procedure.use(sentryMiddleware)
: t.procedure;
// Fallback to base procedure if trpcMiddleware not available (e.g., in tests)
return t.procedure;
})();

// Export as publicProcedure for compatibility
// All procedures will now inherit Sentry tracing
export const publicProcedure = baseProcedure;

/**
* Helper function to get cached user record
Expand Down Expand Up @@ -251,11 +247,13 @@ const isAuthedWithoutVerification = t.middleware(async ({ ctx, next }) => {
});

// Protected procedure - requires authentication
export const protectedProcedure = t.procedure.use(isAuthed);
// Uses baseProcedure to inherit Sentry tracing middleware
export const protectedProcedure = baseProcedure.use(isAuthed);

// Protected procedure without email verification check
// Use this for endpoints that unverified users need (e.g., checkVerificationStatus, resendVerificationEmail)
export const protectedProcedureWithoutVerification = t.procedure.use(
// Uses baseProcedure to inherit Sentry tracing middleware
export const protectedProcedureWithoutVerification = baseProcedure.use(
isAuthedWithoutVerification
);

Expand Down Expand Up @@ -346,7 +344,8 @@ const isAdmin = t.middleware(async ({ ctx, next }) => {
});

// Admin procedure - requires authentication and admin role
export const adminProcedure = t.procedure.use(isAdmin);
// Uses baseProcedure to inherit Sentry tracing middleware
export const adminProcedure = baseProcedure.use(isAdmin);

/**
* Rate limiting middleware
Expand Down
Loading