skills/sentry/SKILL.md
Interact with Sentry (EU region) — install SDK, triage issues, manage releases, upload source maps, CRUD alerts and projects. Use when user wants to track errors, add error monitoring, see recent issues, create a release, upload source maps, wire Sentry into an app, or manage alerts.
npx skillsauth add RonanCodes/ronan-skills sentryInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
CLI-first Sentry ops via the user API (EU region — de.sentry.io for most endpoints, ronan-connolly.sentry.io for org URL). Covers SDK install, issue triage, releases, source maps, and project/alert management.
/ro:sentry install [--tanstack|--node|--both] # wire SDK into current app
/ro:sentry issue list [--project <slug>] [--limit 20]
/ro:sentry issue get <issue-id>
/ro:sentry issue resolve <issue-id>
/ro:sentry release create <version> [--project <slug>]
/ro:sentry release finalize <version>
/ro:sentry sourcemaps upload <version> --dist <path>
/ro:sentry project list
/ro:sentry project create <slug> --platform javascript-react
/ro:sentry alert list [--project <slug>]
~/.claude/.env:
SENTRY_AUTH_TOKEN — all-access user auth token (scopes: alerts:, event:, member:, org:, project:, team:)SENTRY_ORG=ronan-connollySENTRY_URL=https://ronan-connolly.sentry.io/ (UI only)SENTRY_REGION_URL=https://de.sentry.io (API — EU region routing)sentry-cli for source-map uploads: pnpm add -D @sentry/cli (per-project) or brew install getsentry/tools/sentry-cliPersonal vs Simplicity (pick the right token). For ronanconnolly personal apps (souso, side projects) use
SENTRY_AUTH_TOKEN_RONAN(sntryu_…, added 2026-06). The bareSENTRY_AUTH_TOKENand_DATAFORCEvariants in~/.claude/.envresolve to the Simplicity/Dataforce org, do not pipe personal-app telemetry there. NOTE:SENTRY_AUTH_TOKEN_RONANis a user auth token (CLI / source maps / API), NOT a client DSN. Fetch or create the project's DSN via the API (GET ${SENTRY_REGION_URL}/api/0/projects/, then the project's/keys/endpoint) before wiringSentry.init.
Most API calls go to ${SENTRY_REGION_URL}/api/0/... (EU region). The non-region URL (sentry.io) works for some endpoints but returns 404 for others after the EU migration. Always use the region URL.
pnpm add @sentry/react
pnpm add -D @sentry/vite-plugin
Client — src/lib/sentry.ts:
declare const __APP_RELEASE__: string
if (typeof window !== "undefined" && import.meta.env.PROD) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
release: typeof __APP_RELEASE__ === "string" ? __APP_RELEASE__ : undefined,
sendDefaultPii: true, // safe for no-auth utility apps; flip off if PII inputs exist
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllInputs: true, // safe default; flip off only if you've audited all inputs
blockAllMedia: false,
}),
Sentry.feedbackIntegration({
colorScheme: "system",
showBranding: false,
// Ronan default: don't auto-inject the floating bottom-right widget.
// Attach to an in-footer button (see "Footer-attached feedback button"
// section below). Floating UI is noisy on marketing sites and easy
// to miss — footer trigger lives where users already look.
autoInject: false,
}),
],
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1, // ambient coverage for low-traffic; drop to 0.01 at scale
replaysOnErrorSampleRate: 1.0,
});
}
Defaults rationale: integrations array is the load-bearing line — replaysOnErrorSampleRate is a no-op without replayIntegration(), same for tracesSampleRate without browserTracingIntegration(). Skipping it is the most common reason "Sentry's wired but I see nothing."
feedbackIntegration is on by default for utility apps. It opens a one-shot form (name, email, description, optional screenshot) and creates a Sentry issue tagged as user feedback. For a no-auth side project this replaces the missing contact form. Drop it for apps that already have a richer in-app feedback path.
The Sentry SDK's default feedbackIntegration() auto-injects a floating "Report a Bug" button bottom-right. Looks tacked-on, easy to miss on a long page, and clashes with the visual language of marketing/portfolio sites.
Default for every Ronan project: set autoInject: false in the integration config (above) and attach the widget to a button you place in the site footer next to the copyright line. The button reads as part of the chrome; the floating widget reads as a vendor stamp.
Footer button (Astro example):
<!-- src/components/Footer.astro, in the bottom flex row alongside copyright -->
<button
type="button"
id="sentry-feedback-trigger"
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Icon name="lucide:megaphone" class="w-4 h-4" />
{t.footer.reportBug}
</button>
Wire-up in the Sentry init:
// after Sentry.init({...}) has resolved
const bindFeedback = () => {
const feedback = Sentry.getFeedback();
const trigger = document.getElementById("sentry-feedback-trigger");
if (feedback && trigger) feedback.attachTo(trigger);
};
bindFeedback();
// Re-bind on Astro view transitions / SPA routes — the previous trigger
// node gets swapped out and the prior `attachTo` reference goes stale.
document.addEventListener("astro:page-load", bindFeedback);
Translation strings: footer.reportBug → "Report a bug" (en) / "Bug melden" (nl) / equivalent for any other locale you ship.
Why bind on page-load: Astro's <ViewTransitions> (and Tanstack Start's client router) swap the body, which destroys the previous trigger element. Without re-binding, the button stops opening the widget after the first SPA navigation. Same pattern applies to any framework with client-side routing.
When PostHog is also in the app, link the two so a Sentry issue points at the matching PostHog session replay. Drop this snippet at the end of initSentry():
// after Sentry.init(...)
void linkPostHog(Sentry)
async function linkPostHog(Sentry: typeof import("@sentry/react")) {
try {
const { initPostHog, posthog } = await import("./posthog")
await initPostHog() // idempotent
const distinctId = posthog.get_distinct_id?.()
if (distinctId) Sentry.setUser({ id: distinctId })
const sessionUrl = posthog.get_session_replay_url?.()
if (sessionUrl) Sentry.setTag("posthog.session_url", sessionUrl)
} catch (err) {
console.warn("[sentry] posthog cross-link skipped", err)
}
}
get_session_replay_url exists on posthog-js ≥ 1.115. The tag becomes a clickable URL in the Sentry issue UI — one click jumps from "what broke" to "what was the user doing right before it broke." The linker is fire-and-forget so it doesn't block Sentry's own init.
Server (Cloudflare Workers) — src/lib/sentry-server.ts:
import * as Sentry from "@sentry/cloudflare";
export default Sentry.withSentry(
(env: CloudflareEnv) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0.1,
}),
handler, // your worker fetch handler
);
⚠️ TanStack Start caveat. TanStack Start sets main: "@tanstack/react-start/server-entry" in wrangler — a virtual module owned by the framework's vite plugin, so you can't point main at a custom entry without the dev double-init problem (lekkertaal #111). The proven pattern (Settle 2026-06-07; lekkertaal #112):
Recommended: wrap the synthesised worker entry with a build-only vite plugin. In a transform hook, intercept the virtual \0virtual:cloudflare/worker-entry module and rewrite its export default to wrap the handler: export default withSentry(optionsCallback, wrappedHandler). That same wrap is the natural place to bind request-scoped env/ctx (AsyncLocalStorage) and mint a per-request trace_id you tag every event with. Note @sentry/cloudflare has no bare init() — withSentry(optionsCallback, handler) is the only entry; the options callback runs per request and reads env.SENTRY_DSN, so an unset DSN no-ops cleanly until the secret is set. See Settle's vite.config.ts wrapCloudflareWorkerEntry + src/lib/server/sentry.ts.
🚨 MANDATORY with this wrap: strip the body/span integrations, or every POST hangs. withSentry's default httpServerIntegration reads the request body (request.clone().text()) on every non-GET request, and its per-request span flush() rides ctx.waitUntil — on workerd this couples the response stream to Sentry's flush, so POST server fns complete server-side but the browser fetch never sees EOF and await hangs forever (GETs are unaffected: the body capture early-returns). Symptom: buttons stuck on a busy state with the DB write already done. Fix, in the options callback: skipOpenTelemetrySetup: true + integrations: (defaults) => defaults.filter((i) => !['HttpServer','CloudflareFetch','Fetch'].includes(i.name)), keep tracesSampleRate: 0. Error capture + tags survive. (Settle #35, 2026-06-07 — it broke onboarding + every save.)
Fallback: lean on Cloudflare's observability.enabled: true for raw worker errors + per-route try/catch + Sentry.captureException in the handlers that matter (e.g. /api/og Satori rendering).
Vite plugin for source maps — vite.config.ts:
import { execSync } from "node:child_process";
import { sentryVitePlugin } from "@sentry/vite-plugin";
const release = process.env.VITE_RELEASE
|| (() => { try { return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim() } catch { return "dev" } })();
const sentryPlugin = process.env.SENTRY_AUTH_TOKEN
? sentryVitePlugin({
org: process.env.SENTRY_ORG ?? "ronan-connolly",
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
url: process.env.SENTRY_REGION_URL ?? "https://de.sentry.io",
release: { name: release },
sourcemaps: { filesToDeleteAfterUpload: ["**/*.map"] }, // strip maps from shipped bundle
})
: null;
export default defineConfig({
define: { __APP_RELEASE__: JSON.stringify(release) }, // shared with src/lib/sentry.ts
build: { sourcemap: "hidden" }, // NOT `true`: with the Sentry upload plugin + `wrangler deploy`, `true` emits .map files the plugin deletes post-upload, then wrangler ENOENTs on the missing maps. `'hidden'` uploads + strips cleanly, no map refs shipped (Settle 2026-06-07).
plugins: [/* ... */, ...(sentryPlugin ? [sentryPlugin] : [])],
});
Why gate on SENTRY_AUTH_TOKEN: local builds and any CI job without secrets (PRs from forks, etc.) would otherwise fail in the plugin's auth check. Skipping it cleanly is the right default.
__APP_RELEASE__ define: lets the client SDK pick up the same release tag the plugin uploads against, with no separate env wiring. Just declare const __APP_RELEASE__: string in any file that reads it.
CI env (GitHub Actions) for the deploy job's build step:
- name: Build (with Sentry source map upload)
run: pnpm build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ronan-connolly
SENTRY_PROJECT: <project-slug>
SENTRY_REGION_URL: https://de.sentry.io
Common mistake: putting the env vars on the wrangler deploy step instead of the pnpm build step. The plugin runs at build time; if it doesn't see the token then, sourcemaps never upload no matter what's set during deploy.
curl -s "${SENTRY_REGION_URL}/api/0/projects/${SENTRY_ORG}/${PROJECT_SLUG}/issues/?statsPeriod=24h&limit=20" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq '.[] | {id, title, level, count: .count, userCount, lastSeen, status}'
curl -s "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq '{title, culprit, platform, permalink, count, userCount, firstSeen, lastSeen}'
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"status": "resolved"}'
Valid statuses: resolved, unresolved, ignored.
The point of this loop is simple: never reprocess the same Sentry issue twice. When an issue has been turned into a GitHub issue, is being worked on, or is fixed, that state must live ON the Sentry item so the next scan skips it. A scan that re-surfaces an issue you already triaged is wasted work (and the night-shift will keep re-opening duplicate GH issues).
Fixing a Sentry issue is always test-driven: write a failing test that reproduces it FIRST, then fix to green. No Sentry fix ships without a regression test. Full rationale: canon/sentry-user-issues-tdd.md.
Triage issues from the product's go-live timestamp forward. Anything lastSeen before go-live is pre-launch noise — bulk-resolve it, don't file GH issues for it. A scan should filter on lastSeen at/after the launch cutoff.
# Cutoff is the product's go-live in UTC (ISO 8601). Example placeholder below.
GO_LIVE_UTC="2026-06-DDT15:00:00Z"
# Candidates to actually triage: lastSeen at/after go-live, still unresolved.
curl -s "${SENTRY_REGION_URL}/api/0/projects/${SENTRY_ORG}/${PROJECT_SLUG}/issues/?query=is:unresolved&statsPeriod=90d&limit=100" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq --arg cutoff "$GO_LIVE_UTC" '[.[] | select(.lastSeen >= $cutoff)] | .[] | {id, title, count, userCount, lastSeen}'
# Pre-go-live noise: lastSeen strictly before the cutoff — bulk-resolve these.
curl -s "${SENTRY_REGION_URL}/api/0/projects/${SENTRY_ORG}/${PROJECT_SLUG}/issues/?query=is:unresolved&statsPeriod=90d&limit=100" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq -r --arg cutoff "$GO_LIVE_UTC" '.[] | select(.lastSeen < $cutoff) | .id' \
| while read -r id; do
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/issues/${id}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"status": "resolved"}' >/dev/null
done
Show the user the count of pre-go-live issues before bulk-resolving (Safety note below).
When an issue is being WORKED ON, has been turned into a GitHub issue, or is RESOLVED, NOTE that on the Sentry item so a later scan skips it. Preferred order:
triaged / fix-in-progress / fixed-in <release>.resolvedInNextRelease — it stays closed for the current scan but reopens automatically if it recurs after the fix ships, which is exactly the regression signal you want. (Use plain resolved only when there's no upcoming release; use ignored only for genuine won't-fix noise.)ISSUE_ID=<sentry-issue-id>
GH_URL="https://github.com/<owner>/<repo>/issues/<N>"
# (1+2) Add a comment/note carrying the GH link + triage status.
# Endpoint: POST /api/0/issues/{id}/comments/ (field is `text`).
curl -s -X POST "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/comments/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"text\": \"Triaged → ${GH_URL} (status: fix-in-progress). TDD: failing regression test first, then fix.\"}"
# (3) Resolve in the next release so it reopens if it recurs after the fix ships.
# Endpoint: PUT /api/0/issues/{id}/ with status: resolvedInNextRelease.
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"status": "resolvedInNextRelease"}'
# (4) Assign it (assignedTo accepts "user:<id>", "user:<email>", or "team:<id>").
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"assignedTo": "user:[email protected]"}'
Issue status values: resolved, resolvedInNextRelease, unresolved, ignored. The status update and assignedTo can be sent in the same PUT body if you prefer one call:
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/issues/${ISSUE_ID}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"status": "resolvedInNextRelease", "assignedTo": "user:[email protected]"}'
A note alone keeps the Sentry item self-explanatory; the status keeps it out of the default is:unresolved scan. To re-check what's already triaged:
# Already triaged (won't show under is:unresolved): resolved-in-next-release set.
curl -s "${SENTRY_REGION_URL}/api/0/projects/${SENTRY_ORG}/${PROJECT_SLUG}/issues/?query=is:resolved&statsPeriod=90d" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" | jq '.[] | {id, title, status, statusDetails}'
For the souso product specifically:
souso, project id 4511600359178320.ronan-connolly, EU / de region (SENTRY_REGION_URL=https://de.sentry.io).SENTRY_AUTH_TOKEN_RONAN (the bare SENTRY_AUTH_TOKEN is the work Simplicity/Dataforce org — do not use it for souso). For souso hygiene, run the curls above with Authorization: Bearer ${SENTRY_AUTH_TOKEN_RONAN}.GO_LIVE_UTC of the form 2026-06-DDT15:00:00Z. Anything before that is pre-launch noise to bulk-resolve.Releases pair errors to deploys. Create one per deploy — the skill's release create runs on deploy (pairs well with /ro:cf-ship).
VERSION=$(git rev-parse --short HEAD)
# 1. Create release
curl -s -X POST "${SENTRY_REGION_URL}/api/0/organizations/${SENTRY_ORG}/releases/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"version\": \"${VERSION}\",
\"projects\": [\"${PROJECT_SLUG}\"],
\"refs\": [{\"repository\": \"${GH_OWNER:-$(gh repo view --json owner --jq .owner.login)}/${REPO}\", \"commit\": \"${VERSION}\"}]
}"
# 2. Upload source maps (via sentry-cli)
sentry-cli releases files "${VERSION}" upload-sourcemaps ./dist --url-prefix '~/'
# 3. Finalize
curl -s -X PUT "${SENTRY_REGION_URL}/api/0/organizations/${SENTRY_ORG}/releases/${VERSION}/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"dateReleased": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
sentry-cli honours SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_URL env vars — point SENTRY_URL to the region URL for uploads:
export SENTRY_URL=${SENTRY_REGION_URL}
curl -s "${SENTRY_REGION_URL}/api/0/organizations/${SENTRY_ORG}/projects/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq '.[] | {slug, name, platform, id}'
curl -s -X POST "${SENTRY_REGION_URL}/api/0/teams/${SENTRY_ORG}/${TEAM_SLUG}/projects/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "my-app",
"slug": "my-app",
"platform": "javascript-react"
}'
Grab the DSN from the response's keys[0].dsn.public — this is what goes in the app's SENTRY_DSN (per-app, .dev.vars + wrangler secret).
curl -s "${SENTRY_REGION_URL}/api/0/projects/${SENTRY_ORG}/${PROJECT_SLUG}/rules/" \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
| jq '.[] | {id, name, status, conditions: [.conditions[] | .name]}'
Creating alert rules via API is verbose (complex condition/action schemas) — the skill prefers the dashboard for creation and API for listing/auditing.
Global (~/.claude/.env):
SENTRY_AUTH_TOKEN — this skill's management APISENTRY_ORG=ronan-connollySENTRY_URL=https://ronan-connolly.sentry.io/ — for UI permalinks in outputSENTRY_REGION_URL=https://de.sentry.io — for all API callsPer-app (.dev.vars + wrangler secret):
SENTRY_DSN — client + server init. Generate via sentry project create or dashboardSENTRY_PROJECT — project slug (used by Vite plugin)VITE_SENTRY_DSNCI deploy job (GitHub Actions environment secrets):
SENTRY_AUTH_TOKEN — the plugin needs this on the build step, not the deploy stepSENTRY_ORG, SENTRY_PROJECT, SENTRY_REGION_URL — can be inlined in the workflow yaml since they're not secretsFor public open-source apps where forks shouldn't accidentally ship your DSN, expose the DSN via a /api/config endpoint and fetch it at first run instead of inlining at build time. The client SDK initialiser becomes async (await getRuntimeConfig() before Sentry.init), and the worker vars block in wrangler.jsonc reads from CI-provided --var SENTRY_DSN:"...". Tradeoff: one extra network call before Sentry is armed, so the very first error in a session may not be captured. Acceptable for utility apps; not for high-stakes flows.
Ronan's org is on the EU region (de.sentry.io). The UI URL (ronan-connolly.sentry.io) works in browser, but API calls must hit de.sentry.io or you get 404/403. SENTRY_REGION_URL captures this distinction.
SENTRY_AUTH_TOKEN has org-admin scope — NEVER ship it to the client or commit it. Server-only./ro:posthog — the other half of observability/ro:cf-ship — chain release creation + finalize into deploy pipelinecanon/sentry-user-issues-tdd.md — every Sentry / user-reported fix is test-driven (failing test first)testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".