@ssheleg/sheleg-dev 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.md +4 -3
- package/SECURITY.md +13 -12
- package/package.json +4 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +7 -3
- package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +259 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/releases.md +194 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +170 -0
- package/plugins/sheleg-dev/skills/error-tracking/references/setup.md +176 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.9.1 — 2026-08-24
|
|
8
|
+
|
|
9
|
+
### The skill was wrong at the exact place its author then tripped
|
|
10
|
+
|
|
11
|
+
`error-tracking` shipped telling readers to take the release version from
|
|
12
|
+
`HEROKU_SLUG_COMMIT`. Using it on a real project the same evening produced the
|
|
13
|
+
failure it exists to prevent: that variable is **not a config var** — it is dyno
|
|
14
|
+
metadata, injected only when the `runtime-dyno-metadata` labs feature is on, and
|
|
15
|
+
absent in silence otherwise. With the feature off, the SDK reported no release at
|
|
16
|
+
all while Sentry held a release with a deploy nothing would ever attach to.
|
|
17
|
+
Nothing errored.
|
|
18
|
+
|
|
19
|
+
Two more measurements from the same hour: `heroku config:get` never prints it,
|
|
20
|
+
even when it is working, because it is never a config var; and enabling the
|
|
21
|
+
feature takes effect on the next **release**, not on a restart.
|
|
22
|
+
|
|
23
|
+
`references/releases.md` now names the trap, gives the three commands that tell
|
|
24
|
+
you which state you are in, and recommends not depending on the variable alone —
|
|
25
|
+
set `RELEASE` from the deploy, which needs no platform feature and cannot drift
|
|
26
|
+
because one command produces both sides.
|
|
27
|
+
|
|
28
|
+
Also added, from the same mistake one level up: create the Sentry release for the
|
|
29
|
+
**deployed** commit, not for local HEAD. They differ the moment commits are
|
|
30
|
+
pushed but not deployed, and the result is a release nobody's events belong to.
|
|
31
|
+
|
|
32
|
+
## v0.9.0 — 2026-08-24
|
|
33
|
+
|
|
34
|
+
### The pack that wires integrations had nothing about knowing when they break
|
|
35
|
+
|
|
36
|
+
Six skills covered taking money, tracking conversions, signing people in and
|
|
37
|
+
page speed. None covered the layer that says any of it stopped working: a `grep`
|
|
38
|
+
for `sentry` across the whole family returned two incidental mentions.
|
|
39
|
+
|
|
40
|
+
`error-tracking` is written from a measured baseline rather than an imagined
|
|
41
|
+
one. An agent solving this exact task without the skill, in one session, made
|
|
42
|
+
four failures that were recorded as they happened:
|
|
43
|
+
|
|
44
|
+
- **Called "get a DSN" a manual step needing an account.** The account existed
|
|
45
|
+
and the CLI was already authenticated. Error tracking stayed off for no reason.
|
|
46
|
+
- **Wrote a `before_send` scrubber without knowing `EventScrubber` exists** and
|
|
47
|
+
runs by default — so it could not say which layer covered what, or whether its
|
|
48
|
+
own code was redundant or load-bearing.
|
|
49
|
+
- **Did not know the MCP is OAuth**, and would have declared it in the machine's
|
|
50
|
+
shared gateway, where an OAuth flow cannot complete.
|
|
51
|
+
- **Would have set a DSN and stopped**, leaving every issue attached to a release
|
|
52
|
+
with no commits and suspect-commit attribution permanently dead.
|
|
53
|
+
|
|
54
|
+
### The measurement the skill is built on
|
|
55
|
+
|
|
56
|
+
`sentry-sdk` 2.19.2, run 2026-08-24. The built-in `EventScrubber` is enabled by
|
|
57
|
+
default and matches on KEY NAMES against a 32-entry denylist. It does not inspect
|
|
58
|
+
string values, and neither `dsn` nor `database_url` is on that list.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
event = {"message": "could not connect to postgresql://u:SUPERSECRET@host/db"}
|
|
62
|
+
EventScrubber().scrub_event(event)
|
|
63
|
+
assert "SUPERSECRET" in str(event) # passes — not scrubbed
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A service that writes a database URL near an error therefore starts forwarding
|
|
67
|
+
that password to a third party the moment Sentry is added, with wider reach than
|
|
68
|
+
the log had. The skill ships both layers — and, more usefully, the test that
|
|
69
|
+
asserts layer one alone still leaks, so the day Sentry starts scrubbing values a
|
|
70
|
+
red test says the second layer may now be redundant.
|
|
71
|
+
|
|
72
|
+
### Also in this release
|
|
73
|
+
|
|
74
|
+
- The 403 that reads as an auth failure and is not: `allowMemberProjectCreation:
|
|
75
|
+
false` blocks `project create` **even for an org owner**, because the policy is
|
|
76
|
+
evaluated against the token's scopes and the device-flow token carries no
|
|
77
|
+
`org:write`. The team-scoped endpoint fails identically. Measured, with the
|
|
78
|
+
three ways out ranked.
|
|
79
|
+
- Two different tools are called `sentry`. Commands from one silently do not
|
|
80
|
+
exist in the other, and they install into prefixes where the older copy tends
|
|
81
|
+
to win — 0.38.0 shadowing 0.43.0, observed here. The shadow check is one line.
|
|
82
|
+
- `$schema` was missing from `plugin.json` and from the marketplace entry. Added.
|
|
83
|
+
|
|
7
84
|
## v0.8.0 — 2026-08-20
|
|
8
85
|
|
|
9
86
|
**The self-test that proved the money invariants was measuring one thing and claiming
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Part of the [ssheleg skill family](https://github.com/ssheleg/sshlg-skills).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## The
|
|
10
|
+
## The seven skills
|
|
11
11
|
|
|
12
12
|
| Skill | Answers |
|
|
13
13
|
|---|---|
|
|
@@ -17,6 +17,7 @@ Part of the [ssheleg skill family](https://github.com/ssheleg/sshlg-skills).
|
|
|
17
17
|
| **`google-signin`** | how do I let people sign in with Google without handing someone their account |
|
|
18
18
|
| **`google-auth`** | how does my *server* authenticate to Google — OAuth, ADC, service accounts, federation |
|
|
19
19
|
| **`frontend-performance`** | why is the Lighthouse score bad and which fix actually moves it |
|
|
20
|
+
| **`error-tracking`** | how do I know it broke, without mailing my own credentials to Sentry |
|
|
20
21
|
|
|
21
22
|
Each carries its own references and loads them only when the work reaches them.
|
|
22
23
|
|
|
@@ -89,7 +90,7 @@ a Lighthouse score.
|
|
|
89
90
|
/plugin install sheleg-dev@sheleg-dev
|
|
90
91
|
```
|
|
91
92
|
|
|
92
|
-
**npm installer** — copies all
|
|
93
|
+
**npm installer** — copies all seven skills into `~/.claude/skills/`:
|
|
93
94
|
|
|
94
95
|
```bash
|
|
95
96
|
npx @ssheleg/sheleg-dev
|
|
@@ -159,7 +160,7 @@ settings, by design.
|
|
|
159
160
|
with the plugin and the hook is live as soon as the plugin is enabled. Nothing further
|
|
160
161
|
to do, and no per-hook switch: enablement is the whole control.
|
|
161
162
|
- **Installed by `npx @ssheleg/sheleg-dev`, `install.sh` or `npx skills add`** — those
|
|
162
|
-
copy the
|
|
163
|
+
copy the seven skills into `~/.claude/skills/` and **carry no hook**. The gate is prose
|
|
163
164
|
again until you register it yourself, in `~/.claude/settings.json` or a project's
|
|
164
165
|
`.claude/settings.json`:
|
|
165
166
|
|
package/SECURITY.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Security
|
|
2
2
|
|
|
3
|
-
`sheleg-dev` is
|
|
3
|
+
`sheleg-dev` is seven skills about the layer a product reaches once it has users.
|
|
4
4
|
Five of them touch other people's money or identity — Stripe billing, crypto
|
|
5
5
|
payments, ad and conversion tracking, Google sign-in, server-side Google auth —
|
|
6
|
-
|
|
6
|
+
the sixth is front-end performance and the seventh is error tracking. Money is the
|
|
7
|
+
reason this document
|
|
7
8
|
has to be exact about the pack itself.
|
|
8
9
|
|
|
9
10
|
Everything below is checkable from a clone, and the commands at the end are the
|
|
@@ -16,8 +17,8 @@ documents name that does not exist here.
|
|
|
16
17
|
|
|
17
18
|
## What ships, and what of it executes
|
|
18
19
|
|
|
19
|
-
`git ls-files plugins` returns **
|
|
20
|
-
files of the manual gate, and 18 files of money fixtures.** Two of the
|
|
20
|
+
`git ls-files plugins` returns **56 files: 34 markdown, one plugin manifest, three
|
|
21
|
+
files of the manual gate, and 18 files of money fixtures.** Two of the seven skills now
|
|
21
22
|
ship code you can run — the assertion packs under `fixtures/` — and that is a change
|
|
22
23
|
from every release before v0.7.0, so it is stated here rather than left to be noticed.
|
|
23
24
|
They run only when you invoke them, read only the JSON fixtures beside them, and open
|
|
@@ -26,7 +27,7 @@ no socket; the greps below prove all three.
|
|
|
26
27
|
| Component | Count | Runtime behavior |
|
|
27
28
|
|---|---|---|
|
|
28
29
|
| `SKILL.md`, one per skill | 6 | Text. Read by the agent, executes nothing. |
|
|
29
|
-
| `references/` files, loaded on demand |
|
|
30
|
+
| `references/` files, loaded on demand | 25 | Text. Same. |
|
|
30
31
|
| `plugins/sheleg-dev/.claude-plugin/plugin.json` | 1 | Manifest read by Claude Code. |
|
|
31
32
|
| `plugins/sheleg-dev/hooks/hooks.json` | 1 | Hook manifest read by Claude Code. Declares one `PreToolUse` hook and no `if` filter. |
|
|
32
33
|
| `plugins/sheleg-dev/hooks/money-gate.js` | 1 | Runs on a tool call, when the plugin is enabled. Requires `path` and its own decision module — nothing else. Reads stdin, writes one JSON line, exits 0 on every path. |
|
|
@@ -38,7 +39,7 @@ no socket; the greps below prove all three.
|
|
|
38
39
|
| `plugins/sheleg-dev/skills/*/fixtures/manifest.json` and its `README.md` | 4 | Text. The invariant-to-fixture-to-document map, which `test/validate.py` reads. |
|
|
39
40
|
|
|
40
41
|
The published tarball is `bin/` and `plugins/` plus `README.md`, `CHANGELOG.md`,
|
|
41
|
-
`SECURITY.md`, `LICENSE` and `package.json` —
|
|
42
|
+
`SECURITY.md`, `LICENSE` and `package.json` — 62 files, listed by
|
|
42
43
|
`npm pack --dry-run`. `install.sh`, `test/`, `docs/` and `CONTRIBUTING.md` are **not**
|
|
43
44
|
in it; `test/validate.py` now refuses a path these shipped documents name that the
|
|
44
45
|
tarball does not carry, because resolving in a clone is the wrong question for a
|
|
@@ -59,11 +60,11 @@ nothing but the CLI you typed.
|
|
|
59
60
|
|
|
60
61
|
## What the installers read and write
|
|
61
62
|
|
|
62
|
-
Both do one thing: copy the
|
|
63
|
+
Both do one thing: copy the seven skill directories out of the package into
|
|
63
64
|
`~/.claude/skills/<name>`.
|
|
64
65
|
|
|
65
66
|
- **Read:** only files inside the package.
|
|
66
|
-
- **Write:** only `~/.claude/skills/` —
|
|
67
|
+
- **Write:** only `~/.claude/skills/` — seven directories, named for the seven skills.
|
|
67
68
|
Nothing else on the filesystem, nothing outside `$HOME`.
|
|
68
69
|
- **Network: none.** Neither file opens a socket or resolves a hostname.
|
|
69
70
|
`bin/sheleg-dev.js` requires `fs`, `path` and `os` and nothing else — no
|
|
@@ -75,7 +76,7 @@ Both do one thing: copy the six skill directories out of the package into
|
|
|
75
76
|
|
|
76
77
|
**One destructive difference between the two installers.** `bin/sheleg-dev.js`
|
|
77
78
|
skips a skill that is already installed and says so, unless you pass `--force`
|
|
78
|
-
(`bin/sheleg-dev.js:45-46`). `install.sh` does not ask: for each of the
|
|
79
|
+
(`bin/sheleg-dev.js:45-46`). `install.sh` does not ask: for each of the seven names
|
|
79
80
|
it runs `rm -rf` on the destination and re-copies, every time
|
|
80
81
|
(`install.sh:21-22`). If you have hand-edited an installed skill, the shell
|
|
81
82
|
installer deletes your edits and the node installer does not.
|
|
@@ -140,7 +141,7 @@ environment variables, never in source and never in logs:
|
|
|
140
141
|
|
|
141
142
|
## Where the real risk is
|
|
142
143
|
|
|
143
|
-
Not the bytes — the advice. These
|
|
144
|
+
Not the bytes — the advice. These seven skills describe Stripe, Heleket and BTCPay,
|
|
144
145
|
Google Sign-In, Workload Identity Federation, four ad platforms and browser
|
|
145
146
|
defaults, and every one of those changes. **A claim here that a provider has since
|
|
146
147
|
changed is a security defect in this pack**, not merely a stale doc, and it is the
|
|
@@ -168,7 +169,7 @@ git clone https://github.com/ssheleg/sheleg-dev && cd sheleg-dev
|
|
|
168
169
|
# self-describing documents name -- this file included.
|
|
169
170
|
python3 test/validate.py
|
|
170
171
|
|
|
171
|
-
# The shipped payload:
|
|
172
|
+
# The shipped payload: 56 files.
|
|
172
173
|
git ls-files plugins
|
|
173
174
|
|
|
174
175
|
# 22 lines: the plugin manifest, the three files of the manual gate, and the 18
|
|
@@ -205,6 +206,6 @@ grep -nE "require|child_process|exec|spawn|fetch|socket|rm -rf|cp -R|copyFileSyn
|
|
|
205
206
|
# Live-key shapes across the skill payload: one line, and it is a placeholder.
|
|
206
207
|
grep -rnE "sk_live_[A-Za-z0-9]|rk_live_[A-Za-z0-9]|whsec_[A-Za-z0-9]{8}|BEGIN [A-Z ]*PRIVATE KEY" plugins
|
|
207
208
|
|
|
208
|
-
# What npm publishes, and nothing else:
|
|
209
|
+
# What npm publishes, and nothing else: 62 files.
|
|
209
210
|
npm pack --dry-run
|
|
210
211
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/sheleg-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Six integration skills for the layer a product reaches once it has users: Stripe subscription billing, crypto payments, ad and conversion tracking under Consent Mode v2, Google sign-in and the wider Google auth surface, and Core Web Vitals. This package is the installer CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sheleg-dev": "bin/sheleg-dev.js"
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"skill"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"test": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js"
|
|
46
|
+
"test": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js",
|
|
47
|
+
"test:negatives": "python3 test/negatives.py",
|
|
48
|
+
"test:all": "python3 test/validate.py && node test/moneygate_test.js && node test/fixtures_test.js && python3 test/negatives.py"
|
|
47
49
|
}
|
|
48
50
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-plugin.json",
|
|
2
3
|
"name": "sheleg-dev",
|
|
3
4
|
"displayName": "Sheleg Dev",
|
|
4
|
-
"description": "The integration layer a product needs once it has users: Stripe subscription billing reconciled into your own database, crypto payments that survive over-payment and duplicate webhooks, ad and conversion tracking under Consent Mode v2, Google sign-in with the account-linking guards, the wider Google auth surface, and Core Web Vitals work that moves the score.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"description": "The integration layer a product needs once it has users: Stripe subscription billing reconciled into your own database, crypto payments that survive over-payment and duplicate webhooks, ad and conversion tracking under Consent Mode v2, Google sign-in with the account-linking guards, the wider Google auth surface, and Core Web Vitals work that moves the score, and Sentry error tracking wired so it does not forward your own credentials to a third party and so a stack trace names the commit that caused it.",
|
|
6
|
+
"version": "0.9.1",
|
|
6
7
|
"author": {
|
|
7
8
|
"name": "ssheleg",
|
|
8
9
|
"url": "https://x.com/sshlg93"
|
|
@@ -23,6 +24,9 @@
|
|
|
23
24
|
"oauth2",
|
|
24
25
|
"core-web-vitals",
|
|
25
26
|
"claude-code",
|
|
26
|
-
"cursor"
|
|
27
|
+
"cursor",
|
|
28
|
+
"sentry",
|
|
29
|
+
"error-tracking",
|
|
30
|
+
"observability"
|
|
27
31
|
]
|
|
28
32
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: error-tracking
|
|
3
|
+
description: >-
|
|
4
|
+
Use when wiring error tracking into a product — adding Sentry to a service, deciding what a
|
|
5
|
+
DSN is and where it may live, keeping secrets out of events before they reach a third party,
|
|
6
|
+
tying releases to commits so a stack trace names the change that caused it, or judging whether
|
|
7
|
+
a green health check means anything. Covers the Sentry SDK options that matter for a backend,
|
|
8
|
+
the two different tools both called sentry, the auth-token taxonomy and which kind can create
|
|
9
|
+
a project, and the MCP server with the config channel it belongs in. Triggers - "add Sentry",
|
|
10
|
+
"set up error tracking", "SENTRY_DSN", "sentry-cli", "sentry mcp", "scrub secrets from
|
|
11
|
+
errors", "release tracking", "suspect commits", "why is Sentry empty", "подключить Sentry",
|
|
12
|
+
"трекинг ошибок", "секреты в Sentry", "релизы в Sentry", "наблюдаемость". NOT for judging an
|
|
13
|
+
agent's trajectory (agent-evals), triaging tickets in a tracker (triage-issue), or choosing an
|
|
14
|
+
uptime vendor.
|
|
15
|
+
license: MIT
|
|
16
|
+
metadata:
|
|
17
|
+
version: 0.9.1
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# error-tracking — Sentry wired so it does not leak, and so a stack trace names a commit
|
|
21
|
+
|
|
22
|
+
Adding Sentry is four lines of SDK setup, which is why it is usually done badly.
|
|
23
|
+
The four failures below were all observed, on this machine, in one session, by an
|
|
24
|
+
agent working without this skill.
|
|
25
|
+
|
|
26
|
+
| Observed failure | What it cost |
|
|
27
|
+
|---|---|
|
|
28
|
+
| Called "get a DSN" a manual human step needing an account | The account existed and the CLI was already authenticated. Error tracking sat switched off for no reason |
|
|
29
|
+
| Wrote a `before_send` scrubber without knowing `EventScrubber` exists and runs by default | Could not say which layer covered what, so could not say whether the code was redundant or load-bearing |
|
|
30
|
+
| Did not know the MCP is OAuth | Would have declared it in the machine's shared gateway, where an OAuth flow cannot complete |
|
|
31
|
+
| Would have set a DSN and stopped | Every issue lands on a release with no commits; suspect-commit attribution never works |
|
|
32
|
+
|
|
33
|
+
## The one that actually leaks
|
|
34
|
+
|
|
35
|
+
**Sentry's built-in scrubber matches KEY NAMES. It does not look inside string
|
|
36
|
+
values.** A credential embedded in a URL — inside a message, an exception value,
|
|
37
|
+
a breadcrumb — passes through untouched.
|
|
38
|
+
|
|
39
|
+
Measured against `sentry-sdk` 2.19.2 on 2026-08-24:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from sentry_sdk.scrubber import EventScrubber
|
|
43
|
+
event = {"message": "could not connect to postgresql://u:SUPERSECRET@host/db"}
|
|
44
|
+
EventScrubber().scrub_event(event)
|
|
45
|
+
assert "SUPERSECRET" in str(event) # passes — it was NOT scrubbed
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`EventScrubber` is on by default, its denylist holds 32 keys (`password`,
|
|
49
|
+
`token`, `api_key`, `auth`, `secret`, `authorization`, …), and **`dsn` and
|
|
50
|
+
`database_url` are not among them.**
|
|
51
|
+
|
|
52
|
+
So a service that already writes a database URL to its log — a common enough
|
|
53
|
+
mistake — starts forwarding that same password to a third party the moment
|
|
54
|
+
Sentry is added, with a wider blast radius than the log had.
|
|
55
|
+
|
|
56
|
+
**Two layers, always:**
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
|
|
60
|
+
|
|
61
|
+
sentry_sdk.init(
|
|
62
|
+
dsn=..., environment=..., release=...,
|
|
63
|
+
event_scrubber=EventScrubber( # layer 1 — keys
|
|
64
|
+
denylist=DEFAULT_DENYLIST + ["dsn", "database_url", "auth_key"],
|
|
65
|
+
recursive=True,
|
|
66
|
+
),
|
|
67
|
+
before_send=scrub_values, # layer 2 — values
|
|
68
|
+
send_default_pii=False,
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Layer 2 is a regex over URL credentials. Write a test that asserts **layer 1
|
|
73
|
+
alone still leaks** — if that test ever goes red, Sentry started scrubbing
|
|
74
|
+
values and layer 2 may be redundant, which is a thing to learn from a red test
|
|
75
|
+
rather than never. Both layers, the regex and that test: `references/scrubbing.md`.
|
|
76
|
+
|
|
77
|
+
## A DSN is not an auth token
|
|
78
|
+
|
|
79
|
+
They are different objects and conflating them produces both paranoia and
|
|
80
|
+
carelessness in the wrong places.
|
|
81
|
+
|
|
82
|
+
| | DSN | Auth token |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| Direction | write-only ingest | full API |
|
|
85
|
+
| Lives in | the built artifact, client-side included | CI secrets, a developer's keychain |
|
|
86
|
+
| If it leaks | someone can send you junk events; rate-limit and rotate | someone can read your issues and change your org |
|
|
87
|
+
| Needed to | report an error | create a project, read issues, cut a release |
|
|
88
|
+
|
|
89
|
+
A DSN in a repository is untidy. An auth token in a repository is an incident.
|
|
90
|
+
**Neither the CLI nor the MCP removes the need for a DSN** — they operate Sentry,
|
|
91
|
+
they do not instrument your app. What they remove is the manual step of getting
|
|
92
|
+
one.
|
|
93
|
+
|
|
94
|
+
## Setting it up without a human in the loop
|
|
95
|
+
|
|
96
|
+
Before calling any of this a manual step, check — the answer is often that it is
|
|
97
|
+
already done:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
sentry auth status # already authenticated? (credentials in ~/.sentry)
|
|
101
|
+
sentry org list --json # which orgs this token can see
|
|
102
|
+
sentry project list --json # what already exists
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
To fetch a DSN for an existing project, which is what the app needs:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
sentry project view <org>/<project> --json | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['dsn'])"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Full procedure — install, auth, project creation, wiring the DSN into a host's
|
|
112
|
+
config, and the MCP declaration: read `references/setup.md` when you are adding
|
|
113
|
+
Sentry to a project for the first time.
|
|
114
|
+
|
|
115
|
+
### The 403 that looks like an auth problem and is not
|
|
116
|
+
|
|
117
|
+
`sentry project create` can fail with:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
Your organization has disabled this feature for members.
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
even when **you are the owner of that organization**. Measured 2026-08-24. The
|
|
124
|
+
cause is an org policy, `allowMemberProjectCreation: false`, evaluated against
|
|
125
|
+
the token's scopes rather than the human's role — and the OAuth device-flow
|
|
126
|
+
token that `sentry auth` stores carries `project:admin` and `team:write` but
|
|
127
|
+
**no `org:write` or `org:admin`**. The team-scoped endpoint
|
|
128
|
+
(`POST /teams/{org}/{team}/projects/`) fails identically; it is the same policy.
|
|
129
|
+
|
|
130
|
+
Check before assuming a bug:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
sentry org view <org> --json | grep allowMemberProjectCreation
|
|
134
|
+
sentry api "/" --json # prints the token's actual scopes
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Three ways out, in the order worth trying: create the project once in the web
|
|
138
|
+
UI; flip the org setting; or issue an **Internal Integration token**, which is
|
|
139
|
+
the only token type Sentry documents as granting the org-level API access that
|
|
140
|
+
programmatic project creation needs. Organization Tokens are the CI
|
|
141
|
+
recommendation but cannot do org-level operations, and personal tokens are
|
|
142
|
+
explicitly discouraged for CI. Table with the trade-offs: `references/setup.md`.
|
|
143
|
+
|
|
144
|
+
## Two different tools are called sentry
|
|
145
|
+
|
|
146
|
+
Confusing them wastes a debugging round, because commands from one silently do
|
|
147
|
+
not exist in the other.
|
|
148
|
+
|
|
149
|
+
| | `sentry` | `sentry-cli` |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| From | `cli.sentry.dev`, `brew install getsentry/tools/sentry` | `github.com/getsentry/sentry-cli` |
|
|
152
|
+
| Shape | agent-oriented; `issue explain`, `issue plan`, `api` like `gh api` | build-oriented; source maps, debug files |
|
|
153
|
+
| Auth | `sentry auth` — OAuth device flow, stored in `~/.sentry/` | `SENTRY_AUTH_TOKEN` |
|
|
154
|
+
| Both do | `release create / set-commits / finalize / deploy`, `project`, `org` | |
|
|
155
|
+
|
|
156
|
+
Either can run the release commands. Pick one per repository and say which in
|
|
157
|
+
the README, or two people will wire two halves.
|
|
158
|
+
|
|
159
|
+
**Check for a shadow before trusting a version.** Both install into different
|
|
160
|
+
prefixes; `~/.local/bin` typically precedes `/opt/homebrew/bin`, so an older
|
|
161
|
+
copy wins silently:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
which -a sentry | while read p; do printf '%-34s %s\n' "$p" "$("$p" --version 2>&1 | head -1)"; done
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Observed here: 0.38.0 shadowing 0.43.0. Credentials live in `~/.sentry/`, not
|
|
168
|
+
beside the binary, so removing the stale copy does not log you out.
|
|
169
|
+
|
|
170
|
+
## The MCP is OAuth, which decides where it is declared
|
|
171
|
+
|
|
172
|
+
`https://mcp.sentry.dev/mcp` answers an unauthenticated request with:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
401 www-authenticate: Bearer realm="OAuth", …, resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
That `www-authenticate` header carrying `resource_metadata` **is** the test for
|
|
179
|
+
OAuth. An OAuth upstream declares itself as the resource at its own address, so
|
|
180
|
+
a client pointed at a local gateway must reject the mismatch and the flow never
|
|
181
|
+
starts. **OAuth MCP servers therefore belong in the agent's own config, never
|
|
182
|
+
behind a shared MCP gateway.** Run the curl before choosing a channel; a static
|
|
183
|
+
token upstream goes to the gateway, an OAuth one does not.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp/<org>/<project>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The MCP is for reading and triaging from inside a session. It is an accelerator:
|
|
190
|
+
everything it does, the CLI also does, and a machine without it loses no
|
|
191
|
+
capability.
|
|
192
|
+
|
|
193
|
+
## Releases, or the stack trace names nothing
|
|
194
|
+
|
|
195
|
+
A release in Sentry is only useful if its version string is **identical** to
|
|
196
|
+
what the SDK reports as `release`. When they drift, issues attach to a release
|
|
197
|
+
with no commits and suspect-commit attribution silently does nothing — which
|
|
198
|
+
reads as "Sentry is not very good" rather than as a wiring bug.
|
|
199
|
+
|
|
200
|
+
Pick one source for the version and use it on both sides. On Heroku that is the
|
|
201
|
+
slug commit:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
RELEASE = os.getenv("HEROKU_SLUG_COMMIT") or os.getenv("RELEASE")
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
VERSION="$(git rev-parse HEAD)" # the same value
|
|
209
|
+
sentry release create "<org>/$VERSION" --project <project> --finalize
|
|
210
|
+
sentry release set-commits "<org>/$VERSION" --auto
|
|
211
|
+
sentry release deploy "<org>/$VERSION" production
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Release bookkeeping is non-fatal.** A deploy that reached production must not
|
|
215
|
+
be reported as failed because a release call did not land. Guard each command so
|
|
216
|
+
it logs and continues; the deploy-script shape is in `references/releases.md`,
|
|
217
|
+
which also covers what `--auto` needs from your repository integration.
|
|
218
|
+
|
|
219
|
+
## A process that is up is not a service that works
|
|
220
|
+
|
|
221
|
+
Observed 2026-08-24: a bot token was rotated, and for ten minutes the platform
|
|
222
|
+
reported the dyno `up` with a clean log while the API returned `401` to every
|
|
223
|
+
call and no user was served. Nothing in the system could tell the difference.
|
|
224
|
+
|
|
225
|
+
**Liveness must assert the thing the service exists to do**, not that a process
|
|
226
|
+
exists. For anything holding a long-lived authenticated connection, check the
|
|
227
|
+
credential periodically and exit non-zero when it fails — a crash loop is
|
|
228
|
+
visible, a silent zombie is not. Sentry helps only if something raises; a
|
|
229
|
+
connection that stopped working without raising produces no event at all. Cron
|
|
230
|
+
monitors (`sentry monitor`) cover the shape where a job stops running.
|
|
231
|
+
|
|
232
|
+
## Degradation
|
|
233
|
+
|
|
234
|
+
- **Not Claude Code** (Cursor, Codex, skills CLI, the API container): no MCP, no
|
|
235
|
+
`/command`. Everything here is CLI and SDK, both of which work anywhere with a
|
|
236
|
+
shell — except the API container, which has no network and no package install,
|
|
237
|
+
so treat this skill as Claude Code and Cursor only for the setup half.
|
|
238
|
+
- **`sentry` CLI absent**: say so once, then use the REST API directly with a
|
|
239
|
+
token (`curl https://sentry.io/api/0/…`), or the web UI for the one-off. Do
|
|
240
|
+
not loop on the missing binary.
|
|
241
|
+
- **MCP absent**: no capability is lost — read issues with `sentry issue list`.
|
|
242
|
+
- **Not authenticated, or a scope is missing**: interactive auth is a human
|
|
243
|
+
step. State the exact command once, name what is blocked, and continue with
|
|
244
|
+
everything that does not need it.
|
|
245
|
+
|
|
246
|
+
## Verify, do not assume
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
sentry auth status # authenticated, and as whom
|
|
250
|
+
sentry api "/" --json # the token's real scopes
|
|
251
|
+
sentry project view <org>/<project> --json # the DSN, and that it exists
|
|
252
|
+
sentry release list <org>/<project> --limit 3 # releases arriving at all
|
|
253
|
+
python3 -c "import sentry_sdk; print(sentry_sdk.VERSION)"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Two claims worth re-measuring rather than trusting from this page, because both
|
|
257
|
+
are version-dependent: the default denylist contents, and whether the SDK has
|
|
258
|
+
started scrubbing values. `references/scrubbing.md` carries the command for
|
|
259
|
+
each.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Releases, deploys, and health that means something
|
|
2
|
+
|
|
3
|
+
Read when wiring Sentry into a deploy script, when issues show no suspect
|
|
4
|
+
commits, or when a service reports healthy while serving nobody.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [The version contract](#the-version-contract)
|
|
9
|
+
- [The Heroku trap](#the-heroku-trap-the-variable-is-absent-unless-you-turned-it-on)
|
|
10
|
+
- [The four release commands](#the-four-release-commands)
|
|
11
|
+
- [In a deploy script, non-fatally](#in-a-deploy-script-non-fatally)
|
|
12
|
+
- [What set-commits --auto needs](#what-set-commits---auto-needs)
|
|
13
|
+
- [Health that means something](#health-that-means-something)
|
|
14
|
+
- [Cron monitors](#cron-monitors)
|
|
15
|
+
|
|
16
|
+
## The version contract
|
|
17
|
+
|
|
18
|
+
**The version string you create in Sentry and the one the SDK reports as
|
|
19
|
+
`release` must be byte-identical.** When they differ, every issue attaches to a
|
|
20
|
+
release that has no commits, suspect-commit attribution silently does nothing,
|
|
21
|
+
and "which change broke this" stays unanswerable. Nothing errors. It reads as
|
|
22
|
+
Sentry being unhelpful.
|
|
23
|
+
|
|
24
|
+
Pick one source and use it on both sides.
|
|
25
|
+
|
|
26
|
+
| Platform | The value | Read in the app as |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Heroku | slug commit | `os.getenv("HEROKU_SLUG_COMMIT")` — **see the trap below** |
|
|
29
|
+
| Vercel | commit SHA | `os.getenv("VERCEL_GIT_COMMIT_SHA")` |
|
|
30
|
+
| GitHub Actions | `github.sha` | `os.getenv("GITHUB_SHA")` |
|
|
31
|
+
| Anywhere | `git rev-parse HEAD` | inject it at build time |
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
RELEASE = os.getenv("HEROKU_SLUG_COMMIT") or os.getenv("RELEASE")
|
|
35
|
+
sentry_sdk.init(dsn=..., release=RELEASE, environment=...)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
VERSION="$(git rev-parse HEAD)" # the same string, on the other side
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`sentry release propose-version` will invent one, which is convenient and
|
|
43
|
+
exactly the thing that drifts. Prefer an explicit value both sides can derive.
|
|
44
|
+
|
|
45
|
+
### The Heroku trap: the variable is absent unless you turned it on
|
|
46
|
+
|
|
47
|
+
`HEROKU_SLUG_COMMIT` is **not a config var**. It is dyno metadata, injected into
|
|
48
|
+
the dyno only when the `runtime-dyno-metadata` labs feature is enabled, and
|
|
49
|
+
absent in silence otherwise. Three consequences, all measured on 2026-08-24:
|
|
50
|
+
|
|
51
|
+
- `heroku config:get HEROKU_SLUG_COMMIT` prints nothing **even when it is
|
|
52
|
+
working** — it is never a config var. Read it from inside a dyno instead.
|
|
53
|
+
- With the feature off, `release=os.getenv("HEROKU_SLUG_COMMIT")` is `None`, so
|
|
54
|
+
the SDK reports no release at all while Sentry holds a release with a deploy
|
|
55
|
+
that nothing will ever attach to. Nothing errors.
|
|
56
|
+
- Enabling it takes effect on the **next release**, not on a restart.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
heroku labs -a <app> | grep runtime-dyno-metadata # is it on?
|
|
60
|
+
heroku labs:enable runtime-dyno-metadata -a <app>
|
|
61
|
+
heroku run -a <app> -- printenv HEROKU_SLUG_COMMIT # from INSIDE a dyno
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Do not depend on it alone. Set the value from the deploy itself, which needs no
|
|
65
|
+
platform feature and cannot drift because one command produces both sides:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
heroku config:set RELEASE="$(git rev-parse HEAD)" -a "$APP" # inside the down window
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then read `HEROKU_SLUG_COMMIT or RELEASE`: the platform's answer when it is
|
|
72
|
+
available, because it names the commit actually running, and the deploy's answer
|
|
73
|
+
otherwise.
|
|
74
|
+
|
|
75
|
+
**And create the Sentry release for the DEPLOYED commit, not for local HEAD.**
|
|
76
|
+
They differ the moment you have commits pushed but not deployed — the same
|
|
77
|
+
mistake one level up, and it produces a release nobody's events belong to.
|
|
78
|
+
|
|
79
|
+
## The four release commands
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
sentry release create "<org>/$VERSION" --project <project> --finalize
|
|
83
|
+
sentry release set-commits "<org>/$VERSION" --auto
|
|
84
|
+
sentry release deploy "<org>/$VERSION" <environment>
|
|
85
|
+
sentry release list "<org>/<project>" --limit 5
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `--finalize` marks it released now. Omit it when you create the release
|
|
89
|
+
before the code is live and call `sentry release finalize` afterwards.
|
|
90
|
+
- `set-commits --auto` derives the commit range from the previous release.
|
|
91
|
+
`--local` uses local git history instead when the repository is not linked.
|
|
92
|
+
- `deploy` records environment and timing, which is what "released 4 minutes
|
|
93
|
+
before the first error" in the UI is built from.
|
|
94
|
+
|
|
95
|
+
## In a deploy script, non-fatally
|
|
96
|
+
|
|
97
|
+
Release bookkeeping records what happened; it does not make it happen. A deploy
|
|
98
|
+
that reached production must never be reported as failed because a bookkeeping
|
|
99
|
+
call did not land.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
notify_sentry() {
|
|
103
|
+
local version org_project
|
|
104
|
+
version="$(git rev-parse HEAD)"
|
|
105
|
+
org_project="${SENTRY_ORG:?}/${SENTRY_PROJECT:?}"
|
|
106
|
+
|
|
107
|
+
command -v sentry >/dev/null || { log "sentry CLI absent — skipping"; return 0; }
|
|
108
|
+
sentry auth status >/dev/null 2>&1 || { log "sentry not authenticated — skipping"; return 0; }
|
|
109
|
+
|
|
110
|
+
sentry release create "${SENTRY_ORG}/${version}" \
|
|
111
|
+
--project "${SENTRY_PROJECT}" --finalize >/dev/null 2>&1 \
|
|
112
|
+
|| { log "could not create the release — continuing"; return 0; }
|
|
113
|
+
sentry release set-commits "${SENTRY_ORG}/${version}" --auto >/dev/null 2>&1 \
|
|
114
|
+
|| log "could not attach commits (repository linked in Sentry?) — continuing"
|
|
115
|
+
sentry release deploy "${SENTRY_ORG}/${version}" production >/dev/null 2>&1 \
|
|
116
|
+
|| log "could not mark the deploy — continuing"
|
|
117
|
+
log "Sentry: release ${version:0:8} recorded for ${org_project}"
|
|
118
|
+
}
|
|
119
|
+
notify_sentry
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Under `set -euo pipefail` every branch must return 0 explicitly, or the guard
|
|
123
|
+
you wrote becomes the thing that fails the deploy. Test it with the project
|
|
124
|
+
deliberately absent:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bash -c 'set -euo pipefail; . ./deploy.sh --source-only; notify_sentry; echo "exit $?"'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Order matters: record the release **after** the code is live, or the first
|
|
131
|
+
errors from the new build arrive before the release exists and attach to the
|
|
132
|
+
previous one.
|
|
133
|
+
|
|
134
|
+
## What set-commits --auto needs
|
|
135
|
+
|
|
136
|
+
`--auto` asks Sentry to resolve commits itself, which requires the repository to
|
|
137
|
+
be linked through a source-control integration (GitHub, GitLab, Bitbucket) in
|
|
138
|
+
the org's settings. Without it the call fails and the release still exists —
|
|
139
|
+
useful, minus attribution.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
sentry repo list <org> # what is linked
|
|
143
|
+
sentry release set-commits "<org>/$VERSION" --local # fallback: local git history
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`--local` needs the deploy to run from a real checkout with history, not a
|
|
147
|
+
shallow clone. CI defaults to `fetch-depth: 1`; set it to `0` when using
|
|
148
|
+
`--local`.
|
|
149
|
+
|
|
150
|
+
## Health that means something
|
|
151
|
+
|
|
152
|
+
Observed 2026-08-24: a credential was rotated, and for ten minutes the platform
|
|
153
|
+
reported the process `up` with a clean log while the upstream API returned `401`
|
|
154
|
+
to every call. No user was served. Nothing in the system could tell the
|
|
155
|
+
difference between that and working.
|
|
156
|
+
|
|
157
|
+
**Sentry sees exceptions. It does not see a connection that quietly stopped
|
|
158
|
+
working**, because nothing raises. Two different failures, two different checks:
|
|
159
|
+
|
|
160
|
+
| Failure | Caught by |
|
|
161
|
+
|---|---|
|
|
162
|
+
| Code raises | Sentry, automatically |
|
|
163
|
+
| Credential revoked, connection idle | a periodic check that asserts authorization |
|
|
164
|
+
| Process died | the platform's own supervisor |
|
|
165
|
+
| Job stopped running | a cron monitor |
|
|
166
|
+
|
|
167
|
+
For a worker holding a long-lived authenticated connection:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
async def assert_still_authorized(client):
|
|
171
|
+
"""Exit non-zero rather than idle in a state that serves nobody.
|
|
172
|
+
A crash loop is visible; a silent zombie is not."""
|
|
173
|
+
try:
|
|
174
|
+
await client.get_me()
|
|
175
|
+
except AuthError:
|
|
176
|
+
logger.error("Credential no longer valid — exiting so the platform restarts us")
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The rule generalises: **liveness asserts the thing the service exists to do.**
|
|
181
|
+
Anything weaker reports a process, and a process is not a service.
|
|
182
|
+
|
|
183
|
+
## Cron monitors
|
|
184
|
+
|
|
185
|
+
For work that is supposed to happen on a schedule, absence is the failure and
|
|
186
|
+
absence raises nothing.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
sentry monitor list
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Wrap the job so Sentry is told it started and finished; a missed check-in then
|
|
193
|
+
becomes an alert. This is the only shape here that catches "it stopped running"
|
|
194
|
+
rather than "it ran and threw".
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Keeping secrets out of Sentry events
|
|
2
|
+
|
|
3
|
+
Read before switching Sentry on in any service that has ever handled a
|
|
4
|
+
credential — which is every service with a database.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [The measurement](#the-measurement)
|
|
9
|
+
- [What each layer covers](#what-each-layer-covers)
|
|
10
|
+
- [Layer 1 — keys, by the SDK](#layer-1--keys-by-the-sdk)
|
|
11
|
+
- [Layer 2 — values, by you](#layer-2--values-by-you)
|
|
12
|
+
- [The test that guards the boundary](#the-test-that-guards-the-boundary)
|
|
13
|
+
- [Re-measuring after an SDK upgrade](#re-measuring-after-an-sdk-upgrade)
|
|
14
|
+
|
|
15
|
+
## The measurement
|
|
16
|
+
|
|
17
|
+
`sentry-sdk` 2.19.2, run on 2026-08-24:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from sentry_sdk.scrubber import EventScrubber
|
|
21
|
+
|
|
22
|
+
LEAK = "postgresql://u4fc:SUPERSECRETPW@host.rds.amazonaws.com:5432/db"
|
|
23
|
+
event = {
|
|
24
|
+
"message": f"could not connect to {LEAK}",
|
|
25
|
+
"exception": {"values": [{"value": f"FATAL: auth failed for {LEAK}"}]},
|
|
26
|
+
"extra": {"password": "caught", "database_url": LEAK},
|
|
27
|
+
}
|
|
28
|
+
EventScrubber().scrub_event(event)
|
|
29
|
+
|
|
30
|
+
# extra.password -> AnnotatedValue (scrubbed: key is on the denylist)
|
|
31
|
+
# extra.database_url -> untouched (key is NOT on the denylist)
|
|
32
|
+
# message -> untouched (values are never inspected)
|
|
33
|
+
# exception value -> untouched
|
|
34
|
+
assert "SUPERSECRETPW" in str(event)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Two facts follow, and neither is a criticism of Sentry — they are the boundary
|
|
38
|
+
of what that layer claims to do:
|
|
39
|
+
|
|
40
|
+
1. The default denylist holds **32 keys**, and **`dsn` and `database_url` are
|
|
41
|
+
not among them**. Present: `password`, `passwd`, `secret`, `api_key`,
|
|
42
|
+
`apikey`, `auth`, `credentials`, `token`, `session`, `authorization`, and
|
|
43
|
+
similar.
|
|
44
|
+
2. The scrubber matches **key names**. A credential that lives inside a string
|
|
45
|
+
value — a connection URL in a log line that became a message, an exception
|
|
46
|
+
from a driver that echoes the DSN — is not seen at all.
|
|
47
|
+
|
|
48
|
+
Fact 2 is the one that bites. A service that prints a database URL anywhere near
|
|
49
|
+
an error is common; adding Sentry to it forwards that password to a third party
|
|
50
|
+
on every exception, with a wider reach than the log had.
|
|
51
|
+
|
|
52
|
+
## What each layer covers
|
|
53
|
+
|
|
54
|
+
| | Matches on | Catches | Misses |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| `EventScrubber` (SDK, default on) | key names, against a denylist | `extra.password`, `request.headers.authorization` | anything inside a string value; keys not on the list |
|
|
57
|
+
| `before_send` (yours) | the shape of the value | `postgres://u:pw@host` wherever it appears | keys whose value is not URL-shaped |
|
|
58
|
+
|
|
59
|
+
They are complementary, not alternatives. Run both.
|
|
60
|
+
|
|
61
|
+
## Layer 1 — keys, by the SDK
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
|
|
65
|
+
|
|
66
|
+
EXTRA_DENYLIST = ["dsn", "database_url", "bot_token", "api_hash", "auth_key", "session"]
|
|
67
|
+
|
|
68
|
+
sentry_sdk.init(
|
|
69
|
+
...,
|
|
70
|
+
event_scrubber=EventScrubber(
|
|
71
|
+
denylist=DEFAULT_DENYLIST + EXTRA_DENYLIST,
|
|
72
|
+
recursive=True, # off by default; nested payloads otherwise escape
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`recursive=False` is the default. Any structure deeper than one level — a
|
|
78
|
+
config dict inside `extra`, a nested exception context — is not walked. Turn it
|
|
79
|
+
on unless you have measured the cost on a payload you actually send.
|
|
80
|
+
|
|
81
|
+
Add to `EXTRA_DENYLIST` every key your own code uses for a secret. The default
|
|
82
|
+
list knows Sentry's world, not your variable names.
|
|
83
|
+
|
|
84
|
+
## Layer 2 — values, by you
|
|
85
|
+
|
|
86
|
+
Match the shape, not a list of known names — a newly added secret is then
|
|
87
|
+
covered by default instead of leaking until someone updates a list.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import re
|
|
91
|
+
|
|
92
|
+
_URL_CREDENTIALS = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/@\s]+):(?P<pw>[^@/\s]+)@")
|
|
93
|
+
_REDACTED = "<redacted>"
|
|
94
|
+
|
|
95
|
+
def scrub_text(value):
|
|
96
|
+
if not isinstance(value, str):
|
|
97
|
+
return value
|
|
98
|
+
return _URL_CREDENTIALS.sub(rf"\g<scheme>\g<user>:{_REDACTED}@", value)
|
|
99
|
+
|
|
100
|
+
def scrub_values(event, _hint=None):
|
|
101
|
+
def walk(node):
|
|
102
|
+
if isinstance(node, dict):
|
|
103
|
+
return {k: walk(v) for k, v in node.items()}
|
|
104
|
+
if isinstance(node, (list, tuple)):
|
|
105
|
+
return type(node)(walk(v) for v in node)
|
|
106
|
+
return scrub_text(node)
|
|
107
|
+
try:
|
|
108
|
+
return walk(event)
|
|
109
|
+
except Exception:
|
|
110
|
+
# Returning None DROPS the event. A scrubber bug must not silently
|
|
111
|
+
# switch error tracking off.
|
|
112
|
+
return {"message": "event dropped by scrubber", "level": "error"}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Three properties that matter:
|
|
116
|
+
|
|
117
|
+
- **Keep the user and host.** A fully redacted URL is useless for debugging.
|
|
118
|
+
Redact the password only.
|
|
119
|
+
- **Scheme-agnostic.** `amqps://`, `redis://`, `mongodb+srv://` leak the same way.
|
|
120
|
+
- **Never return `None`.** In `before_send` that discards the event. A bug in
|
|
121
|
+
the scrubber then reads as "Sentry is quiet", which is indistinguishable from
|
|
122
|
+
"nothing is broken".
|
|
123
|
+
|
|
124
|
+
## The test that guards the boundary
|
|
125
|
+
|
|
126
|
+
The important assertion is the one that says **the SDK layer alone still leaks**.
|
|
127
|
+
It documents why layer 2 exists, and it goes red the day that stops being true —
|
|
128
|
+
which is exactly when you want to be told.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
def test_the_sdk_scrubber_alone_would_leak_it():
|
|
132
|
+
"""If this fails, the SDK started scrubbing values and layer 2 may be
|
|
133
|
+
redundant. Check before deleting it."""
|
|
134
|
+
from sentry_sdk.scrubber import EventScrubber
|
|
135
|
+
event = {"message": f"could not connect to {LEAK}"}
|
|
136
|
+
EventScrubber().scrub_event(event)
|
|
137
|
+
assert "SUPERSECRETPW" in str(event)
|
|
138
|
+
|
|
139
|
+
def test_our_layer_catches_what_the_sdk_misses():
|
|
140
|
+
assert "SUPERSECRETPW" not in str(scrub_values({"message": f"…{LEAK}"}))
|
|
141
|
+
|
|
142
|
+
def test_extra_denylist_names_what_the_default_omits():
|
|
143
|
+
from sentry_sdk.scrubber import DEFAULT_DENYLIST
|
|
144
|
+
for key in ("dsn", "database_url"):
|
|
145
|
+
assert key not in DEFAULT_DENYLIST, f"{key} is covered now; drop it from EXTRA_DENYLIST"
|
|
146
|
+
assert key in EXTRA_DENYLIST
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The third one keeps the extra list honest: when Sentry adds a key to its
|
|
150
|
+
default, the test tells you to stop carrying it yourself.
|
|
151
|
+
|
|
152
|
+
**Never put a real credential in a fixture.** Use an obviously fake value of the
|
|
153
|
+
same shape. A test that asserts a password is scrubbed, while carrying that
|
|
154
|
+
password in the repository, has moved the leak rather than closed it.
|
|
155
|
+
|
|
156
|
+
## Re-measuring after an SDK upgrade
|
|
157
|
+
|
|
158
|
+
Both facts on this page are version-dependent. After any `sentry-sdk` bump:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python3 -c "
|
|
162
|
+
from sentry_sdk.scrubber import DEFAULT_DENYLIST as D
|
|
163
|
+
print(len(D), 'keys'); print([k for k in ('dsn','database_url') if k in D], 'now covered by default')
|
|
164
|
+
"
|
|
165
|
+
python3 -m pytest -k scrubber # the boundary tests above
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If `dsn` or `database_url` appear in the default list, drop them from
|
|
169
|
+
`EXTRA_DENYLIST`. If the value test goes red, layer 2's job changed — read what
|
|
170
|
+
the SDK now does before removing it.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Sentry setup — install, auth, projects, DSN, MCP
|
|
2
|
+
|
|
3
|
+
Read when adding Sentry to a project for the first time, or when a CLI call is
|
|
4
|
+
refused and you need to tell a policy problem from an auth problem.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Install](#install)
|
|
9
|
+
- [Authenticate](#authenticate)
|
|
10
|
+
- [Token taxonomy — which kind can do what](#token-taxonomy--which-kind-can-do-what)
|
|
11
|
+
- [Find what already exists](#find-what-already-exists)
|
|
12
|
+
- [Create a project](#create-a-project)
|
|
13
|
+
- [Get the DSN and wire it in](#get-the-dsn-and-wire-it-in)
|
|
14
|
+
- [Declare the MCP server](#declare-the-mcp-server)
|
|
15
|
+
- [SDK options for a backend service](#sdk-options-for-a-backend-service)
|
|
16
|
+
|
|
17
|
+
All commands below were executed on 2026-08-24 against `sentry` 0.43.0 and
|
|
18
|
+
`sentry-sdk` 2.19.2. Where a version matters, it is named.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
brew install getsentry/tools/sentry # macOS, preferred: no piping a remote script to a shell
|
|
24
|
+
curl https://cli.sentry.dev/install -fsS | bash # the documented alternative
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then check for a shadow — two prefixes, and the older copy usually wins:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
which -a sentry | while read p; do printf '%-34s %s\n' "$p" "$("$p" --version 2>&1 | head -1)"; done
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Remove the stale copy rather than reordering `PATH`. Credentials live in
|
|
34
|
+
`~/.sentry/`, not beside the binary, so this does not log you out.
|
|
35
|
+
|
|
36
|
+
## Authenticate
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
sentry auth # OAuth device flow: prints a URL and a code
|
|
40
|
+
sentry auth --token <TOKEN> # non-interactive, for CI
|
|
41
|
+
sentry auth status # who, which token, when it expires
|
|
42
|
+
sentry auth logout
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`sentry auth` writes to `~/.sentry/cli.db`. Override with `SENTRY_CONFIG_DIR`.
|
|
46
|
+
`SENTRY_URL` points at self-hosted instances.
|
|
47
|
+
|
|
48
|
+
**The device-flow token expires.** `auth status` prints the expiry — one
|
|
49
|
+
observed token had three days left. A CI job depending on it fails on a
|
|
50
|
+
Wednesday for no visible reason; CI wants a token, not the device flow.
|
|
51
|
+
|
|
52
|
+
## Token taxonomy — which kind can do what
|
|
53
|
+
|
|
54
|
+
| Kind | Created in | Scopes | Use it for |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| **OAuth device flow** (`sentry auth`) | the CLI | fixed by the flow; measured: `event:read`, `event:write`, `member:read`, `org:read`, `project:admin`, `project:read`, `project:write`, `team:read`, `team:write` — **no `org:write`/`org:admin`** | interactive work at a desk |
|
|
57
|
+
| **Organization Token** | Settings → Developer Settings → Organization Tokens | limited, not customizable | CI. Sentry's own recommendation for it |
|
|
58
|
+
| **Internal Integration** | the integration platform | customizable, **full API access** | org-level automation, including creating projects |
|
|
59
|
+
| **Personal Token** | Account → Personal Tokens | chosen at creation, never editable after | a one-off at a desk. Explicitly discouraged for CI |
|
|
60
|
+
|
|
61
|
+
Print the scopes a token actually carries, rather than inferring them from how
|
|
62
|
+
it was made:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
sentry api "/" --json # {"auth": {"scopes": [...]}, "user": {...}}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Create a project
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
sentry project create <org>/<name>:<platform> --team <team> --dry-run
|
|
72
|
+
sentry project create <org>/<name>:<platform> --team <team> --json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`--dry-run` prints the resolved org, team, slug and platform without creating
|
|
76
|
+
anything. Use it: the org must be in the argument or the command refuses.
|
|
77
|
+
|
|
78
|
+
### When it returns 403
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Your organization has disabled this feature for members.
|
|
82
|
+
This is an org-level policy setting, not an auth issue.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The message is accurate and easy to disbelieve, because it appears **even for an
|
|
86
|
+
org owner**. The policy is evaluated against the token's scopes, not the human's
|
|
87
|
+
role, so an owner acting through a device-flow token is treated as a member.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sentry org view <org> --json | grep -E 'allowMemberProjectCreation|allowMemberInvite'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`allowMemberProjectCreation: false` is the cause. `POST /teams/{org}/{team}/projects/`
|
|
94
|
+
fails identically — the error names it as an alternative, but it is the same
|
|
95
|
+
policy underneath.
|
|
96
|
+
|
|
97
|
+
Resolutions, cheapest first:
|
|
98
|
+
|
|
99
|
+
1. Create the project once in the web UI. One browser action, then everything
|
|
100
|
+
else is scriptable forever.
|
|
101
|
+
2. Flip the org setting. Also a browser action, and it loosens a policy that
|
|
102
|
+
exists for a reason — decide deliberately.
|
|
103
|
+
3. Issue an Internal Integration token and `sentry auth --token` with it. The
|
|
104
|
+
only route that makes project creation itself scriptable.
|
|
105
|
+
|
|
106
|
+
## Get the DSN and wire it in
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
sentry project view <org>/<project> --json \
|
|
110
|
+
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['dsn'])"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The response is a **list**, hence the `[0]`. The DSN is the app's ingest
|
|
114
|
+
address; put it wherever that host keeps configuration:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
heroku config:set SENTRY_DSN='https://…@o….ingest.sentry.io/…' -a <app>
|
|
118
|
+
fly secrets set SENTRY_DSN='…'
|
|
119
|
+
gh secret set SENTRY_DSN --body '…' # only if CI itself reports errors
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A DSN is write-only ingest, not an API credential — see the table in `SKILL.md`.
|
|
123
|
+
Treat it as configuration, not as a secret to be guarded like a token, and still
|
|
124
|
+
do not commit it.
|
|
125
|
+
|
|
126
|
+
## Declare the MCP server
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
curl -si https://mcp.sentry.dev/mcp | grep -i www-authenticate
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
A `401` whose `www-authenticate` carries `resource_metadata=` means OAuth, which
|
|
133
|
+
means **the agent's own config, never a shared MCP gateway** — an OAuth upstream
|
|
134
|
+
names itself as the resource at its own address, so a client pointed at a
|
|
135
|
+
gateway must reject the mismatch and the flow cannot complete.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp/<org>/<project>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Scoping is optional and narrows what the server sees:
|
|
142
|
+
`…/mcp`, `…/mcp/<org>`, `…/mcp/<org>/<project>`. Narrow it.
|
|
143
|
+
|
|
144
|
+
Everything the MCP does, the CLI also does. A machine without it loses no
|
|
145
|
+
capability, so never let a procedure depend on it.
|
|
146
|
+
|
|
147
|
+
## SDK options for a backend service
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
sentry_sdk.init(
|
|
151
|
+
dsn=SENTRY_DSN,
|
|
152
|
+
environment=ENVIRONMENT, # "production" | "staging" | "local"
|
|
153
|
+
release=RELEASE, # MUST equal the version you create in Sentry
|
|
154
|
+
event_scrubber=EventScrubber( # see references/scrubbing.md
|
|
155
|
+
denylist=DEFAULT_DENYLIST + ["dsn", "database_url", "auth_key"],
|
|
156
|
+
recursive=True,
|
|
157
|
+
),
|
|
158
|
+
before_send=scrub_values,
|
|
159
|
+
send_default_pii=False, # excludes headers, IPs, and similar
|
|
160
|
+
traces_sample_rate=0.0, # tracing off unless someone reads the spans
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Notes that decide behaviour:
|
|
165
|
+
|
|
166
|
+
- `environment` defaults to `"production"`. A local run that forgets to set it
|
|
167
|
+
pollutes the production feed with a developer's laptop.
|
|
168
|
+
- `release` defaults to `None` and the SDK's auto-detection is not reliable
|
|
169
|
+
enough to depend on. Set it.
|
|
170
|
+
- `traces_sample_rate` defaults to `None`; tracing is off unless it or
|
|
171
|
+
`traces_sampler` is set. Turning it on costs quota — do it when someone is
|
|
172
|
+
going to read the result, not by default.
|
|
173
|
+
- `profiles_sample_rate` is the current profiling option and is not deprecated.
|
|
174
|
+
- `send_default_pii=False` (the default) keeps integrations from attaching
|
|
175
|
+
request headers and IP addresses. It does **not** scrub your own payloads;
|
|
176
|
+
that is the scrubber's job.
|