@ssheleg/sheleg-dev 0.8.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ 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.0 — 2026-08-24
8
+
9
+ ### The pack that wires integrations had nothing about knowing when they break
10
+
11
+ Six skills covered taking money, tracking conversions, signing people in and
12
+ page speed. None covered the layer that says any of it stopped working: a `grep`
13
+ for `sentry` across the whole family returned two incidental mentions.
14
+
15
+ `error-tracking` is written from a measured baseline rather than an imagined
16
+ one. An agent solving this exact task without the skill, in one session, made
17
+ four failures that were recorded as they happened:
18
+
19
+ - **Called "get a DSN" a manual step needing an account.** The account existed
20
+ and the CLI was already authenticated. Error tracking stayed off for no reason.
21
+ - **Wrote a `before_send` scrubber without knowing `EventScrubber` exists** and
22
+ runs by default — so it could not say which layer covered what, or whether its
23
+ own code was redundant or load-bearing.
24
+ - **Did not know the MCP is OAuth**, and would have declared it in the machine's
25
+ shared gateway, where an OAuth flow cannot complete.
26
+ - **Would have set a DSN and stopped**, leaving every issue attached to a release
27
+ with no commits and suspect-commit attribution permanently dead.
28
+
29
+ ### The measurement the skill is built on
30
+
31
+ `sentry-sdk` 2.19.2, run 2026-08-24. The built-in `EventScrubber` is enabled by
32
+ default and matches on KEY NAMES against a 32-entry denylist. It does not inspect
33
+ string values, and neither `dsn` nor `database_url` is on that list.
34
+
35
+ ```python
36
+ event = {"message": "could not connect to postgresql://u:SUPERSECRET@host/db"}
37
+ EventScrubber().scrub_event(event)
38
+ assert "SUPERSECRET" in str(event) # passes — not scrubbed
39
+ ```
40
+
41
+ A service that writes a database URL near an error therefore starts forwarding
42
+ that password to a third party the moment Sentry is added, with wider reach than
43
+ the log had. The skill ships both layers — and, more usefully, the test that
44
+ asserts layer one alone still leaks, so the day Sentry starts scrubbing values a
45
+ red test says the second layer may now be redundant.
46
+
47
+ ### Also in this release
48
+
49
+ - The 403 that reads as an auth failure and is not: `allowMemberProjectCreation:
50
+ false` blocks `project create` **even for an org owner**, because the policy is
51
+ evaluated against the token's scopes and the device-flow token carries no
52
+ `org:write`. The team-scoped endpoint fails identically. Measured, with the
53
+ three ways out ranked.
54
+ - Two different tools are called `sentry`. Commands from one silently do not
55
+ exist in the other, and they install into prefixes where the older copy tends
56
+ to win — 0.38.0 shadowing 0.43.0, observed here. The shadow check is one line.
57
+ - `$schema` was missing from `plugin.json` and from the marketplace entry. Added.
58
+
7
59
  ## v0.8.0 — 2026-08-20
8
60
 
9
61
  **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 six skills
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 six skills into `~/.claude/skills/`:
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 six skills into `~/.claude/skills/` and **carry no hook**. The gate is prose
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 six skills about the layer a product reaches once it has users.
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
- and the sixth is front-end performance. That subject is the reason this document
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 **52 files: 30 markdown, one plugin manifest, three
20
- files of the manual gate, and 18 files of money fixtures.** Two of the six skills now
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 | 22 | Text. Same. |
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` — 58 files, listed by
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 six skill directories out of the package into
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/` — six directories, named for the six 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 six names
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 six skills describe Stripe, Heleket and BTCPay,
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: 52 files.
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: 58 files.
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.8.0",
3
+ "version": "0.9.0",
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.8.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.0",
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.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.
@@ -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.