Scan Sentry for new/regressed unresolved issues, dynamically map them to repos. Report only — no code changes.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add app-vitals/shipwright --skill error-scan --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Error Scan?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/app-vitals-error-scan)More formats (shields.io, HTML) on the badges page.
---
name: error-scan
description: Scan Sentry for new/regressed unresolved issues, dynamically map them to repos. Report only — no code changes.
---
# Error Scan
Query Sentry for currently-unresolved issues across the org, diff them against a local
ledger to find what's **new** or **regressed** since the last run, dynamically derive which
checked-out repo each issue's `service` tag belongs to, and write a structured report. This
skill makes **no code changes** — it reads and reports only. Use `/error-fix` to act on the
findings.
This skill hardcodes Sentry as the backend (no provider abstraction) — see
`planning/error-patrol/PLAN.md` for the rationale. Support for other error-reporting
backends is explicitly out of scope.
---
## Setup: Parse Arguments
Before starting, check if any flags were passed:
- `--summary` — print counts to stdout; skip writing `error-report.md`
- `--dry-run` — run the full scan (Sentry API calls, service→repo derivation, diffing) but
skip writing `error-report.md` **and** skip updating `state/error-patrol-ledger.json`.
Print everything that would have been written to stdout instead. Use this to validate the
skill end-to-end without mutating any state.
---
## Step 0: Preconditions
1. Confirm `SENTRY_ORG` and `SENTRY_AUTH_TOKEN` are set in the environment (`echo
"org=$SENTRY_ORG token_set=$([ -n "$SENTRY_AUTH_TOKEN" ] && echo yes || echo no)"`). If
either is unset or empty, print:
```
error-scan requires SENTRY_ORG and SENTRY_AUTH_TOKEN to be set in the environment. Skipping scan.
```
and stop. Do not write a report or touch the ledger.
2. Confirm `SHIPWRIGHT_REPO_DIR` is set (fall back to `$HOME/src` per the plugin default —
see `docs/configuration.md`). This is the directory containing whatever repo checkouts
the invoking agent happens to have — **never assume specific repo names live here.**
3. All Sentry API calls in the steps below use:
```bash
curl -sS -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" "https://sentry.io/api/0/..."
```
Never print `$SENTRY_AUTH_TOKEN` itself, never write it to any file, and never echo the
literal value of `$SENTRY_ORG` into a comment or log line that could get pasted somewhere
persistent — always reference these as their env var names in output and in this file.
---
## Step 1: Enumerate Sentry Projects (Dynamic — No Hardcoded Project ID)
1. Call:
```bash
curl -sS -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"https://sentry.io/api/0/organizations/$SENTRY_ORG/projects/"
```
2. This returns a JSON array of project objects. Extract each project's `slug` field (e.g.
via `jq -r '.[].slug'`). This is a paginated endpoint — Sentry returns a `Link` response
header with `rel="next"` / `results="true|false"`; follow pagination (via the `cursor`
query param from the `Link` header) until `results="false"` to get the full project list.
3. Record the full list of project slugs. This list is **derived at runtime** — do not
assume any particular project exists or hardcode a slug anywhere in this file or in your
working notes for this run.
4. If the projects list is empty, print `No Sentry projects found for org $SENTRY_ORG.` and
stop (nothing to scan).
---
## Step 2: Enumerate Distinct `service` Tag Values (Dynamic — No Hardcoded Service List)
For each project slug from Step 1, call the tag-values endpoint to discover which `service`
tag values are currently reporting:
```bash
curl -sS -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"https://sentry.io/api/0/projects/$SENTRY_ORG/$PROJECT_SLUG/tags/service/values/"
```
1. This returns a JSON array of tag-value objects; extract each `value` field (e.g. via `jq
-r '.[].value'`). Follow pagination the same way as Step 1 if present.
2. If a project has no `service` tag at all (404 or empty array), that's a valid result —
just means no events on that project carry a `service` tag. Move to the next project.
3. Union the `service` values across all projects into one deduplicated set — this is the
full list of "observed service tags" for this run. Do not hardcode any service name; this
set is entirely derived from the API responses above.
4. If the unioned set is empty across every project, print `No service tag values found
across any project.` and stop (nothing to map or diff).
---
## Step 3: Derive the Service → Repo Mapping
This mapping tells a downstream skill (`error-fix`) which repo checkout corresponds to each
service tag, so it knows where to look for the code that raised an issue. Getting this wrong
sends a future agent session into the wrong source tree, so this step is deliberately
conservative: **when in doubt, mark `unmapped` — never guess.**
**Do not hardcode any repo name anywhere in this step's logic.** The set of repos is
whatever happens to exist under `$SHIPWRIGHT_REPO_DIR` on this run — treat it as fully
dynamic, the same way the Sentry projects and service tags are dynamic.
1. List the repos currently checked out under `$SHIPWRIGHT_REPO_DIR`:
```bash
find "$SHIPWRIGHT_REPO_DIR" -mindepth 1 -maxdepth 1 -type d
```
Each top-level directory here is a candidate repo. If `$SHIPWRIGHT_REPO_DIR` doesn't
exist or is empty, treat every service tag from Step 2 as unmapped (there's nothing to
grep) and continue — this is not a fatal error, just an empty mapping.
2. For each service tag value `S` from Step 2, and for each candidate repo directory `R`
from step 1, grep `R` for the literal quoted string that would appear in a Sentry init
call site, e.g. `"S"` or `'S'` (grep for the tag value as an exact quoted literal, not a
substring match — a service value of `admin` should not match a file merely containing
the word "administrator"). Use something like:
```bash
grep -rn --include='*.ts' --include='*.js' --include='*.py' --include='*.go' --include='*.rb' \
-e "\"$S\"" -e "'$S'" "$R"
```
(adjust the `--include` extensions to whatever source languages are actually present in
`R` — don't assume TypeScript specifically).
3. For every match, inspect a small window of context around the matched line (a few lines
before/after — `grep -B3 -A1` or read the surrounding lines with the Read tool). Treat a
match as a **confident hit** only if that context also contains something that looks like
a Sentry init/config call site: the word `sentry` or `Sentry` **and** a `service` key
(e.g. `service:` or `service =` or `service:"..."`) appearing near the matched literal.
A bare string match with no nearby "sentry"/"service" signal is **not** confident — it's
just an incidental match and must be discarded, not counted as a hit for that repo.
4. After checking every candidate repo for service tag `S`, classify the result:
- **Exactly one repo has ≥1 confident hit** → map `S → that repo's directory name`.
Record it as `mapped`.
- **Zero repos have a confident hit** → `unmapped` (reason: `"no confident match"`).
- **More than one repo has a confident hit** → `unmapped` (reason: `"ambiguous — matched
in N repos"`, listing which repos matched). Do not pick one arbitrarily.
- A confident-looking match that doesn't clearly belong to init/config code (e.g. it's in
a test fixture, a comment, or generated/vendored code like `node_modules`,
`dist`, `build`, `vendor`) does not count — exclude these paths from consideration
before grepping if practical (e.g. add `--exclude-dir=node_modules
--exclude-dir=dist --exclude-dir=vendor` to the grep).
5. Build the mapping as a simple object, e.g.:
```json
{
"<observed service tag A>": { "status": "mapped", "repo": "<matched repo dir name>", "matchedAt": "<path/to/file>:<line>" },
"<observed service tag B>": { "status": "unmapped", "reason": "no confident match" }
}
```
(the keys and values above are placeholders illustrating the shape only — do not copy any
of them as literal defaults; every entry must come from this run's actual grep results
against the actual service tags and repos discovered in Steps 1-3.)
6. **This mapping is re-derived from scratch every run.** Never read a previously-persisted
mapping from the ledger and trust it as-is — Step 6 persists this run's freshly-derived
map for human visibility only, not as an input to this step.
---
## Step 4: Fetch Currently-Unresolved Issues
For each project slug from Step 1, fetch unresolved issues:
```bash
curl -sS -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"https://sentry.io/api/0/organizations/$SENTRY_ORG/issues/?query=is:unresolved&project=$PROJECT_ID"
```
Notes on this endpoint:
- The org-level issues endpoint takes a numeric `project` id (not slug) as a filter, or can
be queried without a `project` filter to get issues across every project the token can
see in one call — prefer the no-`project`-filter form (`.../organizations/$SENTRY_ORG/issues/?query=is:unresolved`)
if it returns results across all projects in this Sentry instance; fall back to per-project
calls (resolving each project's numeric `id` from the Step 1 response) only if the
org-wide call appears scoped to a single default project.
- Follow pagination via the `Link` header / `cursor` param as in Step 1, so large result sets
aren't silently truncated.
- For each issue, record at minimum: `id`, `shortId`, `title`, `culprit`, `permalink`,
`status` (should be `unresolved` given the query filter), `count` (total event count),
`userCount`, `firstSeen`, `lastSeen`, and the project's `slug`.
- For each issue, determine its `service` by reading the issue's `service` tag if present in
the list payload; if not present in the list response, fetch
`https://sentry.io/api/0/organizations/$SENTRY_ORG/issues/$ISSUE_ID/tags/service/` for that
issue to resolve it. If an issue has no `service` tag at all, record its service as `null`
(it will show up in the report as unmapped by definition — there's nothing to map).
---
## Step 5: Diff Against the Ledger
The ledger lives at `state/error-patrol-ledger.json` — **one level up from the repo
checkout, in agent workspace state, not tracked inside this repo** (same tier as
`state/entropy-patrol-last-run.json`; see that file for an example of this state tier's
location convention). Resolve its path relative to the repo root the same way
`state/entropy-patrol-last-run.json` sits relative to `repos/<repo>` — i.e. it is a sibling
of the repo checkouts, not a path inside the invoking repo's working tree.
1. If `state/error-patrol-ledger.json` does not exist yet, treat the ledger as empty
(`{"issues": {}, "lastRun": null, "serviceRepoMap": {}}`) — this is a normal first run,
not an error.
2. If it exists, read and parse it. Expected shape:
```json
{
"lastRun": "<ISO8601 timestamp of previous run>",
"issues": {
"<sentry_issue_id>": {
"status": "unresolved | resolved | ignored",
"count": <last-seen event count as of previous run>,
"lastSeen": "<ISO8601>"
}
},
"serviceRepoMap": { "<service>": { "status": "mapped|unmapped", "repo": "...", ... } }
}
```
3. For each unresolved issue fetched in Step 4, classify it against the ledger's `issues`
entry for that issue's `id`:
- **New**: no entry exists for this issue `id` in the ledger at all.
- **Regressed**: an entry exists, and *either* (a) the ledger's recorded `status` for
that issue was `resolved` or `ignored` (i.e. it was not unresolved last run, and is
unresolved now), *or* (b) the ledger's recorded `status` was already `unresolved` but
the current `count` is greater than the ledger's recorded `count` (i.e. it kept
firing — more events accumulated since last run). Document this precisely in the
report so a human reviewing it understands why an issue was flagged regressed.
- **Unchanged**: an entry exists, status was already `unresolved`, and current `count`
is not greater than the ledger's recorded `count`. Unchanged issues are **not** included
in the report body (only in the summary counts, if you choose to show a total).
4. Issues that exist in the ledger as `unresolved` but no longer appear in this run's
unresolved fetch (Step 4) have presumably been resolved/ignored since the last run —
note this for the ledger update in Step 6, but they do not need their own report section
(that's `error-resolve`'s concern, not this skill's).
---
## Step 6: Write the Report
If `--summary` or `--dry-run` was passed, skip writing the file — for `--dry-run`, print the
exact content that would have been written instead; for `--summary`, print only the counts
table from below.
Write `error-report.md` to the project root (overwrite if it exists — same convention as
`entropy-report.md`). Format:
```markdown
# Error Report
**Generated:** {YYYY-MM-DD HH:MM} {timezone}
**Org:** $SENTRY_ORG (read from env — value not repeated literally in this file)
**Projects scanned:** {count}
**Service tags observed:** {count}
## Summary
| | Count |
|---|---|
| New issues | N |
| Regressed issues | N |
| Unchanged (not shown below) | N |
| Unmapped service tags | N |
---
## New Issues
{If none: "No new issues since last run."}
{For each new issue, as a checkbox:}
- [ ] `{shortId}` — {title} _{service tag, or "unmapped" if no confident repo match}_
- Project: {project slug}
- Repo: {mapped repo dir name, or **UNMAPPED** with the reason from Step 3}
- Events: {count} · Users affected: {userCount}
- First seen: {firstSeen} · Last seen: {lastSeen}
- Link: {permalink}
---
## Regressed Issues
{If none: "No regressed issues since last run."}
{For each regressed issue, as a checkbox:}
- [ ] `{shortId}` — {title} _{service tag, or "unmapped"}_
- Project: {project slug}
- Repo: {mapped repo dir name, or **UNMAPPED** with the reason}
- Why flagged: {"was resolved/ignored, now unresolved" | "event count grew from {old} to {new}"}
- Events: {count} · Users affected: {userCount}
- Last seen: {lastSeen}
- Link: {permalink}
---
## Service → Repo Mapping (this run)
| Service tag | Status | Repo | Detail |
|---|---|---|---|
| {service} | mapped | {repo dir name} | matched at `{file:line}` |
| {service} | **unmapped** | — | {reason, e.g. "no confident match" or "ambiguous — matched in N repos"} |
_Any issue whose service tag is unmapped above is flagged **UNMAPPED** in its own entry too — `/error-fix` should treat these as needing manual repo identification, never a guessed repo._
---
_Run `/error-fix` to classify these issues and queue task-store tasks._
```
Rules:
- New issues and regressed issues are each sorted by `count` descending (highest event
volume first) within their section.
- Unchanged issues are not listed individually — only reflected in the summary count.
- Every issue entry always states its repo mapping status explicitly, even when mapped —
this makes it easy to spot-check the derivation without re-running the scan.
---
## Step 7: Persist the Derived Map + Update the Ledger
Skip this entire step if `--dry-run` was passed (dry runs must not mutate any state).
`--summary` alone does **not** skip this step — summary mode only affects report output, not
ledger persistence, since keeping the ledger current is what makes the *next* run's diff
correct.
1. Build the new ledger content:
```json
{
"lastRun": "<current UTC ISO-8601>",
"issues": {
"<sentry_issue_id>": {
"status": "unresolved",
"count": <current event count>,
"lastSeen": "<ISO8601 from the issue payload>"
}
// one entry per issue fetched in Step 4 (all currently-unresolved issues, not just new/regressed ones)
},
"serviceRepoMap": {
"<service tag>": { "status": "mapped", "repo": "<repo dir name>", "matchedAt": "<file:line>" }
// or: { "status": "unmapped", "reason": "<reason from Step 3>" }
// one entry per service tag observed in Step 2, from this run's fresh derivation
}
}
```
2. For any issue present in the *previous* ledger's `issues` map but absent from this run's
unresolved fetch (Step 4), you may either drop it or retain it with `"status": "resolved"`
— retaining it lets `error-resolve` observe the transition; prefer retaining with
`"status": "resolved"` over dropping, since dropping loses the "this used to be
unresolved" signal for no benefit.
3. Overwrite `state/error-patrol-ledger.json` with this new content (this is a full
replace, not an append — unlike `entropy-scan`'s quality log, which is append-only, this
ledger is a current-state snapshot).
4. Print: `Ledger updated: state/error-patrol-ledger.json`
**Important:** `serviceRepoMap` in the ledger is written **every run** from this run's fresh
Step 3 derivation — it is overwritten, never merged with the previous run's map, and it is
never read back in as an input to Step 3. Its only purpose is letting a human (or
`error-fix`) see what the last run inferred, without re-running the grep themselves.
---
## Step 8: Print Summary
Whether or not `--summary` was passed, always print a summary to stdout after the scan:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ERROR SCAN COMPLETE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Projects scanned: {N}
Service tags observed: {N}
Services mapped: {N}
Services unmapped: {N}
NEW {N} issues
REGRESSED {N} issues
─────────────────────
Unchanged (not reported): {N}
{If any new/regressed issues exist:}
Run /error-fix to classify these issues and queue task-store tasks.
{If zero new/regressed issues:}
✓ No new or regressed issues since last run.
{If --dry-run: "Dry run — no files written."}
{Else if --summary: "Summary only — error-report.md not written; ledger still updated."}
{Else: "Report written to: error-report.md"}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## Constraints (Do Not Violate)
- **No code changes.** This skill reads and reports only. The only files it writes are
`error-report.md` (project root) and `state/error-patrol-ledger.json` (agent workspace
state, one level up from repo checkouts) — and neither is written in `--dry-run` mode.
- **No git operations.** Do not commit, branch, or stage anything.
- **No PR creation.** `error-scan` never creates PRs or tasks — queueing issues as
task-store tasks belongs to `/error-fix`.
- **Read-only network calls.** Only `GET` requests against the Sentry API. Never call an
issue-mutating endpoint (resolve/ignore/delete) from this skill — that's `/error-resolve`'s
job.
- **No hardcoded org, project, service, or repo names.** `$SENTRY_ORG` is read from the
environment at runtime and never written literally into this file, into `error-report.md`,
or into the ledger. Sentry projects and `service` tag values are always enumerated via the
API. Repos are always enumerated by listing `$SHIPWRIGHT_REPO_DIR` at runtime — never
assume or name a specific repo directory in this skill's logic.
- **Never log or persist `$SENTRY_AUTH_TOKEN`.** Not in the report, not in the ledger, not
in stdout output.
- **Unmapped over guessed.** A service tag with zero confident repo matches, or matches in
more than one repo, is always reported as `unmapped` — never resolved to a "best guess"
repo. A wrong mapping here would send `/error-fix` to edit the wrong codebase.
- **The service→repo map is re-derived every run, never trusted from cache.** The map
persisted into the ledger is for human/`error-fix` visibility only — it is not read back
in as an input to a future run's Step 3.
- **One scan, one report.** Each run fully overwrites `error-report.md`. Previous results are
not preserved (the ledger, not old reports, is the historical record).
- **Ledger is a snapshot, not a log.** Unlike `entropy-scan`'s append-only quality log,
`state/error-patrol-ledger.json` is fully overwritten each run with current state.
- **`--dry-run` mutates nothing.** No report write, no ledger write — everything that would
be written is printed to stdout instead.
- **`--summary` skips only the report file**, not the ledger update — the ledger must stay
current so the next run's diff is correct regardless of which flag was used.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!