Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 39 additions & 32 deletions docs/error-sdk-handoff.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,62 @@
# App-side Error SDK — DSN Handoff (GlitchTip)

How the GlitchTip project DSN reaches the app workloads so an errors-only Sentry SDK can report
How each GlitchTip project DSN reaches its app workload so an errors-only Sentry SDK can report
to GlitchTip. Infra owns the wiring; the per-app SDK code is owned in the app repos (wire briefs:
`../plans/<app>/SENTRY-WIRE-BRIEF.md`).

## The DSN
## Model: one project per app, one org per environment

| Field | Value |
|-------|-------|
| GlitchTip org / project | `solidstats` / `staging` (PROJECT_ID `1`) |
| Public key (non-secret client key) | `e771bce6-706f-4deb-b308-0e4ba12fb233` |
| **DSN the apps use** | `https://e771bce6-706f-4deb-b308-0e4ba12fb233@errors.solid-stats.ru/1` |
Each app reports to its **own** GlitchTip project, so issue lists / DSNs / quotas are separated per
service. Environments are separated at the **organization** level (`staging`, `production`).

The **public-URL** form is used on purpose: app pods in `solid-stats-staging` egress freely to the
public edge, and the edge → glitchtip-web ingress source is the cni0 gateway `10.42.0.1`, which the
Phase 17 NetworkPolicy already allows. So no new NetworkPolicy is required.
| App | Env | GlitchTip org / project (id) | DSN the app uses |
|-----|-----|------------------------------|------------------|
| `server-2` | staging | `staging` / `server-2` (2) | `https://32d59bfbb7ea43e0bba7d91d79405c77@errors.solid-stats.ru/2` |
| `replays-fetcher` | staging | `staging` / `replays-fetcher` (3) | `https://b8337da501bf411c8e8cb454e66e9f28@errors.solid-stats.ru/3` |
| `replay-parser-2` | staging | `staging` / `replay-parser-2` (4) | `https://1c0d52572d6d4813b25329e46f5ea83b@errors.solid-stats.ru/4` |
| `server-2` | production | `production` / `server-2` (5) | `https://5b4b2f26b87c4fafbbeb1d199df077b2@errors.solid-stats.ru/5` |
| `replays-fetcher` | production | `production` / `replays-fetcher` (6) | `https://8d1bd54c772e45f2b0deb916e6d9a9fc@errors.solid-stats.ru/6` |
| `replay-parser-2` | production | `production` / `replay-parser-2` (7) | `https://a78e1215080149bb9e3b216318ae98ee@errors.solid-stats.ru/7` |

> In-cluster alternative (NOT used): `http://e771bce6-…@glitchtip-web.error-tracking.svc:8000/1`.
> This direct path is blocked by the Phase 17 `error-tracking` default-deny ingress (only the edge +
> intra-ns are allowed). Using it would require adding a `from: solid-stats-staging` selector to
> `allow-glitchtip-web-ingress` in `k8s/observability/96-netpol-error-tracking.yaml` (the Phase 18
> note in that file). Prefer the public URL until there's a reason to switch.
The DSN public key is a non-secret client ingest key. The **public-URL** form is used on purpose:
app pods egress freely to the public edge, and the edge → glitchtip-web ingress source is the cni0
gateway `10.42.0.1`, which the Phase 17 NetworkPolicy already allows. No new NetworkPolicy is required.

## Handoff chain
## Handoff chain (per app)

```
GlitchTip project DSN
→ GitHub `staging` environment secret SENTRY_DSN
→ scripts/render-staging-secrets.py (injects SENTRY_DSN into each <app>-runtime k8s Secret)
→ k8s Secret server-2-runtime / replay-parser-2-runtime / replays-fetcher-runtime
GlitchTip project DSN (per app, per env)
→ GitHub <env> environment secret SENTRY_DSN_SERVER_2 | SENTRY_DSN_REPLAYS_FETCHER | SENTRY_DSN_REPLAY_PARSER_2
→ scripts/render-<env>-secrets.py (injects each into the matching <app>-runtime Secret as SENTRY_DSN)
→ k8s Secret server-2-runtime / replays-fetcher-runtime / replay-parser-2-runtime
→ Deployment/CronJob envFrom: secretRef → env var SENTRY_DSN
→ app SDK init (Sentry.init / sentry::init reads SENTRY_DSN)
```

`SENTRY_DSN` is **optional** in the renderer. An empty value makes the Sentry SDK a no-op, so an app
can ship the SDK code before the secret is set (and the secret can be set before the SDK ships)
without breaking deploys. No manifest change is needed — every app workload already pulls its env
via `envFrom: secretRef: <app>-runtime`.
The in-pod env var name stays `SENTRY_DSN` for every app — only the value differs per app. Each DSN
is **optional** in the renderer: an empty value makes the SDK a no-op, so an app can ship the SDK
code before its secret is set (and vice versa) without breaking deploys. No manifest change is
needed — every app workload already pulls its env via `envFrom: secretRef: <app>-runtime`.

## Operator steps (infra side — done in Phase 18)
## Operator steps

```bash
# 1. Set the GitHub staging secret (value = the public-URL DSN above)
gh secret set SENTRY_DSN --env staging --body 'https://e771bce6-…@errors.solid-stats.ru/1'
**Staging — done:**

# 2. Render + apply the runtime Secrets (CI does this on deploy; or live):
SENTRY_DSN=… <other runtime envs> python3 scripts/render-staging-secrets.py | kubectl apply -f -
```bash
# Three per-app DSN secrets in the GitHub `staging` environment:
gh secret set SENTRY_DSN_SERVER_2 --env staging -R solid-stats/infrastructure --body '…/2'
gh secret set SENTRY_DSN_REPLAYS_FETCHER --env staging -R solid-stats/infrastructure --body '…/3'
gh secret set SENTRY_DSN_REPLAY_PARSER_2 --env staging -R solid-stats/infrastructure --body '…/4'
# render-staging-secrets.py reads them; CI renders + applies the runtime Secrets on deploy.
```

The env var reaches the pods on their next rollout — no forced restart is needed just to carry an
unused var; it becomes live when an app actually wires the SDK and redeploys.
**Production — pending prod infra.** The three production projects exist (ids 5/6/7) but there is no
`render-production-secrets.py` / production namespace yet. When prod is stood up, set the **same three
secret names** in the GitHub `production` environment (values = the production DSNs above); the prod
renderer reuses the names, so no further change here.

The env var reaches the pods on their next rollout — it becomes live when an app wires the SDK and
redeploys.

## Errors-only policy (all apps)

Expand Down
20 changes: 11 additions & 9 deletions scripts/render-staging-secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ def secret(name: str, values: dict[str, str], secret_type: str = "Opaque") -> st
s3_access_key_id = required("S3_ACCESS_KEY_ID")
s3_secret_access_key = required("S3_SECRET_ACCESS_KEY")

# SENTRY_DSN: GlitchTip project DSN for the app-side error SDK (Phase 18). Optional —
# an empty DSN makes the Sentry SDK a no-op, so app deploys never break before an app
# wires the SDK. Value is the public-URL form https://<public_key>@errors.solid-stats.ru/1
# (apps egress to the public edge; the edge->glitchtip ingress is already allowed by the
# Phase 17 netpol). Injected into every app runtime Secret via envFrom. See
# SENTRY_DSN (per-app): each app reports to its OWN GlitchTip project, so each app gets a
# distinct DSN injected as SENTRY_DSN into its runtime Secret. Optional — an empty DSN makes
# the Sentry SDK a no-op, so app deploys never break before an app wires the SDK. Public-URL
# form https://<public_key>@errors.solid-stats.ru/<project_id> (apps egress to the public
# edge; the edge->glitchtip ingress is allowed by the Phase 17 netpol). See
# docs/error-sdk-handoff.md and ../plans/<app>/SENTRY-WIRE-BRIEF.md.
sentry_dsn = os.environ.get("SENTRY_DSN", "")
sentry_dsn_server2 = os.environ.get("SENTRY_DSN_SERVER_2", "")
sentry_dsn_parser = os.environ.get("SENTRY_DSN_REPLAY_PARSER_2", "")
sentry_dsn_fetcher = os.environ.get("SENTRY_DSN_REPLAYS_FETCHER", "")

fetcher_replay_source_url = required("REPLAYS_FETCHER_REPLAY_SOURCE_URL")
fetcher_replay_source_transport = os.environ.get("REPLAYS_FETCHER_REPLAY_SOURCE_TRANSPORT", "direct")
Expand Down Expand Up @@ -104,7 +106,7 @@ def secret(name: str, values: dict[str, str], secret_type: str = "Opaque") -> st
"S3_BUCKET": s3_bucket,
"S3_ACCESS_KEY_ID": s3_access_key_id,
"S3_SECRET_ACCESS_KEY": s3_secret_access_key,
"SENTRY_DSN": sentry_dsn,
"SENTRY_DSN": sentry_dsn_server2,
},
),
secret(
Expand All @@ -114,10 +116,10 @@ def secret(name: str, values: dict[str, str], secret_type: str = "Opaque") -> st
"REPLAY_PARSER_S3_BUCKET": s3_bucket,
"AWS_ACCESS_KEY_ID": s3_access_key_id,
"AWS_SECRET_ACCESS_KEY": s3_secret_access_key,
"SENTRY_DSN": sentry_dsn,
"SENTRY_DSN": sentry_dsn_parser,
},
),
secret("replays-fetcher-runtime", {**fetcher_runtime, "SENTRY_DSN": sentry_dsn}),
secret("replays-fetcher-runtime", {**fetcher_runtime, "SENTRY_DSN": sentry_dsn_fetcher}),
]

print("\n---\n".join(documents))