From 1caa821cf937e94946c87f5a01b4737996f7b962 Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Tue, 29 Sep 2026 17:31:40 -0400 Subject: [PATCH 1/2] fix(sentry): give the server SDK the release the client already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client bundle gets SENTRY_RELEASE injected by the Sentry plugin. The server got nothing: there is no global scope in Node for the plugin to write to, and @sentry/node has no SENTRY_RELEASE env fallback of its own — grepping the installed package returns no hits. So every server-side event went out with no release, and since ~99% of this project's error volume is server-side, that is nearly all of it. Two changes, and neither is sufficient alone: - other/Dockerfile redeclares COMMIT_SHA in the final stage. ARG does not cross a FROM, so the build stage's declaration left the running container with no idea which commit it was. The value reaches vite.config.ts either way, which is why the client always looked right. - server/utils/monitoring.ts sets `release` explicitly rather than relying on an injected global, which also keeps working if the bundler integration is ever dropped. Closes #15. The source maps this depends on only started uploading with #16: filesToDeleteAfterUpload runs in the plugin's writeBundle `finally` whether or not the upload succeeded, so before then the server bundle's maps were generated and then deleted. The last deploy's log now shows server-build-*.js uploading alongside the client bundles. --- other/Dockerfile | 8 +++++ server/utils/monitoring.ts | 12 +++++++ tests/sentry-release.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/other/Dockerfile b/other/Dockerfile index ab91676..9ccf309 100644 --- a/other/Dockerfile +++ b/other/Dockerfile @@ -76,6 +76,14 @@ RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN,required=true \ # Finally, build the production image with minimal footprint FROM base +# `ARG` does not cross a `FROM`, so the value the build stage declared has to be +# redeclared here or the running container has no idea which commit it is. It is +# the release name the server SDK tags its events with (server/utils/monitoring.ts), +# so without it every server-side event arrives with no release and cannot be +# correlated to a deploy. See #15. +ARG COMMIT_SHA +ENV COMMIT_SHA=$COMMIT_SHA + ENV FLY="true" ENV INTERNAL_PORT="8080" ENV PORT="8081" diff --git a/server/utils/monitoring.ts b/server/utils/monitoring.ts index 8b22705..0c21f9a 100644 --- a/server/utils/monitoring.ts +++ b/server/utils/monitoring.ts @@ -5,6 +5,18 @@ export function init() { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV, + // Set explicitly rather than left to the bundler plugin's injected global. + // That injection only reaches the client bundle — there is no global scope + // in Node for the plugin to write to, and @sentry/node has no + // SENTRY_RELEASE env fallback of its own (grep the package: no hits). So + // without this line the server sends events with no release at all, and + // since ~99% of this project's error volume is server-side, that is + // nearly all of it. See #15. + // + // COMMIT_SHA is declared as an ARG/ENV in *both* stages of other/Dockerfile; + // ARG does not cross a FROM, so the build-stage declaration alone leaves + // this undefined at runtime. + release: process.env.COMMIT_SHA, tracesSampleRate: process.env.NODE_ENV === 'production' ? 1 : 0, denyUrls: [ /\/resources\/healthcheck/, diff --git a/tests/sentry-release.test.ts b/tests/sentry-release.test.ts index 9c5ab92..1111b13 100644 --- a/tests/sentry-release.test.ts +++ b/tests/sentry-release.test.ts @@ -53,6 +53,25 @@ const BUILD_ONLY = [ const read = (path: string) => readFileSync(path, 'utf8') +/** + * The client gets `SENTRY_RELEASE` injected into its bundle by the Sentry plugin; + * the server gets nothing. There is no global scope in Node for the plugin to + * write to, and @sentry/node has no SENTRY_RELEASE env fallback of its own, so + * the release name has to be handed to the server SDK explicitly. + * + * Mocked rather than asserted with a regex over monitoring.ts, because `init()` + * is callable and the thing worth protecting is the value that reaches the SDK — + * not the text of the line that sets it. + */ +const { sentry } = vi.hoisted(() => ({ sentry: { init: vi.fn() } })) + +vi.mock('@sentry/node', () => ({ + init: (...args: unknown[]) => sentry.init(...args), + // Named in `integrations` by server/utils/monitoring.ts, so the mock has to + // satisfy it even though this file never inspects it. + httpIntegration: () => ({ name: 'Http' }), +})) + /** Every distinct capture of `pattern` in `source`, sorted. */ function matchAll(source: string, pattern: RegExp) { return [ @@ -230,6 +249,50 @@ describe('Sentry release management build inputs', () => { }) }) +describe('the release reaches the running server', () => { + it('redeclares COMMIT_SHA in the runtime stage, since ARG does not cross FROM', () => { + const stage = runtimeStage() + // Both lines: `ARG` alone makes it a build-time-only value, and `ENV` is + // what the running process actually reads. + expect(stage).toMatch(/^ARG COMMIT_SHA$/m) + expect(stage).toMatch(/^ENV COMMIT_SHA=\$COMMIT_SHA$/m) + }) + + it('tags the SDK with the release, so server events are attributable', async () => { + const commit = 'a'.repeat(40) + vi.stubEnv('COMMIT_SHA', commit) + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('SENTRY_DSN', 'https://abc@o0.ingest.sentry.io/0') + + const { init } = await import( + /* @vite-ignore */ '../server/utils/monitoring.ts' + ) + init() + + const [options] = sentry.init.mock.calls[0] as [Record] + expect(options.release).toBe(commit) + }) +}) + +/** + * The final stage of the Dockerfile — the image that actually runs in + * production, as opposed to the `as build` stage that only produces it. + * + * This is where `COMMIT_SHA` has to be *redeclared*. `ARG` does not cross a + * `FROM`, so the build stage's declaration says nothing about what ends up in the + * running container, which is why the server had no release for two years while + * the client bundle looked perfectly correct. + */ +function runtimeStage() { + const dockerfile = read(DOCKERFILE) + const start = dockerfile.indexOf('\nFROM base\n') + expect( + start, + `could not find the final stage in ${DOCKERFILE}`, + ).toBeGreaterThan(-1) + return dockerfile.slice(start) +} + describe('Sentry credentials at the build/runtime boundary', () => { // #16 removes the three build-only secrets from the Fly *runtime* // environment once the build is proven. That is a one-line, unreviewable From fc67092830a62e221b8aa9a5954b6d99b42179d7 Mon Sep 17 00:00:00 2001 From: Arpit Dalal Date: Tue, 29 Sep 2026 17:33:29 -0400 Subject: [PATCH 2/2] test(sentry): move runtimeStage() next to the other Dockerfile helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function declarations hoist, so it worked where it was — two hundred lines below its only use site, and below the describe block that reads it. Grouped with buildStage() so the two halves of the Dockerfile are described side by side. --- tests/sentry-release.test.ts | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/sentry-release.test.ts b/tests/sentry-release.test.ts index 1111b13..3b0662c 100644 --- a/tests/sentry-release.test.ts +++ b/tests/sentry-release.test.ts @@ -102,6 +102,25 @@ function buildStage() { return dockerfile.slice(start, end) } +/** + * The final stage of the Dockerfile — the image that actually runs in + * production, as opposed to the `as build` stage that only produces it. + * + * This is where `COMMIT_SHA` has to be *redeclared*. `ARG` does not cross a + * `FROM`, so the build stage's declaration says nothing about what ends up in the + * running container, which is why the server had no release for two years while + * the client bundle looked perfectly correct. + */ +function runtimeStage() { + const dockerfile = read(DOCKERFILE) + const start = dockerfile.indexOf('\nFROM base\n') + expect( + start, + `could not find the final stage in ${DOCKERFILE}`, + ).toBeGreaterThan(-1) + return dockerfile.slice(start) +} + /** * Every build input `getSentryPlugin()` reads, as named in `vite.config.ts`. * @@ -274,25 +293,6 @@ describe('the release reaches the running server', () => { }) }) -/** - * The final stage of the Dockerfile — the image that actually runs in - * production, as opposed to the `as build` stage that only produces it. - * - * This is where `COMMIT_SHA` has to be *redeclared*. `ARG` does not cross a - * `FROM`, so the build stage's declaration says nothing about what ends up in the - * running container, which is why the server had no release for two years while - * the client bundle looked perfectly correct. - */ -function runtimeStage() { - const dockerfile = read(DOCKERFILE) - const start = dockerfile.indexOf('\nFROM base\n') - expect( - start, - `could not find the final stage in ${DOCKERFILE}`, - ).toBeGreaterThan(-1) - return dockerfile.slice(start) -} - describe('Sentry credentials at the build/runtime boundary', () => { // #16 removes the three build-only secrets from the Fly *runtime* // environment once the build is proven. That is a one-line, unreviewable