diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 701416c..36c18e4 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,6 +13,15 @@ concurrency: group: deploy-apps 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: build-images: name: Build & Push Docker Images @@ -48,8 +57,12 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest 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: | SENTRY_RELEASE=${{ github.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -70,6 +83,9 @@ jobs: echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. 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 uses: appleboy/scp-action@v1 diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index 9f63ef0..5cd5a0b 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -30,6 +30,12 @@ concurrency: group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} 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: # ── Build ───────────────────────────────────────────────────────────── # Tags: @@ -89,6 +95,7 @@ jobs: ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} build-args: | SENTRY_RELEASE=${{ steps.tags.outputs.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -118,6 +125,8 @@ jobs: echo "JOURNAL_IMAGE_TAG=staging" echo "PLANNER_IMAGE_TAG=staging" echo "SENTRY_RELEASE=${{ github.sha }}" + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" } >> infrastructure/staging.env - name: Copy compose file + env to server @@ -220,6 +229,8 @@ jobs: # PR-preview journals all share the persistent staging planner. echo "PLANNER_URL=https://planner.staging.trails.cool" 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 - name: Generate per-PR Caddyfile snippet diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 585803c..86a0a89 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -23,11 +23,16 @@ RUN pnpm install --frozen-lockfile FROM base AS build 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 . . 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_RELEASE="$SENTRY_RELEASE" \ + VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \ pnpm --filter @trails-cool/journal build FROM base AS runtime diff --git a/apps/journal/app/lib/sentry.client.ts b/apps/journal/app/lib/sentry.client.ts index f68cba3..8ecaf65 100644 --- a/apps/journal/app/lib/sentry.client.ts +++ b/apps/journal/app/lib/sentry.client.ts @@ -5,15 +5,12 @@ import { browserSentryConfig } from "@trails-cool/sentry-config"; let initialized = false; -// Build-time DSN injection: `VITE_SENTRY_DSN` (if set during `pnpm build`) -// overrides the flagship default so self-hosters can ship their own -// Sentry project. Set it to `""` (empty string) to disable Sentry on -// the client entirely. -const FLAGSHIP_JOURNAL_DSN = - "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"; -const CLIENT_DSN = - (import.meta.env as Record).VITE_SENTRY_DSN ?? - FLAGSHIP_JOURNAL_DSN; +// Build-time DSN injection. `VITE_SENTRY_DSN` (set during `pnpm build`, +// see apps/journal/Dockerfile + cd-apps.yml) determines whether the +// client emits Sentry events at all. Self-hosted builds without the +// env var produce a Sentry-free client bundle. +const CLIENT_DSN = (import.meta.env as Record) + .VITE_SENTRY_DSN; export function initSentryClient() { if (initialized) return; diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 90c3c39..901db0f 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -11,14 +11,13 @@ import { createBoss, startWorker } from "@trails-cool/jobs"; import { getDatabaseUrl } from "@trails-cool/db"; import postgres, { type Sql } from "postgres"; -// Sentry DSN is read from env so self-hosted instances don't ship their -// errors to the trails.cool flagship Sentry by default. The flagship -// keeps its DSN as the fallback; setting SENTRY_DSN="" (or any other -// truthy value) overrides. SENTRY_DISABLED=true skips init entirely. -const FLAGSHIP_JOURNAL_SENTRY_DSN = - "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"; -const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_JOURNAL_SENTRY_DSN; -if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") { +// Sentry DSN is supplied via env. No hardcoded fallback — self-hosted +// instances default to no Sentry. Flagship sets SENTRY_DSN via +// docker-compose env (see infrastructure/docker-compose.yml). +// SENTRY_DISABLED=true is an explicit opt-out even when a DSN is set +// (useful for local prod-mode debugging). +const sentryDsn = process.env.SENTRY_DSN; +if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) { Sentry.init({ dsn: sentryDsn, ...nodeSentryConfig("journal server"), diff --git a/apps/planner/server.ts b/apps/planner/server.ts index a8d6e86..43c9ce8 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -14,10 +14,8 @@ import { getDatabaseUrl } from "@trails-cool/db"; import postgres, { type Sql } from "postgres"; // See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract. -const FLAGSHIP_PLANNER_SENTRY_DSN = - "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"; -const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_PLANNER_SENTRY_DSN; -if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") { +const sentryDsn = process.env.SENTRY_DSN; +if (process.env.SENTRY_DISABLED !== "true" && sentryDsn) { Sentry.init({ dsn: sentryDsn, ...nodeSentryConfig("planner server"), diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index b5c6c00..29fae42 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -53,6 +53,7 @@ services: SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} NODE_ENV: production PORT: 3000 + SENTRY_DSN: ${SENTRY_DSN_JOURNAL:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-} SMTP_URL: "" SMTP_FROM: trails.cool staging @@ -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} NODE_ENV: production PORT: 3001 + SENTRY_DSN: ${SENTRY_DSN_PLANNER:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-} healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"] diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 029b211..1390257 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -42,6 +42,11 @@ services: SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set in SOPS secrets.app.env} NODE_ENV: production 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:-} SMTP_URL: ${SMTP_URL:-} SMTP_FROM: ${SMTP_FROM:-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 NODE_ENV: production PORT: 3001 + SENTRY_DSN: ${SENTRY_DSN_PLANNER:-} SENTRY_RELEASE: ${SENTRY_RELEASE:-} healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3001/health || exit 1"]