@ssheleg/sheleg-dev 0.7.0 → 0.9.0

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.
@@ -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.0
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,159 @@
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 four release commands](#the-four-release-commands)
10
+ - [In a deploy script, non-fatally](#in-a-deploy-script-non-fatally)
11
+ - [What set-commits --auto needs](#what-set-commits---auto-needs)
12
+ - [Health that means something](#health-that-means-something)
13
+ - [Cron monitors](#cron-monitors)
14
+
15
+ ## The version contract
16
+
17
+ **The version string you create in Sentry and the one the SDK reports as
18
+ `release` must be byte-identical.** When they differ, every issue attaches to a
19
+ release that has no commits, suspect-commit attribution silently does nothing,
20
+ and "which change broke this" stays unanswerable. Nothing errors. It reads as
21
+ Sentry being unhelpful.
22
+
23
+ Pick one source and use it on both sides.
24
+
25
+ | Platform | The value | Read in the app as |
26
+ |---|---|---|
27
+ | Heroku | slug commit | `os.getenv("HEROKU_SLUG_COMMIT")` |
28
+ | Vercel | commit SHA | `os.getenv("VERCEL_GIT_COMMIT_SHA")` |
29
+ | GitHub Actions | `github.sha` | `os.getenv("GITHUB_SHA")` |
30
+ | Anywhere | `git rev-parse HEAD` | inject it at build time |
31
+
32
+ ```python
33
+ RELEASE = os.getenv("HEROKU_SLUG_COMMIT") or os.getenv("RELEASE")
34
+ sentry_sdk.init(dsn=..., release=RELEASE, environment=...)
35
+ ```
36
+
37
+ ```bash
38
+ VERSION="$(git rev-parse HEAD)" # the same string, on the other side
39
+ ```
40
+
41
+ `sentry release propose-version` will invent one, which is convenient and
42
+ exactly the thing that drifts. Prefer an explicit value both sides can derive.
43
+
44
+ ## The four release commands
45
+
46
+ ```bash
47
+ sentry release create "<org>/$VERSION" --project <project> --finalize
48
+ sentry release set-commits "<org>/$VERSION" --auto
49
+ sentry release deploy "<org>/$VERSION" <environment>
50
+ sentry release list "<org>/<project>" --limit 5
51
+ ```
52
+
53
+ - `--finalize` marks it released now. Omit it when you create the release
54
+ before the code is live and call `sentry release finalize` afterwards.
55
+ - `set-commits --auto` derives the commit range from the previous release.
56
+ `--local` uses local git history instead when the repository is not linked.
57
+ - `deploy` records environment and timing, which is what "released 4 minutes
58
+ before the first error" in the UI is built from.
59
+
60
+ ## In a deploy script, non-fatally
61
+
62
+ Release bookkeeping records what happened; it does not make it happen. A deploy
63
+ that reached production must never be reported as failed because a bookkeeping
64
+ call did not land.
65
+
66
+ ```bash
67
+ notify_sentry() {
68
+ local version org_project
69
+ version="$(git rev-parse HEAD)"
70
+ org_project="${SENTRY_ORG:?}/${SENTRY_PROJECT:?}"
71
+
72
+ command -v sentry >/dev/null || { log "sentry CLI absent — skipping"; return 0; }
73
+ sentry auth status >/dev/null 2>&1 || { log "sentry not authenticated — skipping"; return 0; }
74
+
75
+ sentry release create "${SENTRY_ORG}/${version}" \
76
+ --project "${SENTRY_PROJECT}" --finalize >/dev/null 2>&1 \
77
+ || { log "could not create the release — continuing"; return 0; }
78
+ sentry release set-commits "${SENTRY_ORG}/${version}" --auto >/dev/null 2>&1 \
79
+ || log "could not attach commits (repository linked in Sentry?) — continuing"
80
+ sentry release deploy "${SENTRY_ORG}/${version}" production >/dev/null 2>&1 \
81
+ || log "could not mark the deploy — continuing"
82
+ log "Sentry: release ${version:0:8} recorded for ${org_project}"
83
+ }
84
+ notify_sentry
85
+ ```
86
+
87
+ Under `set -euo pipefail` every branch must return 0 explicitly, or the guard
88
+ you wrote becomes the thing that fails the deploy. Test it with the project
89
+ deliberately absent:
90
+
91
+ ```bash
92
+ bash -c 'set -euo pipefail; . ./deploy.sh --source-only; notify_sentry; echo "exit $?"'
93
+ ```
94
+
95
+ Order matters: record the release **after** the code is live, or the first
96
+ errors from the new build arrive before the release exists and attach to the
97
+ previous one.
98
+
99
+ ## What set-commits --auto needs
100
+
101
+ `--auto` asks Sentry to resolve commits itself, which requires the repository to
102
+ be linked through a source-control integration (GitHub, GitLab, Bitbucket) in
103
+ the org's settings. Without it the call fails and the release still exists —
104
+ useful, minus attribution.
105
+
106
+ ```bash
107
+ sentry repo list <org> # what is linked
108
+ sentry release set-commits "<org>/$VERSION" --local # fallback: local git history
109
+ ```
110
+
111
+ `--local` needs the deploy to run from a real checkout with history, not a
112
+ shallow clone. CI defaults to `fetch-depth: 1`; set it to `0` when using
113
+ `--local`.
114
+
115
+ ## Health that means something
116
+
117
+ Observed 2026-08-24: a credential was rotated, and for ten minutes the platform
118
+ reported the process `up` with a clean log while the upstream API returned `401`
119
+ to every call. No user was served. Nothing in the system could tell the
120
+ difference between that and working.
121
+
122
+ **Sentry sees exceptions. It does not see a connection that quietly stopped
123
+ working**, because nothing raises. Two different failures, two different checks:
124
+
125
+ | Failure | Caught by |
126
+ |---|---|
127
+ | Code raises | Sentry, automatically |
128
+ | Credential revoked, connection idle | a periodic check that asserts authorization |
129
+ | Process died | the platform's own supervisor |
130
+ | Job stopped running | a cron monitor |
131
+
132
+ For a worker holding a long-lived authenticated connection:
133
+
134
+ ```python
135
+ async def assert_still_authorized(client):
136
+ """Exit non-zero rather than idle in a state that serves nobody.
137
+ A crash loop is visible; a silent zombie is not."""
138
+ try:
139
+ await client.get_me()
140
+ except AuthError:
141
+ logger.error("Credential no longer valid — exiting so the platform restarts us")
142
+ sys.exit(1)
143
+ ```
144
+
145
+ The rule generalises: **liveness asserts the thing the service exists to do.**
146
+ Anything weaker reports a process, and a process is not a service.
147
+
148
+ ## Cron monitors
149
+
150
+ For work that is supposed to happen on a schedule, absence is the failure and
151
+ absence raises nothing.
152
+
153
+ ```bash
154
+ sentry monitor list
155
+ ```
156
+
157
+ Wrap the job so Sentry is told it started and finished; a missed check-in then
158
+ becomes an alert. This is the only shape here that catches "it stopped running"
159
+ 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.