diff --git a/docs/architecture/sentry-integration.md b/docs/architecture/sentry-integration.md index 743de83..1a0b8a1 100644 --- a/docs/architecture/sentry-integration.md +++ b/docs/architecture/sentry-integration.md @@ -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 @@ -298,6 +307,47 @@ 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 @@ -305,3 +355,6 @@ In Node.js/tests, metrics are no-ops. They only work in Cloudflare Workers with 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 diff --git a/packages/api/src/config/sentry.ts b/packages/api/src/config/sentry.ts index 7cc9720..6207c17 100644 --- a/packages/api/src/config/sentry.ts +++ b/packages/api/src/config/sentry.ts @@ -73,6 +73,10 @@ export function getSentryConfig(env: Env): Record | 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", diff --git a/packages/api/src/entries/cloudflare.ts b/packages/api/src/entries/cloudflare.ts index 5646ef0..23457d2 100644 --- a/packages/api/src/entries/cloudflare.ts +++ b/packages/api/src/entries/cloudflare.ts @@ -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 || @@ -135,6 +142,9 @@ export default Sentry.withSentry((env: Env) => { release: config.release, hasDsn: !!config.dsn, aiTracking: true, + consoleLogging: true, + httpTracing: true, + trpcTracing: true, }); } diff --git a/packages/api/src/entries/node.ts b/packages/api/src/entries/node.ts index ab672bf..0953d87 100644 --- a/packages/api/src/entries/node.ts +++ b/packages/api/src/entries/node.ts @@ -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)" + ); } } diff --git a/packages/api/src/hono/app.ts b/packages/api/src/hono/app.ts index 0228ccd..5961f46 100644 --- a/packages/api/src/hono/app.ts +++ b/packages/api/src/hono/app.ts @@ -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:", { diff --git a/packages/api/src/trpc/init.ts b/packages/api/src/trpc/init.ts index 1ac3ffa..f19a0a9 100644 --- a/packages/api/src/trpc/init.ts +++ b/packages/api/src/trpc/init.ts @@ -56,37 +56,33 @@ const t = initTRPC.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 | 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({ + 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 @@ -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 ); @@ -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