name: sentry-triage
description: Triage Sentry crashes for an iOS release. Pulls unresolved fatal events from sentry.anytype.io, investigates each fingerprint cluster against the source, creates one Linear ticket per cluster with a root-cause hypothesis (no proposed fix - the implementer figures that out with full context), then archives the Sentry issue (status ignored) so it stops cluttering the inbox. Activate on "triage Sentry crashes", "triage fatal errors", "investigate crashes in release", "fatal errors in 0.X.Y", or any time the user wants to turn a release's Sentry inbox into actionable Linear tickets. The slash entry is /do-sentry-triage.
user-invocable: false
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