# Sentry, From Zero

> Error tracking that turns a vague bug report into a stack trace: grouped issues, the breadcrumbs and context around a crash, releases, and source maps.


---

# Sentry, From Zero

A user writes "it crashed" and closes the ticket. No steps, no browser, no idea which line. You stare at a log file that scrolled past hours ago, hoping the right exception is still in there. Sentry is the tool that catches the crash the moment it happens, with the stack trace, the events leading up to it, and which deploy introduced it. This guide takes you from "I have no idea what broke" to "I'm looking at the exact line, for the exact user, on the exact release."

## How to read this

Read the phases in order. Phase 1 builds the mental model: what an error tracker actually does and why it beats grepping logs. Phase 2 is the everyday loop: capturing exceptions, reading an issue, adding the context that makes a crash diagnosable. Phase 3 is the hard-won stuff: source maps for minified JavaScript, releases, noise control, and the gotchas that quietly make Sentry useless if you skip them. Each phase ends with a short quiz so you can check yourself before moving on.

## The phases

1. [What Sentry actually is](01-what-sentry-actually-is.md) - the mental model: exceptions captured with full context, not lines in a log.
2. [Capturing and reading an issue](02-capturing-and-reading-an-issue.md) - install the SDK, group events into issues, add tags and context.
3. [Releases, source maps, and noise](03-releases-source-maps-and-noise.md) - production reality: which deploy broke it, un-minifying traces, taming alerts.


---

# What Sentry actually is

Here is the reality Sentry is built for. Something throws an exception in production. Maybe a user sees a white screen, maybe an API returns a 500, maybe a background job dies quietly and nobody notices for a week. The information you need to fix it - the type of error, the file and line, the values that caused it, what the user did right before - all exists for a fraction of a second and then it's gone. Your logs might have caught a sliver of it, if you happened to log the right thing, and if you can find it among ten thousand other lines.

Sentry's whole job is to grab that moment and keep it. When an exception is thrown, Sentry's SDK catches it, packages up everything around it, and sends it to a server you can search. You stop asking users "what were you doing?" because Sentry already knows.

## It captures exceptions, not log lines

The mental shift is this: a log is a string you decided to write in advance. An error tracker captures a structured event the moment something breaks, whether or not you anticipated it.

A typical log line for a crash looks like this:

```text
2026-06-30T14:22:01Z ERROR something went wrong: NoneType has no attribute 'email'
```

*What just happened:* you got one flat string. You know roughly what broke, but not where, not for whom, not with what input. To learn more you'd have had to predict this exact failure and log those details ahead of time.

A Sentry event for the same crash carries structure instead:

```text
AttributeError: 'NoneType' object has no attribute 'email'
  in send_welcome(user)  at  app/mail.py:42
  user.id = 90431   user is None  (lookup returned nothing)
  release = web@2026.06.30-a1b2c3
  url = POST /signup    environment = production
  10 breadcrumbs leading up to the crash
```

*What just happened:* the same failure now comes with the exact function and line, the variable that was `None`, the deploy it happened on, the request that triggered it, and a trail of what led there. You didn't write any of that by hand - the SDK collected it automatically when the exception bubbled up.

If reading a stack trace still feels like guesswork, the [reading-a-stack-trace](/guides/reading-a-stack-trace) guide is worth a detour - Sentry shows you traces all day, and they only help if you can read them.

## Events get grouped into issues

If one bad deploy crashes for ten thousand users, you do not want ten thousand notifications. You want one: "this is broken, it's affecting ten thousand people."

That is the difference between an **event** and an **issue**. An event is a single occurrence - one crash, one user, one moment. An **issue** is a group of events that Sentry decided are the same bug.

```text
Issue:  AttributeError: 'NoneType' object has no attribute 'email'
        app/mail.py in send_welcome
        ── 12,403 events ── 9,981 users ── first seen 2h ago

Events inside it:
  event a1  user 90431  14:22:01
  event a2  user 90432  14:22:01
  event a3  user 88110  14:22:02
  ...
```

*What just happened:* twelve thousand individual crashes collapsed into one issue you can read, assign, and resolve once. The event count tells you severity; the user count tells you blast radius.

How does Sentry decide two events are "the same"? It computes a **fingerprint** - by default, derived from the exception type and the top frames of the stack trace. Same error type thrown from the same place becomes the same issue. This is the most important idea in the tool: get fingerprinting right and your dashboard is a clean list of distinct bugs; get it wrong and one bug splinters into hundreds of issues, or hundreds of bugs collapse into one. Phase 3 covers how to steer it.

> Issues have a lifecycle, not a delete button. You **resolve** an issue when you've shipped a fix. If a matching event arrives after that, Sentry **regresses** it - reopens it and flags that your fix didn't hold. That regression signal is one of the most useful things Sentry does, and you only get it because issues persist instead of being cleared like logs.

## Where Sentry sits in the bigger picture

Sentry is not a replacement for logs, metrics, or traces - it's the specialist for one question: *what exceptions are happening, and exactly why?* Metrics tell you error rates are up; Sentry tells you which error and which line. They're complementary, and the [observability-logs-metrics-traces](/guides/observability-logs-metrics-traces) guide maps how the three pillars fit together.

```text
Metrics   "error rate jumped from 0.1% to 4%"     ── what & how much
Logs      "here's the timeline of requests"        ── narrative
Sentry    "AttributeError at mail.py:42, 12k hits" ── the exact failure
```

*What just happened:* each tool answers a different question. You reach for Sentry the moment "something is throwing" becomes the thing you need to chase down.

**For builders:** Sentry is open source and can be self-hosted, but most teams use the hosted service because running the storage and processing pipeline yourself is real work. Either way the SDK and concepts are identical - what you learn here transfers.

```quiz
[
  {
    "q": "What is the difference between an event and an issue in Sentry?",
    "choices": [
      "An event is a warning; an issue is a crash",
      "An event is one occurrence; an issue is a group of similar events",
      "They are two names for the same thing",
      "An issue is older than an event"
    ],
    "answer": 1,
    "explain": "An event is a single crash occurrence. Sentry groups similar events (by fingerprint) into one issue so you see distinct bugs, not duplicates."
  },
  {
    "q": "What does Sentry use to decide that two events belong to the same issue?",
    "choices": [
      "The user's IP address",
      "The exact timestamp",
      "A fingerprint, by default from the error type and top stack frames",
      "The size of the request body"
    ],
    "answer": 2,
    "explain": "By default the fingerprint comes from the exception type and the top frames of the stack trace, so the same error from the same place groups together."
  },
  {
    "q": "What happens when a new matching event arrives for an issue you already resolved?",
    "choices": [
      "Sentry ignores it permanently",
      "Sentry deletes the issue",
      "Sentry regresses the issue, reopening it",
      "Sentry creates a brand-new unrelated issue"
    ],
    "answer": 2,
    "explain": "A resolved issue that sees a new matching event is regressed (reopened), signaling that the fix did not fully hold."
  }
]
```


---

# Capturing and reading an issue

You've got the mental model. Now the loop you'll actually live in: get the SDK reporting, watch crashes turn into issues, and make those issues rich enough that you can diagnose a bug without ever reproducing it. The goal of this phase is that when an issue lands in your inbox, you can fix it from the page in front of you.

## Step one: the DSN and one init call

Sentry knows which project an event belongs to because of a **DSN** - a URL that contains your project's public key. It looks like a secret but it's safe to ship in client code; it only grants permission to *send* events, not read them.

Wiring up the SDK is a single initialization call near the start of your program. Here's a Python service:

```python
import sentry_sdk

sentry_sdk.init(
    dsn="https://abc123@o12345.ingest.sentry.io/67890",
    environment="production",
    release="web@2026.06.30-a1b2c3",
    traces_sample_rate=0.0,   # error tracking only for now
)
```

*What just happened:* one `init` call installs hooks into the runtime so any uncaught exception is automatically captured and sent. You did not wrap your code in try/except - Sentry catches what bubbles all the way up. The `environment` and `release` fields tag every event, which matters enormously later.

The same shape holds in JavaScript, with the SDK loaded before the rest of your app:

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

Sentry.init({
  dsn: "https://abc123@o12345.ingest.sentry.io/67890",
  environment: "production",
  release: "web@2026.06.30-a1b2c3",
});
```

*What just happened:* same contract - initialize early, and uncaught errors plus unhandled promise rejections flow to Sentry on their own. The earlier this runs, the more crashes it can catch.

> Set `environment` deliberately and keep dev out of production. If your laptop sends events to the same project as production, your real signal drowns in noise from code you're actively breaking. A separate `environment` (or a separate project entirely) for development is the cheapest sanity you'll ever buy.

## Capturing on purpose

Uncaught exceptions report themselves, but sometimes you catch an error to handle it gracefully and still want Sentry to know it happened. That's an explicit capture:

```python
try:
    charge_card(order)
except PaymentError as err:
    show_user("Payment failed, please retry")
    sentry_sdk.capture_exception(err)   # handled, but still recorded
```

*What just happened:* the user got a clean message and your code kept running, but Sentry still recorded the full exception with its stack trace. You're not choosing between good UX and visibility - you get both.

You can also capture a message with no exception attached, for a "this shouldn't happen" branch:

```python
if cart.total < 0:
    sentry_sdk.capture_message("negative cart total", level="error")
```

*What just happened:* Sentry recorded an event even though nothing threw. Use this sparingly - it's for genuinely anomalous states, not as a logging replacement.

## Reading an issue: the anatomy

Open an issue and you're looking at a representative event plus aggregate stats. The parts that earn their keep:

```text
AttributeError: 'NoneType' object has no attribute 'email'

STACK TRACE
  app/views.py     line 88   signup_view(request)
  app/services.py  line 31   create_account(data)
  app/mail.py      line 42   send_welcome(user)   ← user is None

TAGS        environment=production  release=web@2026.06.30-a1b2c3
            browser=Chrome 126      server=web-07
CONTEXT     user.id=90431   request: POST /signup
BREADCRUMBS (10)  ...trail of what happened before the crash...
```

*What just happened:* the stack trace shows the call path with the failing frame highlighted; tags let you filter ("only Chrome?"); context carries the request and user; breadcrumbs reconstruct the lead-up. You're reading the crash scene, not guessing at it.

## Breadcrumbs: the trail before the crash

Breadcrumbs are the single feature that most often turns "I can't reproduce it" into "oh, that's why." They're a rolling log of recent activity - navigation, network calls, clicks, your own log statements - attached to whatever event fires next.

```text
14:21:58  navigation   /  →  /signup
14:21:59  ui.click     button#submit
14:22:00  http         POST /api/account  →  201
14:22:00  http         GET  /api/user/90431  →  404   ← lookup failed here
14:22:01  error        AttributeError ... mail.py:42
```

*What just happened:* the breadcrumbs reveal the actual cause - the user lookup returned 404, so `user` was `None` by the time `send_welcome` touched it. The exception is at line 42, but the bug is the failed lookup one step earlier. Most SDKs record these automatically; you can add your own for domain events.

## Tags and context: making issues searchable and diagnosable

There's a real distinction here. **Tags** are indexed key-value pairs you filter and group by - low-cardinality things like `plan`, `region`, `browser`. **Context** is rich structured data attached for reading, not searching - the full request body, feature flags, the order object.

```python
sentry_sdk.set_tag("plan", user.plan)          # searchable: "show me Pro crashes"
sentry_sdk.set_user({"id": user.id})           # who was affected
sentry_sdk.set_context("order", {              # readable detail on the event
    "id": order.id,
    "items": len(order.items),
    "total": order.total,
})
```

*What just happened:* you can now answer "is this only hitting Pro users?" with a tag filter, while the order context sits on every event for when you open one. The rule of thumb: tag what you'll filter by, set context for what you'll read.

> Resist tagging high-cardinality values like user IDs or full URLs with query strings. Tags are indexed, and a tag with a near-infinite set of values bloats storage and makes the UI sluggish. Identify the user with `set_user`; keep one-off detail in context.

**In the wild:** teams wire Sentry into their alerting and ticketing. A new issue can open a ticket automatically, post to a chat channel, and link back to the deploy - so the path from "it broke" to "someone's looking at it" is minutes, not the next morning.

```quiz
[
  {
    "q": "Is it safe to include the Sentry DSN in client-side JavaScript that ships to browsers?",
    "choices": [
      "No, the DSN is a secret that grants full account access",
      "Yes, the DSN only grants permission to send events, not read them",
      "Only if you encrypt it first",
      "Only for internal apps behind a VPN"
    ],
    "answer": 1,
    "explain": "The DSN is a public ingestion key. It lets clients send events but does not grant read access, so shipping it to browsers is expected."
  },
  {
    "q": "What are breadcrumbs in Sentry?",
    "choices": [
      "Permanent server-side audit logs",
      "A rolling trail of recent activity attached to the next event",
      "The list of resolved issues",
      "Stack frames from the standard library"
    ],
    "answer": 1,
    "explain": "Breadcrumbs are a rolling record of recent actions (navigation, network calls, logs) attached to whatever event fires next, reconstructing the lead-up to a crash."
  },
  {
    "q": "Which value is a poor choice for a Sentry tag?",
    "choices": [
      "The subscription plan (free/pro)",
      "The browser name",
      "The unique user ID",
      "The deployment region"
    ],
    "answer": 2,
    "explain": "Tags are indexed and meant for low-cardinality filtering. A unique user ID has near-infinite values; identify users with set_user instead."
  }
]
```


---

# Releases, source maps, and noise

The SDK is reporting and your issues are readable. This phase is the difference between a Sentry that's a daily tool and one your team mutes after a week. Three things decide that fate: knowing which deploy broke things, getting readable stack traces out of minified JavaScript, and keeping the signal-to-noise ratio high enough that an alert still means something.

## Releases: which deploy introduced this

A **release** is a version identifier you attach to every event - you saw it in the `init` call as `release="web@2026.06.30-a1b2c3"`. On its own it's a tag. Its power comes from telling Sentry *when each release was deployed*, so it can line up issues against your deploy history.

```bash
# create the release and mark it deployed (run in CI after a deploy)
sentry-cli releases new "web@2026.06.30-a1b2c3"
sentry-cli releases set-commits "web@2026.06.30-a1b2c3" --auto
sentry-cli releases finalize "web@2026.06.30-a1b2c3"
sentry-cli releases deploys "web@2026.06.30-a1b2c3" new -e production
```

*What just happened:* you registered the release, attached the commits in it, finalized it, and recorded the deploy. Sentry can now show an issue's "first seen" against your deploy timeline and frequently name the **suspect commit** - the change most likely to have introduced the bug.

The payoff on an issue page:

```text
Issue: TypeError: cannot read properties of undefined (reading 'name')
  First seen:  in release web@2026.06.30-a1b2c3   (deployed 2h ago)
  Regression:  was resolved in web@2026.06.29-f0e1d2
  Suspect commit:  a1b2c3  "refactor user profile loader"  - by dev@team
```

*What just happened:* instead of "something is broken," you have "this started with this deploy, here's the likely commit, here's who wrote it." That's the line between an hour of bisecting and a one-minute fix. The release field you set in phase 2 is what makes all of this possible - skip it and you lose the single most useful diagnostic Sentry offers.

## Source maps: un-minifying JavaScript traces

Front-end code ships minified. Without help, a production JavaScript error in Sentry looks like this:

```text
TypeError: undefined is not a function
  at app.min.js:1:48210
  at app.min.js:1:51992
```

*What just happened:* the stack trace points into a single minified line. It's true and completely useless - you can't map column 48210 to anything in your source.

A **source map** is the translation table from minified code back to your original files and line numbers. Upload your source maps to Sentry alongside the matching release, and it rewrites the trace:

```bash
sentry-cli sourcemaps upload \
  --release "web@2026.06.30-a1b2c3" \
  ./dist
```

*What just happened:* you uploaded the build's source maps tied to that exact release. Now the same error renders against your real code:

```text
TypeError: undefined is not a function
  at renderProfile   (src/profile/view.js:88:12)
  at handleClick     (src/profile/view.js:54:5)
```

*What just happened:* the trace is back in your source coordinates - real function names, real files, real lines. The catch that bites everyone: source maps are matched to events **by release**. If the `release` in your SDK init doesn't exactly match the release you uploaded maps for, Sentry can't connect them and your traces stay minified. Same string, both places.

> Upload source maps but do not deploy them publicly. Source maps reveal your original source. Upload them to Sentry in CI, then keep them out of your public web root so strangers can't reconstruct your code. Sentry only needs its own copy.

## Steering grouping when the fingerprint is wrong

Default fingerprinting is good, not perfect. Two failure modes:

- **One bug, many issues.** An error message contains a changing value - `User 90431 not found`, `User 88110 not found` - and each variant becomes its own issue.
- **Many bugs, one issue.** A generic wrapper like `RequestError` swallows distinct underlying failures into a single group.

You fix the first by giving Sentry a stable fingerprint so the varying part is ignored:

```python
sentry_sdk.init(dsn="...", before_send=collapse_user_not_found)

def collapse_user_not_found(event, hint):
    exc = event.get("exception")
    if exc and "not found" in str(event.get("message", "")):
        event["fingerprint"] = ["user-not-found"]   # one issue, not thousands
    return event
```

*What just happened:* every "user not found" variant now shares the fingerprint `["user-not-found"]`, collapsing into a single issue. `before_send` runs on every event before it leaves your process - it's also where you split an over-grouped issue, by adding a distinguishing value to the fingerprint instead.

## Keeping the noise down

An error tracker everyone has muted catches nothing. Two levers keep alerts meaningful.

First, drop events you don't care about - bot noise, browser-extension errors, expected exceptions - in the same `before_send`:

```python
def before_send(event, hint):
    exc = hint.get("exc_info")
    if exc and isinstance(exc[1], BrokenPipeError):
        return None          # returning None discards the event entirely
    return event
```

*What just happened:* returning `None` from `before_send` drops the event so it's never sent or stored. `BrokenPipeError` from a client hanging up isn't your bug - filtering it keeps the dashboard accurate.

Second, alert on **state changes**, not on every event. The useful triggers are "a *new* issue appeared" and "a resolved issue *regressed*" - not "an event happened," which fires constantly for known issues.

```text
GOOD alert rule:   notify when a NEW issue is created in production
GOOD alert rule:   notify when a RESOLVED issue regresses
NOISY alert rule:   notify on every event  ← everyone mutes this by day two
```

*What just happened:* the good rules fire only when something genuinely changed - a fresh bug or a fix that broke again - so the notification still means "look now." That's the entire game: an alert people trust enough to act on.

## One last gotcha: scrub the sensitive data

Sentry captures request bodies, headers, and local variables - which means passwords, tokens, and personal data can ride along into an event. Many SDKs scrub common fields by default, but treat that as a starting point, not a guarantee.

```python
def scrub(event, hint):
    req = event.get("request", {})
    if "Authorization" in req.get("headers", {}):
        req["headers"]["Authorization"] = "[Filtered]"
    return event
```

*What just happened:* the auth header is redacted before the event leaves your server. Decide what must never reach Sentry and strip it in `before_send` - it's far easier than explaining later why secrets ended up in your error tracker.

**In the wild:** the teams that get the most out of Sentry treat releases as non-negotiable in CI - every deploy creates a release, uploads source maps, and finalizes it automatically. Once that pipeline exists, "which deploy broke it and what was the exact line" stops being a question you have to investigate.

```quiz
[
  {
    "q": "Why might a production JavaScript error show a stack trace pointing into app.min.js with no real file names?",
    "choices": [
      "Sentry is down",
      "The matching source maps were not uploaded for that release",
      "The DSN is wrong",
      "Breadcrumbs are disabled"
    ],
    "answer": 1,
    "explain": "Source maps translate minified traces back to source. Without maps uploaded and matched by release, the trace stays in minified coordinates."
  },
  {
    "q": "What links uploaded source maps to the right events?",
    "choices": [
      "The user's email",
      "The file size",
      "The release identifier, which must match exactly",
      "The time of day"
    ],
    "answer": 2,
    "explain": "Source maps are matched to events by release. If the SDK's release string doesn't exactly match the uploaded maps' release, traces stay minified."
  },
  {
    "q": "What does returning None from a before_send hook do?",
    "choices": [
      "Resolves the issue",
      "Discards the event so it is never sent",
      "Marks the event as a regression",
      "Doubles the event's severity"
    ],
    "answer": 1,
    "explain": "Returning None from before_send drops the event entirely - useful for filtering out noise like expected exceptions before they're stored."
  }
]
```
