All docs/@repo/core

docs/architecture/error-logging-and-sentry.md

Error Logging & Sentry Integration Architecture

This document describes how errors flow from application code to Sentry, how the logger and Sentry interact, and flags known issues in the current implementation.

Overview

The codebase uses a provider registration pattern to connect structured loggers to Sentry without creating hard import dependencies. The key principle: packages define register*Provider() functions, and host apps wire up Sentry at initialization.

There are three error reporting mechanisms:

MechanismWhereWhat it does
logger.error()Server + ClientStructured console log + auto-captures to Sentry via registered provider
useSentryToast()Client onlyShows destructive toast + captures to Sentry directly
Sentry.captureException()Server + ClientDirect Sentry capture (bypasses logger)

Core Logger Implementations

Server Logger (packages/core/src/server/logger.ts)

Logger.error(message, context?, error?)
  ├── console.error(JSON) in production (Cloud Logging / Vercel)
  ├── console.error(readable) in development
  └── _errorCaptureProvider(error ?? message, { tags, contexts })   ← Sentry

Key behavior:

  • error() always captures to Sentry when provider is registered, even without an Error object
  • Without an Error object, the message string is passed to Sentry.captureException (creates a synthetic exception)
  • warn() captures via _messageCaptureProvider (Sentry.captureMessage with level "warning")
  • info() and debug() never capture to Sentry
  • The error parameter is unknown and normalized via toError() (packages/core/src/shared/errors.ts)

Client Logger (packages/core/src/web/logger.ts)

ClientLogger.error(message, context?, error?)
  ├── addBreadcrumb({ type: "error", ... })                        ← Sentry breadcrumb trail
  ├── _errorCaptureProvider(error ?? message, { tags, contexts })   ← Sentry
  └── console.error(readable) in development ONLY

Key differences from server logger:

  • Adds Sentry breadcrumbs on every log call (debug/info/warn/error)
  • Only outputs to console in non-production (dev) environments
  • error parameter is typed as Error (not unknown)

Log Level to Sentry Mapping

LevelConsole OutputSentry CaptureSentry Breadcrumb
debugDev only (both)NeverClient only
infoAlways (server), Dev only (client)NeverClient only
warnAlways (server), Dev only (client)captureMessage (warning level)Client only
errorAlways (server), Dev only (client)captureExceptionClient only

Provider Registration Flow

Server-Side (instrumentation.ts)

All Next.js apps follow the same pattern in instrumentation.ts:

export async function register() {
  const env = getAppEnvironment();
  if (env.ENVIRONMENT === "cloud" && env.SENTRY_DSN) {
    const Sentry = await import("@sentry/nextjs");
    Sentry.init({ dsn, sampleRate: 1.0, tracesSampleRate: 0.2, ... });

    // Wire logger -> Sentry
    registerErrorCaptureProvider(Sentry.captureException);
    registerMessageCaptureProvider(createSentryMessageCaptureProvider(Sentry));
  }
}

Apps with this pattern: library, hello, admin, branded-video-flow, links

Express services (video-processor, sync-worker) follow the same pattern in their src/index.ts.

Client-Side (Two-Phase Init)

Phase 1: sentry.client.config.ts - Runs at script load time (before React):

if (typeof window !== "undefined") {
  const env = getAppPublicEnvironment();
  if (env.ENVIRONMENT === "cloud" && env.NEXT_PUBLIC_SENTRY_DSN) {
    Sentry.init(buildClientSentryOptions({ dsn, ... }));
  }
}

Phase 2: Client providers - Registers logger providers. Registration location varies by app:

// library, hello, admin, links: inside useEffect (runs after first render)
useEffect(() => {
  registerBreadcrumbProvider((b) => Sentry.addBreadcrumb(b));
  registerErrorCaptureProvider(Sentry.captureException);
  registerMessageCaptureProvider(createSentryMessageCaptureProvider(Sentry));
}, []);

// branded-video-flow: at module level (before component renders — preferred)
registerSentryProvider({ addBreadcrumb: Sentry.addBreadcrumb, captureException: Sentry.captureException });
registerBreadcrumbProvider((b) => Sentry.addBreadcrumb(b));
registerErrorCaptureProvider(Sentry.captureException);
registerMessageCaptureProvider(createSentryMessageCaptureProvider(Sentry));
registerLayoutTelemetrySentry(Sentry);

The module-level pattern (used in BVF) is preferred because registration is guaranteed to happen before any component renders. The useEffect pattern delays registration until after the first render — components that call getLogger().error() synchronously on mount could theoretically fire before providers are registered in the useEffect approach.

Apps with client registration: library, hello, admin, links, branded-video-flow

useSentryToast

useSentryToast() (packages/core/src/web/use-sentry-toast.tsx) combines user-visible error toasts with Sentry capture:

const { toast } = useSentryToast();

toast.error("Operation failed", {
  description: "Could not save",
  error: caughtError,
  context: { flowId },
  tags: { action: "save" },
});

Internal flow:

toast.error(message, options)
  ├── baseToast({ title, description, variant: "destructive" })    ← User sees toast
  └── Sentry.captureException(error ?? new Error(message), ...)    ← Direct Sentry call

~20 files use useSentryToast correctly across app-hello, app-library, app-admin-library, and the hello/admin apps.

Client Sentry Configuration

buildClientSentryOptions() (packages/core/src/web/sentry.ts) configures:

FeatureSettingNotes
Error capturesampleRate: 1.0100% of errors captured
PerformancetracesSampleRate: 0.220% of transactions
Console capturecaptureConsoleIntegration(["error", "warn"])Captures console.error/warn to Sentry
Session replayreplaysOnErrorSampleRate: 0.1-0.5Per-app configuration
Browser profilingprofilesSampleRate: 0.110% of sessions
Extension filteringbeforeSendDrops browser extension errors
Third-party filteringbeforeSendDrops errors from only third-party frames
Network enrichmentbeforeSendAdds online/offline status as tag
Clerk filteringignoreErrorsDrops Clerk dev key warnings in non-prod

Error Boundary Pattern

Error boundaries use direct Sentry.captureException to obtain the eventId needed for feedback dialogs:

// packages/core/src/web/global-error.tsx
useEffect(() => {
  Sentry.captureException(error, {
    tags: serviceName ? { service: serviceName } : undefined,
  });
}, [error]);

Components that need the Sentry event ID for feedback dialogs (e.g., DraftErrorState, RecordingSetupScreen) call Sentry.captureException directly because logger.error() does not return the event ID.

Data Flow Diagrams

Server-Side Error Flow

Application code
  │
  ├─── logger.error("msg", ctx, err)
  │      ├── console.error(JSON)                → Cloud Logging / Vercel Logs
  │      └── Sentry.captureException(err)        → Sentry (via registered provider)
  │
  ├─── logger.warn("msg", ctx)
  │      ├── console.warn(JSON)                  → Cloud Logging / Vercel Logs
  │      └── Sentry.captureMessage("msg", warn)  → Sentry (via registered provider)
  │
  └─── Sentry.captureException(err)              → Sentry (direct, bypasses logger)
         (used in: test endpoints, Express error handlers)

Client-Side Error Flow

Application code
  │
  ├─── logger.error("msg", ctx, err)
  │      ├── Sentry.addBreadcrumb(...)           → Breadcrumb trail
  │      ├── Sentry.captureException(err)        → Sentry (via registered provider)
  │      └── console.error(readable)             → Browser console (dev only)
  │
  ├─── toast.error("msg", { error })             (via useSentryToast)
  │      ├── Toast notification                  → User sees destructive toast
  │      └── Sentry.captureException(err)        → Sentry (direct call)
  │
  ├─── Sentry.captureException(err)              → Sentry (direct)
  │      (used in: error boundaries, feedback dialog components)
  │
  └─── console.error("...")                      → Sentry (via captureConsoleIntegration)
         (only in production where Sentry is initialized)

Key Source Files

FileRole
packages/core/src/server/logger.tsServer Logger class with provider registration
packages/core/src/web/logger.tsClient ClientLogger class with breadcrumbs + provider registration
packages/core/src/server/sentry.tsbuildServerSentryOptions, createSentryMessageCaptureProvider
packages/core/src/web/sentry.tsbuildClientSentryOptions, client event filtering, createSentryMessageCaptureProvider
packages/core/src/web/use-sentry-toast.tsxuseSentryToast() hook - toast + Sentry in one call
packages/core/src/web/global-error.tsxShared Next.js global error boundary with Sentry capture
packages/core/src/shared/errors.tstoError() - normalizes unknown values to Error instances
apps/*/instrumentation.tsServer-side Sentry init + provider registration
apps/*/sentry.client.config.tsClient-side Sentry init (phase 1)
apps/*/_providers/client-providers.tsxClient-side provider registration (phase 2)

Architecture Strengths

  1. Decoupled via providers: Logger works without Sentry; Sentry integration is opt-in via registration. Packages never import @sentry/* directly.
  2. Consistent initialization: All apps follow the same instrumentation.ts + sentry.client.config.ts + client-providers.tsx pattern.
  3. Auto-capture on error/warn: Once providers are registered, logger.error() and logger.warn() automatically capture without additional code.
  4. Smart client filtering: Browser extension errors, third-party-only errors, and Clerk dev warnings are filtered before reaching Sentry.
  5. Breadcrumb trail: Client logger adds breadcrumbs on every log call, providing context leading up to errors.
  6. Environment-aware: Sentry only initializes in cloud environments with valid DSNs. Local dev gets console-only logging.

Decision Guide: Which Error Reporting Mechanism to Use

Need to report an error?
├── Server-side code?
│   └── logger.error(message, context, error)
│       Auto-captures to Sentry, logs to Cloud Logging.
│
├── Client component with user-visible error?
│   ├── Need a toast notification?
│   │   └── useSentryToast().toast.error(message, { error, context })
│   │       Shows toast + captures to Sentry.
│   │
│   └── No toast needed (e.g., error boundary)?
│       └── Need Sentry eventId for feedback dialog?
│           ├── YES → Sentry.captureException(error) directly
│           │         (Don't also call logger.error — causes double capture)
│           └── NO  → logger.error(message, context, error)
│                     Auto-captures to Sentry via provider.
│
└── Package code (no direct Sentry import)?
    └── logger.error(message, context, error)
        Captures to Sentry only if host app registered providers.

Related Documentation