Sentry Triage
Purpose
Turn a release's unresolved Sentry crashes into actionable Linear tickets in one pass. The user ships a release, opens this skill, and gets back a list of Linear tickets - one per crash fingerprint - each carrying enough context (stack trace, suspect code, root-cause hypothesis from source reading, Sentry permalink, resolve footer) that picking up the fix is a single regular PR.
No proposed fix in the ticket. Triage is a fast survey across many clusters; the triager does not have enough implementation context to propose a fix that's actually right. Empirically the proposed-fix block in older versions of this skill was wrong nearly every time and biased the eventual implementer toward the wrong approach. The implementer reads the hypothesis + stack + source themselves and decides the fix with full context.
When to Use
- After a TestFlight or production release where Sentry shows fatal events
- When the user types
/do-sentry-triage(with or without args) - When the user says "triage crashes", "go through Sentry fatals", "what's broken in 0.X.Y"
- Any time the user wants a release's Sentry inbox turned into Linear work
Anytype iOS context (hardcoded constants)
The skill is project-scoped, so these don't need lookup at runtime:
- Sentry host:
sentry.anytype.io(self-hosted) - Organization slug:
anytype - Project slug:
ios - Linear team key:
iOS - Release name format:
- Production:
io.anytype.app@<version>+<build>(App Store) - Development:
io.anytype.app.dev@<version>+<build>(TestFlight nightly)
- Production:
Prerequisites (one-time)
- Sentry auth token from
https://sentry.anytype.io(User Settings -> Auth Tokens). Required scopes:org:read,project:read,project:releases,event:read,event:write. - MCP server wired at user scope:
Verify withclaude mcp add --scope user sentry -- npx -y @sentry/mcp-server@latest \ --access-token=$SENTRY_TOKEN --host=sentry.anytype.io/mcp-sentryshould be listed and connected. linctlauthenticated (linctl auth) - see.claude/skills/linear-developer/SKILL.md.
If the MCP isn't connected when the skill runs, stop and tell the user. The MCP tools we use are mcp__sentry__find_releases, mcp__sentry__list_issues, mcp__sentry__get_sentry_resource, mcp__sentry__update_issue.
Self-hosted Sentry quirks (these are not bugs - know them up front)
- Seer is unavailable:
analyze_issue_with_seerreturns 404 onsentry.anytype.io. Don't call it. The skill works fine on stack + source reading alone. ignoreMode='untilEscalating'resolves to "Forever": self-hosted maps the Sentry-Cloud-only ignore modes toForever. The issue still leaves the unresolved inbox, which is the goal. TheFixes <shortId>commit footer still auto-resolves on next release.assignedTofield is not used in this org. Don't read it, don't filter on it, don't mention it in tickets.
Invocation
| Form | Behavior |
|---|---|
/do-sentry-triage |
Latest production release in Sentry, fatal only |
/do-sentry-triage 0.46.0 |
Specific version, production env, fatal only |
/do-sentry-triage 0.46.0 development |
Same version, development env (TestFlight) |
/do-sentry-triage 0.46.0 --include-errors |
Fatal + error level |
/do-sentry-triage --include-errors |
Latest production, fatal + error |
Parse args off the user message. Default env is production. Default severity is level:fatal. --include-errors widens to level:[fatal,error].
Workflow
Step 1: Resolve the full release name
The full release name is what Sentry's release: query filter expects, e.g. io.anytype.app@0.46.1+6. Construct it:
- Env-based prefix:
- production ->
io.anytype.app@ - development ->
io.anytype.app.dev@
- production ->
- If the user gave a version with a build (e.g.
0.46.1+6): concatenate<prefix><version>+<build>and use that. - If the user gave only a version (
0.46.1): callmcp__sentry__find_releases(organizationSlug='anytype', projectSlug='ios', query='<prefix><version>')and pick the highest+Nbuild. - If the user gave nothing: call
mcp__sentry__find_releases(organizationSlug='anytype', projectSlug='ios')and pick the most recent release whose name starts with the env prefix.
If no matching release is found, stop and ask the user.
Step 2: Query unresolved crashes
mcp__sentry__list_issues(
organizationSlug='anytype',
projectSlugOrId='ios',
query='is:unresolved level:fatal release:<FULL_RELEASE_NAME>', # or level:[fatal,error]
sort='freq',
limit=20
)
Capture per cluster: shortId, permalink, title, culprit, level, firstSeen, lastSeen, count, userCount.
If empty, print No unresolved <severity> in <RELEASE> and exit.
If 20 come back, note "more may exist - re-run after handling these" in the final summary.
Step 3: Idempotency check (Linear-side)
Critical: Linear's team prefix is also IOS, so linctl issue search "IOS-93H" matches Linear's own ticket IOS-93H, not Sentry references. Search by Sentry permalink instead:
linctl issue search "issues/<shortId>" --json
Or use the full permalink. If a Linear ticket already references that permalink in its body, skip the cluster and record "already triaged" in the final summary.
Step 4: Investigate each remaining cluster
For each cluster:
Fetch full issue + latest event in one call:
mcp__sentry__get_sentry_resource(url=<permalink>). This returns the issue metadata, the latest event, and the full stack trace in one response. Do not uselist_issue_events- it returns summaries without stacks.Classify by where the crash happened:
- Swift / iOS-side: top frames in
Anytype/Sources/orModules/. Proceed to read source. - Middleware / Go-side: stack is entirely
github.com/anyproto/anytype-heart/.... Skip source-reading; the ticket exists to route to the middleware team. - Watchdog / no-stack:
mechanism: watchdog_terminationorStacktrace: No stacktrace available. No specific code site; ticket describes the symptom only. - Symbolication failure: hex addresses instead of names. Note dSYM upload status, recommend re-upload.
- Swift / iOS-side: top frames in
For Swift-side clusters: read source at the top app frame's
file:line(skipPods/, system frameworks,<compiler-generated>- walk up to the first app frame). Read ~30 lines for context. Walk up the stack as needed to follow the call chain.Form your hypothesis in one or two sentences. Be honest about uncertainty: "likely a force-unwrap when X is nil after Y" beats "the bug is on line 42".
Stop at the hypothesis. Do not write a proposed fix, a corrected snippet, or "the fix is to do X" guidance into the ticket. Triage is a fast survey; you don't have the implementation context to propose a fix that's actually correct, and a wrong proposal anchors the implementer toward the wrong approach (this was observed across ~15 fixes - the proposed-fix block was wrong nearly every time). The hypothesis + stack + source links are enough; the implementer reads them with full context and decides the fix themselves.
Step 5: Create the Linear ticket
linctl issue create --team iOS --title "<TITLE>" --description "<BODY>" --json
Do not pass --labels. The title prefix carries the categorization signal:
- Swift / iOS-side bug:
[Sentry] <error_type> in <top_app_frame> - Middleware / Go-side bug:
[Sentry][Middleware] <error_type> in <go_path>
Capture the returned IOS-XXXX identifier and Linear URL from the JSON output (it can be long; pipe through jq -r '.identifier' to extract just the ID).
Body must follow the template in the next section verbatim - same headings, same order. Same structure across tickets is what makes the inbox scannable when there are five at once.
Step 6: Archive the Sentry issue
mcp__sentry__update_issue(
organizationSlug='anytype',
issueId=<shortId>,
status='ignored',
ignoreMode='untilEscalating'
)
On self-hosted this resolves to Forever (see Quirks); the issue still leaves the inbox, which is the goal.
If the update fails (network, permission), continue without it - the Linear ticket already exists. Log the failure for the final summary.
Step 7: Final summary
Print a markdown table to chat:
| Linear | Sentry | Title | Events / Users | Hypothesis |
|--------|--------|-------|----------------|------------|
| IOS-6178 | IOS-59D | Spreadsheet selectItem OOB section | 293 / 10 | re-selection uses pre-snapshot indexPaths; clamp to new section count |
Always include a final line summarizing skips: clusters already triaged (Linear hit in Step 3), Sentry status-update failures, and whether the 20-cluster cap was hit.
Linear ticket template
Title (no labels - title prefix is the only categorization):
- Swift / iOS-side:
[Sentry] <error_type> in <top_app_frame> - Middleware / Go-side:
[Sentry][Middleware] <error_type> in <go_path>
Body for Swift / iOS-side bugs:
## Summary
<one or two sentence root-cause hypothesis - what likely went wrong, not how to fix it>
## Sentry data
- Issue: <permalink>
- Short ID: `<shortId>`
- First seen: <ISO timestamp>
- Last seen: <ISO timestamp>
- Affected release: <full release name>
- Environment: <production|development>
- Events: <count> / Users: <userCount>
- Top device: <device tag>
- Top OS: <os tag>
## Stack (app frames only)
\```
<top 5-10 frames inside Anytype/Sources or Modules, with file:line>
\```
---
**Resolve on merge**: include `Fixes <shortId>` in the fix commit. Sentry CI auto-resolves on the next release containing the commit.
Body for middleware / Go-side bugs - prepend a Routing block:
## Routing
**This crash is in Go middleware (`anytype-heart` v<X.Y.Z>), not iOS Swift code.** Please route to the middleware team.
## Summary
<symptom + Go-side hypothesis - what likely went wrong, not how to fix it>
## Sentry data
... (same fields as Swift template)
## Stack (Go runtime)
\```
github.com/anyproto/anytype-heart/<...>:line
\```
---
**Resolve on merge**: middleware fix lands in `anytype-heart`, then iOS bumps the middleware version. Include `Fixes <shortId>` in the bump commit message.
Body for watchdog / stackless events:
## Summary
<symptom - OOM, hang, etc.>. No stack available; this is a profiling task, not a code-fix task. The implementer will need to instrument and reproduce to find the underlying pressure source.
## Sentry data
... (same fields)
## Stack
`No stacktrace available` - <mechanism> events don't capture call stacks.
---
**Resolve on merge**: include `Fixes <shortId>` in any commit that meaningfully reduces the underlying pressure (memory, main-thread blocking, etc.).
The Fixes <shortId> footer is load-bearing - the auto-resolve loop only works if the commit message contains it exactly. Use the Sentry short ID (e.g. IOS-93H), not the Linear ID.
Idempotency
Re-running the skill on the same release is safe:
- Step 6 sets handled clusters to ignored, so the next run's
is:unresolvedquery won't return them. - Step 3 cross-checks Linear by Sentry permalink before any expensive work. (Don't search by short ID - Linear's
IOS-prefix collides with Sentry's.)
Edge cases
- Crash inside Go middleware: stack is entirely
github.com/anyproto/anytype-heart/.... Use the[Sentry][Middleware]title prefix and the routing block. Don't read iOS source. - WatchdogTermination / no stack: use the watchdog body template. Describe the symptom only - don't fabricate a fix or prescribe specific instrumentation steps.
- Symbolication failure: hex addresses instead of names. Mention dSYM upload status in the ticket.
- Same fingerprint, different versions: Sentry groups by fingerprint across releases. The release filter narrows to the current one, but
firstSeenmay predate it. Call that out so the user knows it's recurring, not a regression. - Top frame in
<compiler-generated>: Swift trap fromArray._checkIndex,range,Optional.unsafelyUnwrapped. Walk up the stack to the first app frame; that's the call site. - No fatal events found: print "No unresolved fatals in " and exit. Don't widen severity automatically; that's what
--include-errorsis for. - Sentry MCP times out: stop, surface the error, suggest
/mcpand checking the token.
What this skill does NOT do
- Write code or open PRs - only triage tickets
- Resolve Sentry issues - that happens automatically on the next release that contains a
Fixes <shortId>commit, via the existing sentry-cli pipeline - Run on a schedule - interactive only
- Triage warnings or non-crash events unless
--include-errorsis passed - Use Linear labels - title prefix carries the categorization
- Read or filter on the Sentry
assignedTofield - it's not used by this team
Related skills
linear-developer- linctl command reference for ticket creationconfidence-check- run before implementing the fix, not during triageios-dev-guidelines- context when reading Swift source to form a hypothesis