fix(sentry): remove hardcoded DSN fallbacks; supply via env in CI

Self-hosted instances no longer inherit the trails.cool flagship Sentry
DSNs. Code paths now read DSN strictly from env — unset = no Sentry
init, no events sent.

Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml
inject the public DSNs as workflow env vars, which feed into:
- runtime via \`infrastructure/app.env\` / \`staging.env\`
  (\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service)
- build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only;
  planner has no client Sentry init).

Sentry DSNs are public-by-design (transmitted unencrypted from the
client JS bundle), so embedding them as plaintext workflow env vars
is no worse than the runtime exposure. Forks should replace these with
their own DSNs or remove the workflow env lines to ship Sentry-free.

Files changed:
- apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback
- apps/planner/server.ts: same
- apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\`
  only; falsy = skip init
- apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build
- infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service
- infrastructure/docker-compose.staging.yml: same
- .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo
- .github/workflows/cd-staging.yml: same

Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-25 23:07:07 +02:00
parent f239b4adee
commit edb618fe40
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 55 additions and 21 deletions

View file

@ -13,6 +13,15 @@ concurrency:
group: deploy-apps group: deploy-apps
cancel-in-progress: true cancel-in-progress: true
# Public Sentry DSNs for the trails.cool flagship instance. Public by
# design — Sentry DSNs are transmitted unencrypted from the client JS
# bundle, embedding them in this workflow is no worse than embedding
# them in the runtime env. Self-hosted forks should either replace
# these with their own DSNs or remove the lines to ship without Sentry.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs: jobs:
build-images: build-images:
name: Build & Push Docker Images name: Build & Push Docker Images
@ -48,8 +57,12 @@ jobs:
tags: | tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
# VITE_SENTRY_DSN bakes the client-side DSN into the journal's
# built bundle. Only journal has a client Sentry init; planner
# ignores the build-arg if present.
build-args: | build-args: |
SENTRY_RELEASE=${{ github.sha }} SENTRY_RELEASE=${{ github.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: | secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -70,6 +83,9 @@ jobs:
echo "DOMAIN=trails.cool" >> infrastructure/app.env echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates. # Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env echo "IS_FLAGSHIP=true" >> infrastructure/app.env
# Sentry DSNs (public — see workflow top-level env for context).
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
- name: Copy files to server - name: Copy files to server
uses: appleboy/scp-action@v1 uses: appleboy/scp-action@v1

View file

@ -30,6 +30,12 @@ concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true cancel-in-progress: true
# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the
# "public by design" rationale.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs: jobs:
# ── Build ───────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────
# Tags: # Tags:
@ -89,6 +95,7 @@ jobs:
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: | build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }} SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: | secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -118,6 +125,8 @@ jobs:
echo "JOURNAL_IMAGE_TAG=staging" echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging" echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}" echo "SENTRY_RELEASE=${{ github.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
} >> infrastructure/staging.env } >> infrastructure/staging.env
- name: Copy compose file + env to server - name: Copy compose file + env to server
@ -220,6 +229,8 @@ jobs:
# PR-preview journals all share the persistent staging planner. # PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool" echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Generate per-PR Caddyfile snippet - name: Generate per-PR Caddyfile snippet

View file

@ -23,11 +23,16 @@ RUN pnpm install --frozen-lockfile
FROM base AS build FROM base AS build
ARG SENTRY_RELEASE ARG SENTRY_RELEASE
# Client-side Sentry DSN baked into the bundle at build time. Empty (or
# unset) produces a Sentry-free client. Public-by-design: the DSN
# appears in the shipped client JS regardless.
ARG VITE_SENTRY_DSN=""
COPY --from=deps /app/ ./ COPY --from=deps /app/ ./
COPY . . COPY . .
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \
SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
SENTRY_RELEASE="$SENTRY_RELEASE" \ SENTRY_RELEASE="$SENTRY_RELEASE" \
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
pnpm --filter @trails-cool/journal build pnpm --filter @trails-cool/journal build
FROM base AS runtime FROM base AS runtime

View file

@ -5,15 +5,12 @@ import { browserSentryConfig } from "@trails-cool/sentry-config";
let initialized = false; let initialized = false;
// Build-time DSN injection: `VITE_SENTRY_DSN` (if set during `pnpm build`) // Build-time DSN injection. `VITE_SENTRY_DSN` (set during `pnpm build`,
// overrides the flagship default so self-hosters can ship their own // see apps/journal/Dockerfile + cd-apps.yml) determines whether the
// Sentry project. Set it to `""` (empty string) to disable Sentry on // client emits Sentry events at all. Self-hosted builds without the
// the client entirely. // env var produce a Sentry-free client bundle.
const FLAGSHIP_JOURNAL_DSN = const CLIENT_DSN = (import.meta.env as Record<string, string | undefined>)
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"; .VITE_SENTRY_DSN;
const CLIENT_DSN =
(import.meta.env as Record<string, string | undefined>).VITE_SENTRY_DSN ??
FLAGSHIP_JOURNAL_DSN;
export function initSentryClient() { export function initSentryClient() {
if (initialized) return; if (initialized) return;

View file

@ -11,14 +11,13 @@ import { createBoss, startWorker } from "@trails-cool/jobs";
import { getDatabaseUrl } from "@trails-cool/db"; import { getDatabaseUrl } from "@trails-cool/db";
import postgres, { type Sql } from "postgres"; import postgres, { type Sql } from "postgres";
// Sentry DSN is read from env so self-hosted instances don't ship their // Sentry DSN is supplied via env. No hardcoded fallback — self-hosted
// errors to the trails.cool flagship Sentry by default. The flagship // instances default to no Sentry. Flagship sets SENTRY_DSN via
// keeps its DSN as the fallback; setting SENTRY_DSN="" (or any other // docker-compose env (see infrastructure/docker-compose.yml).
// truthy value) overrides. SENTRY_DISABLED=true skips init entirely. // SENTRY_DISABLED=true is an explicit opt-out even when a DSN is set
const FLAGSHIP_JOURNAL_SENTRY_DSN = // (useful for local prod-mode debugging).
"https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"; const sentryDsn = process.env.SENTRY_DSN;
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_JOURNAL_SENTRY_DSN; if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) {
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
Sentry.init({ Sentry.init({
dsn: sentryDsn, dsn: sentryDsn,
...nodeSentryConfig("journal server"), ...nodeSentryConfig("journal server"),

View file

@ -14,10 +14,8 @@ import { getDatabaseUrl } from "@trails-cool/db";
import postgres, { type Sql } from "postgres"; import postgres, { type Sql } from "postgres";
// See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract. // See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract.
const FLAGSHIP_PLANNER_SENTRY_DSN = const sentryDsn = process.env.SENTRY_DSN;
"https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"; if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) {
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_PLANNER_SENTRY_DSN;
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
Sentry.init({ Sentry.init({
dsn: sentryDsn, dsn: sentryDsn,
...nodeSentryConfig("planner server"), ...nodeSentryConfig("planner server"),

View file

@ -53,6 +53,7 @@ services:
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
SENTRY_DSN: ${SENTRY_DSN_JOURNAL:-}
SENTRY_RELEASE: ${SENTRY_RELEASE:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-}
SMTP_URL: "" SMTP_URL: ""
SMTP_FROM: trails.cool staging <noreply@staging.trails.cool> SMTP_FROM: trails.cool staging <noreply@staging.trails.cool>
@ -89,6 +90,7 @@ services:
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/${STAGING_DATABASE} DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/${STAGING_DATABASE}
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
SENTRY_DSN: ${SENTRY_DSN_PLANNER:-}
SENTRY_RELEASE: ${SENTRY_RELEASE:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-}
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"] test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"]

View file

@ -42,6 +42,11 @@ services:
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set in SOPS secrets.app.env} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set in SOPS secrets.app.env}
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
# SENTRY_DSN per service (journal + planner have separate Sentry
# projects on the same org). Empty = no Sentry init (correct
# default for self-hosted instances). See cd-apps.yml for how the
# flagship populates this.
SENTRY_DSN: ${SENTRY_DSN_JOURNAL:-}
SENTRY_RELEASE: ${SENTRY_RELEASE:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-}
SMTP_URL: ${SMTP_URL:-} SMTP_URL: ${SMTP_URL:-}
SMTP_FROM: ${SMTP_FROM:-trails.cool <noreply@trails.cool>} SMTP_FROM: ${SMTP_FROM:-trails.cool <noreply@trails.cool>}
@ -87,6 +92,7 @@ services:
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/trails DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in SOPS secrets.app.env}@postgres:5432/trails
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
SENTRY_DSN: ${SENTRY_DSN_PLANNER:-}
SENTRY_RELEASE: ${SENTRY_RELEASE:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-}
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"] test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"]