# Issues

> **For agents:** `search_issues(org="acme", project="web", status="unresolved", q="timeout", sort="last_seen", limit=10)` to find, `get_issue` for a summary, `get_fix_context` before fixing, `resolve_issue` / `ignore_issue` / `unresolve_issue` / `bulk_update_issues` to change status (`event:write`, audited), `get_issue_activity` for what people did to it, `get_issue_history` for how often it has actually happened.

## Grouping (fingerprint v2)

Each processed error event gets a deterministic fingerprint; events with the same fingerprint are one issue. In priority order:

1. **SDK fingerprint** (`event.fingerprint`) when set, with `{{ default }}`, `{{ transaction }}`, `{{ message }}`, and `{{ tag.<name> }}` expanded.
2. **Exception with stack trace** — for up to 5 chained exceptions (root cause first): the exception `type` plus the ordered frames, using `in_app` frames when any exist (otherwise all), capped at the last 25. Each frame contributes `module` (or the filename basename with build hashes such as `-8f3a9c1e` stripped) and the platform-normalised function name; one- or two-character minified names are dropped, direct recursion is collapsed. The exception **message is never included** — it carries ids, timestamps, and user data.
3. **Exception without a stack** — `type` + the normalised message (numbers, hex, UUIDs, emails, and URLs replaced by placeholders).
4. **Message events** — the `logentry.message` template (not the formatted string) + logger.
5. **Fallback** — platform + level + transaction.

`hash = sha256("v2" + material)[0:32]`. The version is part of the hash, so a future `v3` applies to new issues only; nothing existing splits.

**Title** is `Type: value` (truncated to 120 chars) or the message; **culprit** is the top in-app frame as `function (module)` or the transaction name.

## Status lifecycle

| Status | Meaning |
|---|---|
| `unresolved` | Default for new issues and for reopened ones |
| `resolved` | Marked fixed. Optionally *in* a release |
| `ignored` | Muted: no alerts, hidden from the default list |
| `regressed` | Was resolved, then reproduced — see below; alert rules can match on it |

Change status with `PATCH /v1/orgs/{org}/projects/{project}/issues/{shortId}` `{"status": "…"}`, or up to 100 at once with `POST …/issues/bulk` `{"shortIds": [42, 43], "status": "resolved"}` (bulk allows `unresolved`, `resolved`, `ignored`). Both need `event:write` and record who did it (user or token).

## Regressions

A new event for a `resolved` issue flips it to `regressed` and emits an `issue.regressed` alert signal. When the issue was resolved **in a release**, only events whose `release` is semver-newer than that release regress it; events from the same or an older release (a straggling old deploy) do not, and incomparable version strings never regress. The state lives in the per-project coordinator and is mirrored to the control database.

## Counts

`eventCount` is exact: it comes from the project coordinator's counters, flushed to the control database, and is what billing is derived from.

`userCount` is an **estimate** and is labelled as one everywhere it appears. Exact distinct users would mean keeping every user hash per issue, and the storage contract reserves exact counters for the numbers that must never be estimated. Instead a nightly job asks the analytics store for distinct user hashes per issue over the last 30 days and writes that number back. Under sampling it is a lower bound, and events with no identified user are excluded rather than collapsed into one phantom user. An issue seen for the first time today shows its estimate after the next nightly run.

## Search and listing

`GET /v1/orgs/{org}/projects/{project}/issues` accepts:

- `status` — one of the four statuses (omit for all).
- `q` — up to 100 characters, literal substring match on title or culprit (wildcards are escaped).
- `sort` — `last_seen` (default) or `first_seen`, newest first.
- `limit` — 1–100 (default 25), `cursor` — opaque, from `nextCursor`.

Counts in the list (`eventCount`, `userCount`) are exact. `userCount` is distinct salted user hashes, not raw identities.

## Inline summary

In the dashboard, the chevron on a row (or **Space** on the focused row) expands it in place without leaving the list. The summary is the latest stored event, fetched only when the row is opened, laid out as tabs:

- **Stack** — the exception chain, outermost first, with frames innermost first. Frames that carry source context show the surrounding lines with syntax highlighting and the failing line marked; frames with locals show them beneath. Library frames are hidden by default when in-app frames exist (**In-app only · N hidden** toggles them).
- **Breadcrumbs** — the timeline, newest first; a fetch that came back 4xx/5xx is marked *failed* whatever level it claimed.
- **Request** — method, URL, query, headers, body and env from the event's request.
- **Context** — tags as chips, then user, browser/OS/runtime/trace contexts and extra data.

Every tab keeps the same actions: **Fix with agent**, **Copy as Markdown**, **Open issue**. The issue page renders the same frame rows, so a stack reads identically in both places.

## Events

`GET …/issues/{shortId}/events` lists stored events for the issue (newest first, 50 per page, R2-backed); `GET …/events/{eventId}` returns the full processed body — scrubbed, symbolicated, with breadcrumbs, contexts, tags, request, and SDK info. Event ids are 32 hex characters.

## Fix context

`GET …/issues/{shortId}/fix-context` (tool `get_fix_context`) assembles, from the latest event:

- `event.exceptions[]` — type, value, and the last 5 in-app frames with `file`, `function`, `line`, `col`, the `context` line and 3 lines of `pre`/`post` context;
- `event.breadcrumbs` — the last 10, trimmed to category, message (200 chars), level; `event.symbolication` flags frames that could not be resolved;
- `release` — version, commit SHA and URL when registered;
- `issue` — title, culprit, level, status, first/last seen, `impact` (exact events, approximate users), deep link;
- `similarResolved` — issues of the same exception type this project already resolved, with their links.

It is one request and a few KB, which is why the `fix_issue` prompt calls it before anything else.

## Activity feed

`GET …/issues/{shortId}/activity` returns the last 50 entries: status changes with the actor (user email or token), regressions, and `mcp_action` rows written when an agent changes the issue, each carrying the tool name and token id.

## How long it has been happening

The chart on the issue page comes from Analytics Engine, which keeps **three months**. That answers "is this spiking now" and cannot answer "was this happening last spring" — and for a recurring issue that second question is the one that decides whether you are looking at a regression or at something that has been quietly failing all along.

`GET /v1/orgs/{org}/projects/{project}/issues/{shortId}/history?days=365` (tool: `get_issue_history`) answers it from the nightly rollup, which has one row per issue-day and is kept for **400 days** — thirteen months, so "this time last year" is in range.

```
365 days requested, 47 with activity since 2025-09-09, through 2026-09-08 · 12,904 events (estimated)
busiest day 2026-03-02: 2,110 events, ~430 users
```

Two things to know about the numbers:

- **They are estimates.** The rollup is derived from the sampled analytics index, so it carries that sampling with it; the response says `estimated: true` rather than leaving you to infer it. What the rollup adds is durability past the analytics window, not precision. Exact counts live where they must — the billing meter and the issue counters — and never come from here.
- **Today is not in it.** The rollup runs nightly, so the newest day is yesterday. `through` says which day the history actually reaches, rather than leaving a chart that always ends a day short with nothing explaining why.

A day with no activity has no row at all, so an issue that fired twice in a year returns two entries rather than 363 zeros.

## Resolving from a commit

`POST …/resolve-by-commit` (tool `resolve_issues_from_commit`) reads a commit message and resolves the issues its trailers close — `Fixes #42`, `Closes COMPAT-42` — recording the commit on each issue's activity feed. Wire it into CI on your default branch and merging a fix is what closes the issue.

A closing verb is required: `see #42` is a mention and resolves nothing. The [fix agent](https://docs.bugwatch.io/agents/fix-agent.md) writes that trailer into the pull requests it opens.

## Deduplication

The coordinator remembers event ids for 24 hours; a redelivered or replayed event does not create a second copy or increment counters twice.
