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:
| Field | Purpose |
|---|---|
type | How Sentry renders it: default, http, navigation, ui, user, query, error, debug, info |
category | Your own grouping label, e.g. auth, checkout, ui.click |
message | Human-readable description |
level | fatal, error, warning, log, info, or debug |
data | Arbitrary key/value context |
timestamp | Set 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.clickandui.inputevents on DOM elements (with a CSS-like selector for the target)fetchandXMLHttpRequestcalls, including method, URL, and status code- Navigation changes via the History API
consolecalls (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 indata, not only inmessage. The message is for reading the timeline;datais 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.inputcan end up in Sentry.beforeBreadcrumbis 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 indata, 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 functionWithout breadcrumbs, the issue is a TypeError in the dashboard component with no obvious cause. With them, the path is clear in one read:
- The
new-dashboardflag was on for this user. - Trial workspaces had no widget config, so the API returned 404.
- The fallback code set
widgetsto 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
initruns. In Node.js, load the instrument file before your app code. In the browser, initialize before your framework mounts. - Relying on
console.logas your breadcrumb strategy. It works, but console output is unstructured and usually the noisiest source. UseaddBreadcrumbfor 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 includeAuthorization. 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:
- Initialize the SDK early and set
releaseandenvironment. - Keep the automatic integrations on; tune
dom.serializeAttributefor readable click targets. - Add custom breadcrumbs at state transitions, retries, and flag evaluations, with IDs in
data. - Use
beforeBreadcrumbto drop noise and scrub sensitive data. - 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.