Reconstruct production crashes with Sentry breadcrumbs

By Kate Baker
Reconstruct production crashes with Sentry breadcrumbs

A stack trace answers one question: which line threw. It rarely answers the one you actually need when a beta user reports a crash, which is what happened before that line ran. Did they click "Save" twice? Did the previous API call return a 401 that nobody handled? Did a feature flag flip mid-session?

Sentry breadcrumbs exist to answer that. They are a chronological trail of events that the SDK records in memory as your app runs, and attaches to the error event when something goes wrong. Configured well, they let you read a crash like a log of the last 30 seconds of the session. Configured badly, they are either empty or 100 lines of console.log noise.

This guide covers how breadcrumbs work, what Sentry captures automatically, how to add your own at the right points, and how to filter the trail so it stays readable. Examples use the JavaScript SDK (browser and Node.js) and the Python SDK.

How Sentry breadcrumbs work

A breadcrumb is a small structured record with a timestamp. The SDK keeps them in a ring buffer on the current scope. Nothing is sent to Sentry when a breadcrumb is recorded. Only when an error or message event is captured does the SDK attach the buffered breadcrumbs to that event.

That design has three consequences worth remembering:

  • Breadcrumbs are cheap. Recording one is an in-memory push, not a network call, so you can be generous at important points.
  • The buffer is finite. By default the SDK keeps the most recent 100 breadcrumbs (maxBreadcrumbs). Older ones are dropped. If noisy events fill the buffer, the interesting ones fall off before the crash.
  • Breadcrumbs belong to a scope. In the browser that is effectively the page session. On a server, each incoming request should get its own trail, otherwise you see events from other users mixed into your error. More on this below.

Every breadcrumb has a handful of fields:

FieldPurpose
typeHow Sentry renders it: default, http, navigation, ui, user, query, error, debug, info
categoryYour own grouping label, e.g. auth, checkout, ui.click
messageHuman-readable description
levelfatal, error, warning, log, info, or debug
dataArbitrary key/value context
timestampSet automatically

In the Sentry UI, the issue page shows these as a timeline right above or beside the stack trace, with the final entry being the exception itself.

What gets captured automatically

Before writing any custom code, check what you already get. Most crashes can be reconstructed from automatic breadcrumbs alone once you know what to look for.

Browser JavaScript SDK records, out of the box:

  • ui.click and ui.input events on DOM elements (with a CSS-like selector for the target)
  • fetch and XMLHttpRequest calls, including method, URL, and status code
  • Navigation changes via the History API
  • console calls (log, warn, error, and so on)
  • Previous Sentry events captured in the same session

Node.js SDK records outgoing HTTP requests, console output, and, depending on the integrations enabled, database queries from supported drivers.

Python SDK records outgoing HTTP requests made with the standard library and requests, and log records. By default, logging calls at INFO and above become breadcrumbs, while ERROR and above also create events.

A typical automatic trail for a browser crash looks like this:

navigation   /projects → /projects/42/settings
ui.click     button#save-settings
http         PATCH /api/projects/42  [403]
console      warn: "Permission check failed"
ui.click     button#save-settings
error        TypeError: Cannot read properties of undefined (reading 'name')

That trail already tells you the fix: the frontend does not handle a 403, and the second click reads a response body that is not there.

Setting up the SDK with breadcrumbs in mind

Browser

import * as Sentry from "@sentry/browser"; // or @sentry/react, @sentry/vue, etc.

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.MODE,
  release: import.meta.env.VITE_RELEASE,
  maxBreadcrumbs: 100,
  integrations: [
    Sentry.breadcrumbsIntegration({
      console: true,
      dom: { serializeAttribute: ["data-testid"] },
      fetch: true,
      history: true,
      xhr: true,
    }),
  ],
});

The breadcrumbsIntegration is enabled by default. You only need to add it explicitly when you want to change its options. The serializeAttribute option is worth using: it tells the SDK to include a stable attribute such as data-testid in the click selector. Auto-generated class names from CSS-in-JS libraries make selectors like div.css-1x2y3z > button useless. [data-testid="save-settings"] is readable.

Node.js

// instrument.js, loaded before anything else (node --require ./instrument.js app.js)
const Sentry = require("@sentry/node");

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  release: process.env.RELEASE,
  maxBreadcrumbs: 50,
});

Recent major versions of the Node SDK (v8 and later) create an isolation scope per incoming request for supported HTTP frameworks. Breadcrumbs added while handling a request stay attached to that request, so a crash in one user's request does not show another user's database queries. If you run background jobs or queue consumers outside an HTTP request, wrap each unit of work so it gets its own isolation scope:

await Sentry.withIsolationScope(async () => {
  Sentry.addBreadcrumb({ category: "job", message: `Processing ${job.id}` });
  await processJob(job);
});

Python

import logging
import os
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration

sentry_sdk.init(
    dsn=os.environ["SENTRY_DSN"],
    environment=os.environ.get("ENVIRONMENT", "production"),
    release=os.environ.get("RELEASE"),
    max_breadcrumbs=50,
    integrations=[
        LoggingIntegration(
            level=logging.INFO,        # INFO+ becomes breadcrumbs
            event_level=logging.ERROR, # ERROR+ becomes Sentry events
        ),
    ],
)

If your app logs a lot at INFO, raise level to logging.WARNING or filter specific loggers (shown later) so the buffer is not dominated by request logs.

Adding custom breadcrumbs at state transitions

Automatic breadcrumbs capture mechanics: clicks, requests, navigation. They do not capture meaning. Sentry does not know that a user just switched workspaces, that a cart moved to "payment pending", or that a retry loop is on attempt four.

The rule of thumb: add a breadcrumb wherever your application changes state in a way you would want to know about during debugging. Good candidates:

  • Authentication changes: login, logout, token refresh, role switch
  • Business state machine transitions: order status, subscription status, onboarding step
  • Feature flag evaluations that change code paths
  • Retries, fallbacks, and circuit breaker trips
  • Cache misses on critical paths
  • Background job start and finish
  • WebSocket connect, disconnect, and reconnect

JavaScript

import * as Sentry from "@sentry/browser";

function setWorkspace(workspace) {
  Sentry.addBreadcrumb({
    type: "user",
    category: "workspace",
    message: `Switched to workspace ${workspace.id}`,
    level: "info",
    data: { workspaceId: workspace.id, plan: workspace.plan },
  });
  store.setWorkspace(workspace);
}

async function fetchWithRetry(url, attempts = 3) {
  for (let i = 1; i <= attempts; i++) {
    try {
      return await fetch(url);
    } catch (err) {
      Sentry.addBreadcrumb({
        category: "network.retry",
        message: `Retry ${i}/${attempts} for ${url}`,
        level: "warning",
        data: { error: String(err) },
      });
      if (i === attempts) throw err;
    }
  }
}

Python

import sentry_sdk

def transition_order(order, new_status):
    sentry_sdk.add_breadcrumb(
        category="order",
        message=f"Order {order.id}: {order.status} -> {new_status}",
        level="info",
        data={"order_id": order.id, "from": order.status, "to": new_status},
    )
    order.status = new_status
    order.save()
Put identifiers and states in data, not only in message. The message is for reading the timeline; data is what you inspect when you need the exact value that caused the branch.

Keep custom breadcrumbs consistent

With a single developer, it is tempting to sprinkle ad hoc addBreadcrumb calls. Six months later the categories are Auth, auth, login, and user-auth. A tiny wrapper keeps the trail consistent:

export function trail(category, message, data = {}, level = "info") {
  Sentry.addBreadcrumb({ category, message, data, level });
}

// usage
trail("checkout", "Payment intent created", { intentId });

Decide on a small set of categories upfront and stick to them.

Filtering noise with beforeBreadcrumb

The single biggest reason breadcrumbs fail to help is noise. A chatty analytics script, a polling endpoint, or verbose debug logging fills the 100-slot buffer, and the events leading up to the crash are gone.

The beforeBreadcrumb hook runs for every breadcrumb before it enters the buffer. Return the breadcrumb (optionally modified) to keep it, or null to drop it. The second argument, hint, contains the raw source: the DOM event for clicks, the request and response for fetch, the arguments for console.

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  beforeBreadcrumb(breadcrumb, hint) {
    // 1. Drop console.debug and console.log noise; keep warn and error
    if (breadcrumb.category === "console" &&
        (breadcrumb.level === "debug" || breadcrumb.level === "log")) {
      return null;
    }

    // 2. Drop polling and third-party analytics requests
    if (breadcrumb.category === "fetch" || breadcrumb.category === "xhr") {
      const url = breadcrumb.data?.url ?? "";
      if (url.includes("/api/health") || url.includes("analytics.")) {
        return null;
      }
      // 3. Strip query strings that may contain tokens
      breadcrumb.data.url = url.split("?")[0];
    }

    // 4. Enrich clicks with the visible button label
    if (breadcrumb.category === "ui.click" && hint?.event?.target) {
      const label = hint.event.target.innerText?.trim().slice(0, 40);
      if (label) breadcrumb.message = `${breadcrumb.message} "${label}"`;
    }

    return breadcrumb;
  },
});

The Python equivalent is before_breadcrumb:

def before_breadcrumb(crumb, hint):
    # Drop noisy loggers
    if crumb.get("category") in ("urllib3.connectionpool", "botocore"):
        return None
    # Drop health checks
    if crumb.get("type") == "http" and "/health" in crumb.get("data", {}).get("url", ""):
        return None
    return crumb

sentry_sdk.init(dsn=..., before_breadcrumb=before_breadcrumb)

A few filtering guidelines:

  • Filter by source, not by volume. Drop specific categories and URLs you know are noise rather than lowering maxBreadcrumbs.
  • Scrub before it leaves the process. Breadcrumbs travel with the event, so query strings, email addresses in log messages, and form values in ui.input can end up in Sentry. beforeBreadcrumb is the right place to remove them. Sentry also has server-side data scrubbing, but do not rely on it alone.
  • Be careful with hint. It holds references to live objects (DOM nodes, response objects). Read from them, but do not store them in data, because they may not serialize.

Reading the trail: a worked example

Here is a trail from a real-world style bug in a small SaaS: users occasionally saw a blank dashboard after logging in.

14:02:11  navigation      /login → /dashboard
14:02:11  auth            Token refreshed (expires_in: 3600)
14:02:11  workspace       Switched to workspace ws_91 (plan: trial)
14:02:12  fetch           GET /api/workspaces/ws_91/stats [200]
14:02:12  feature-flag    new-dashboard = true
14:02:12  fetch           GET /api/workspaces/ws_91/widgets [404]
14:02:12  console.warn    Widget config missing, using defaults
14:02:12  error           TypeError: widgets.map is not a function

Without breadcrumbs, the issue is a TypeError in the dashboard component with no obvious cause. With them, the path is clear in one read:

  1. The new-dashboard flag was on for this user.
  2. Trial workspaces had no widget config, so the API returned 404.
  3. The fallback code set widgets to an object instead of an array.

Two custom breadcrumbs (workspace and feature-flag) made the difference. The automatic ones supplied the 404. That combination, automatic mechanics plus custom state transitions, is what you are aiming for.

Common mistakes

  • Initializing Sentry too late. Breadcrumbs are only recorded after init runs. In Node.js, load the instrument file before your app code. In the browser, initialize before your framework mounts.
  • Relying on console.log as your breadcrumb strategy. It works, but console output is unstructured and usually the noisiest source. Use addBreadcrumb for things that matter.
  • Mixing request scopes on the server. If breadcrumbs in a server error show unrelated users' activity, the work is not running inside a per-request isolation scope. Check your framework integration or wrap work explicitly.
  • Logging secrets. A breadcrumb with data: { headers } from a request will happily include Authorization. Whitelist the fields you add.
  • Expecting breadcrumbs without errors. Breadcrumbs are context, not an event stream. If you need to see behavior when nothing crashes, that is a job for logs, tracing, or session replay.

Where your app runs matters too

Breadcrumbs are only as good as the metadata around them. Setting release and environment on every deploy lets you tell whether a crash trail belongs to the build you shipped an hour ago or last week's.

If you deploy on Granite.so, you can set SENTRY_DSN, ENVIRONMENT, and a release identifier as environment variables on the app, and every push-triggered build picks them up without extra infrastructure. That keeps error context tied to the right deploy while you focus on fixing what the trail shows you.

Summary

Sentry breadcrumbs turn an isolated stack trace into a timeline. To make them useful in production:

  1. Initialize the SDK early and set release and environment.
  2. Keep the automatic integrations on; tune dom.serializeAttribute for readable click targets.
  3. Add custom breadcrumbs at state transitions, retries, and flag evaluations, with IDs in data.
  4. Use beforeBreadcrumb to drop noise and scrub sensitive data.
  5. On servers, make sure each request or job has its own isolation scope.

Do that once, and the next production crash arrives with enough context to fix it on the first occurrence instead of the fifth.

And if you would rather spend that saved time on the product than on servers, Granite.so can host the app those breadcrumbs come from, with deploys straight from Git and a managed database alongside it.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox