Repository navigation
Server-side Sentry events carry no release — COMMIT_SHA is dropped at the end of the Docker build stage #15
Description
Server-side Sentry events carry no release at all. The client bundle gets one injected at build time; the server has no equivalent, and COMMIT_SHA is not present in the runtime container. Since ~99% of this project's error volume is server-side, almost none of it is attributable to a commit.
Consequences:
- Sentry's Resolve → Resolved In dropdown offers a stale release (
8a7cb2c7f28a, deployed 03:25Z) instead of the current one, because the events it would be resolving have no release to reason about. - Triaging "did this start after deploy X?" is manual — the Sentry UI cannot answer it.
- Regressions cannot be correlated to a commit via Sentry's suspect-commit/commit-context features.
The build-time release machinery itself is working correctly and is not part of this issue. Do not go looking for a bug in vite.config.ts — there isn't one.
The client half works, and here is the proof
The deployed client bundle contains the injected release global. From https://arpitdalal.dev/assets/index-CuGSVhxJ.js:
n.SENTRY_RELEASE={id:"4b7771cbfcdc6db7909936f67d81133b0f00defc"};
n._sentryDebugIds[t]="1e87b83c-659c-447e-a627-51411afb9c77";_sentryDebugIds is only emitted when the plugin uploads source maps, which only happens after a release is created. So COMMIT_SHA reaches the build, the auth token is mounted, the plugin runs, source maps upload, and the release exists in Sentry.
Confirmed from the deployment side too:
flyctl image show --app arpitdalal-dev-256c
→ Labels: {"GH_EVENT_NAME":"push","GH_SHA":"4b7771cbfcdc6db7909936f67d81133b0f00defc", ...}
flyctl releases --app arpitdalal-dev-256c
→ v86, created 2026-09-27T15:19:06Z, status complete
You can reproduce the first check at any time:
curl -s https://arpitdalal.dev/ -o /tmp/prod.html
grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' /tmp/prod.html | sort -u
curl -s "https://arpitdalal.dev/assets/<that-file>" | grep -o 'SENTRY_RELEASE={id:"[^"]*"}'The server half has no release
server/utils/monitoring.ts:5:
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === 'production' ? 1 : 0,
denyUrls: [...],
integrations: [Sentry.httpIntegration(), nodeProfilingIntegration()],
tracesSampler(samplingContext) { ... },
})No release. The SDK reads SENTRY_RELEASE from the global scope, but there is no global scope in Node, and the Vite plugin's injection is a client-build concern — the SSR bundle gets no equivalent. Nothing else supplies it.
Root cause: ARG scope in the Dockerfile
other/Dockerfile:29-32, the build stage:
FROM base as build
ARG COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHAand other/Dockerfile:46-51, the final stage that actually runs in production:
FROM base
ENV FLY="true"
ENV INTERNAL_PORT="8080"
ENV PORT="8081"
ENV NODE_ENV="production"COMMIT_SHA stops existing at the end of the build stage. The image that runs in production has never had it. The value reaches vite.config.ts:89-91 — which is why the client is fine — and then is thrown away:
release: {
name: process.env.COMMIT_SHA,
setCommits: { auto: true },
},So even the obvious one-line fix does not compile today: release: process.env.COMMIT_SHA in server/utils/monitoring.ts would evaluate to undefined at runtime. Both halves are needed.
How much is actually unattributed
app/utils/monitoring.client.tsx:47 sets replaysOnErrorSampleRate: 1.0, so every browser session containing an error should burn a replay. The Aug 30 – Sep 29 2026 billing period consumed 9 / 50 replays against 4,153 / 5,000 error events. Client-side errors therefore happened in single-digit sessions, and roughly 4,150 of 4,153 events are server-side — all of them currently released as nothing.
That is also the set of events the Sentry issue list is dominated by, which is why the Resolve dropdown looked wrong.
Solution
1. Carry COMMIT_SHA into the runtime image
In other/Dockerfile, add to the final stage (after the FROM base at line 46, alongside the other ENVs):
ARG COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHAAn ARG is required to re-declare the build argument in a new stage — it does not inherit across FROM. Without the ARG, ENV COMMIT_SHA=$COMMIT_SHA resolves to empty.
Note the value flows in from .github/workflows/deploy.yml, which already passes it correctly:
flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} \
--build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}No workflow change is needed.
2. Set release explicitly in the server SDK
server/utils/monitoring.ts:6:
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.COMMIT_SHA,
...
})Being explicit is better than relying on the global here even after the env var exists: it keeps the association working if the bundler integration is ever removed, and it makes the dependency visible to anyone reading the file.
3. Leave the client as-is
app/utils/monitoring.client.tsx needs no change. The injected SENTRY_RELEASE global already does the right thing, and adding release there would mean threading COMMIT_SHA through getEnv() in app/utils/env.server.ts and envSchema in app/utils/schemas.ts — more surface for no gain. If you would rather have it explicit and symmetric, that is a reasonable alternative, but it is not required and it is not cheaper.
Acceptance criteria
-
A server-side error surfaces in Sentry tagged with the release of the commit that is deployed. Verify by triggering one and checking the release shown on the issue.
-
A direct check that the variable survives into the runtime image:
flyctl ssh -C 'printenv COMMIT_SHA' --app arpitdalal-dev-256cmust print the full 40-char SHA of the deployed commit, and it must match
flyctl image show'sGH_SHAlabel. -
The Sentry Resolve → Resolved In dropdown offers the currently-deployed release rather than a stale one.
-
Server stack traces resolve to original source, not minified frames. This is the part most likely to still be broken after the release is tagged — see the note below.
-
npm run validateandnpm run test:runpass.
A caveat worth checking before you close this
vite.config.ts:96 sets filesToDeleteAfterUpload: ['./build/**/*.map']. Following #13, the server bundle is produced by the Vite SSR build into build/server/ rather than by esbuild into server-build/, so server maps are now inside the glob and should upload for the first time. That is a genuine improvement from #13 and worth confirming, but it also means server stack-trace symbolication is newly in play and may surface mapping issues that were previously invisible because no server maps were ever uploaded.
Do not treat a good-looking release tag as proof that server symbolication works. Trigger a real server error and check the stack trace reads as TypeScript. If it does not, that is a separate problem — file it separately rather than folding it in here.
Tests
tests/ has no Sentry init coverage today. A cheap regression guard is worth adding: assert that the built server bundle contains the release value, so a future Dockerfile edit that drops the ARG fails loudly instead of silently regressing. The repo already has a Docker-adjacent smoke test in tests/server.smoke.test.ts — check how it provisions the server and extend it if it can read the runtime env cheaply.
A unit test is not practical here: server/utils/monitoring.ts calls Sentry.init at module scope with no seam for injection, and the value under test is a build-time constant. Assert on the artifact, not the function.
Related
- Independent of Tier 2: replace custom icon build with vite-plugin-icons-spritesheet, and drop the esbuild server build for native Node TS stripping #8, Tier 3 (optional/deferred): Tailwind 4 CSS-first config, move CSP out of Express, Vite 8 + TypeScript 7 #9, Umami is blocked by the CSP (missing nonce on the only script without one) — diagnose, then fix or remove #10, Remove the stale epic-stack metadata from package.json and document independent maintenance #11. The change is two
Dockerfilelines and oneSentry.initkey. - Sequence it after build: generate icons from a Vite plugin and run the server as TypeScript #13 has settled. build: generate icons from a Vite plugin and run the server as TypeScript #13 restructured the server build, and this touches the same
other/Dockerfile; landing it on top of a freshly-merged refactor is easier to review than landing it into one. - Related, not in scope here:
SENTRY_AUTH_TOKEN,SENTRY_ORG, andSENTRY_PROJECTare currently Fly runtime secrets, so the production process holds a Sentry API token in its environment. The token is only needed at build time (the workflow already passes it via--build-secret), and the SDK only needs the DSN. That is a separate credential-hygiene issue and should be filed on its own. - Related context: Honeypot turns a malformed spam field into an unhandled server error (atob / AES-GCM OperationError) #14 (honeypot) is currently the only genuinely open bug, and the five Sentry issues fixed by
4b7771ccan be closed now — this issue does not gate them.
Activity
arpitdalal commentedon Sep 27, 2026
Correction: the release is probably not being created
While investigating credential handling for #16, I found the mechanism that actually explains the missing release — and it invalidates the evidence section above.
#16 root cause:
SENTRY_ORGandSENTRY_PROJECTare read atvite.config.ts:87-88and have never been available during a remote build. Not in GitHub Actions secrets (gh secret listshows onlyFLY_API_TOKENandSENTRY_AUTH_TOKEN), not passed as--build-secret, not visible to the build from Fly's runtime secret store, and.envis excluded byother/Dockerfile.dockerignore. So both evaluate toundefinedon every deploy.The plugin then hits this guard in
@sentry/bundler-pluginand returns before creating anything:What this invalidates: the
_sentryDebugIdsline I cited above is not proof the release exists. Debug-ID injection happens during the bundle phase; release creation and source map upload happen later incloseBundle. The guard returns from the release step only and never un-injects anything. The bundle looks correct whether or not the upload ever ran. My statement that "the release is in Sentry — go look at Releases" was wrong.The
SENTRY_RELEASE={id:"4b7771c..."}value is injected locally fromCOMMIT_SHAand does not require a round trip, so it proves only thatCOMMIT_SHAreached the build.What still stands in this issue:
COMMIT_SHAreaches the build correctly.release, andCOMMIT_SHAis not present in the runtime image. That is unaffected by SENTRY_ORG/SENTRY_PROJECT never reach the build, so the Sentry plugin silently skips release creation and source map upload — and three build-only credentials are sitting in the Fly runtime environment #16 and still needs theARG/ENVin the final Dockerfile stage plusrelease: process.env.COMMIT_SHAinserver/utils/monitoring.ts.These two must be fixed together for server events to be attributed at all: #16 makes the build-time value available, this issue makes it available at runtime. Fixing only this one would tag the server with a correctly-named release that still has no source maps uploaded under it.
To confirm the diagnosis, read the remote build log (dashboard → your app → Builds → the build for the commit → Build log) and search for
No organization slug provided.flyctlhas no builds subcommand. If the warning is absent, the token must besntrys_-prefixed, the guard passed, and the cause is something else — file that separately.Merge pull request #22 from arpitdalal/issue-15-server-release