From 86f9527894eba64dfd5a3e5b32c671e572d90459 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:17:02 -0700 Subject: [PATCH] build(selfhost): wire Sentry source maps for release images --- .dockerignore | 3 +- .env.example | 9 ++++ .github/workflows/release-selfhost.yml | 47 +++++++++++++++++ .github/workflows/selfhost.yml | 5 ++ Dockerfile | 24 ++++++--- docs/self-host/configuration.md | 17 +++++- docs/self-hosting.md | 47 +++++++++++++++++ scripts/build-selfhost.mjs | 2 + scripts/validate-selfhost-sourcemap.mjs | 63 +++++++++++++++++++++++ src/selfhost/sentry.ts | 13 ++++- test/unit/selfhost-sentry-release.test.ts | 45 ++++++++++++++++ test/unit/selfhost-sentry.test.ts | 28 ++++++++++ 12 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 scripts/validate-selfhost-sourcemap.mjs create mode 100644 test/unit/selfhost-sentry-release.test.ts diff --git a/.dockerignore b/.dockerignore index 89cf858992..4e2da4791f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,8 @@ # Keep the build context lean — deps are installed fresh (npm ci) and the bundle is built in the image. node_modules **/node_modules -dist +dist/* +!dist/server.mjs dist-ssr .output .nitro diff --git a/.env.example b/.env.example index e9a7934f9a..61fd284585 100644 --- a/.env.example +++ b/.env.example @@ -178,6 +178,15 @@ GITTENSORY_REVIEW_DRAFT=false # # Claude usage telemetry → OTEL collector → Prometheus → the Claude usage dashboard. OFF by default. # CLAUDE_CODE_ENABLE_TELEMETRY=1 # enable; needs --profile observability (starts the otel-collector) + +# --- Sentry error tracking (optional) --- +# SENTRY_DSN= # enables self-host Sentry capture; unset = complete no-op +# SENTRY_ENVIRONMENT=production +# SENTRY_TRACES_SAMPLE_RATE=0 # traces are off by default; errors still report +# SENTRY_RELEASE= # custom images only: set this ONLY when you uploaded source maps for +# # the exact built bundle under this exact release id. Future official +# # images bake GITTENSORY_VERSION=gittensory-selfhost@, so do +# # not override SENTRY_RELEASE for those images. # OTEL_METRIC_EXPORT_INTERVAL=10000 # ms between metric exports (default 10s here; CLI default is 60s) # OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # override only for an external collector # diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index 9b4a7f3a66..40d7c6afc3 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -41,6 +41,42 @@ jobs: else echo "v=${GITHUB_REF_NAME#selfhost-v}" >> "$GITHUB_OUTPUT" fi + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${INPUT_VERSION}" + else + VERSION="${GITHUB_REF_NAME#selfhost-v}" + fi + echo "release=gittensory-selfhost@${VERSION}" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + cache: "npm" + + - name: Install deps + run: npm ci --ignore-scripts + + - name: Build release bundle + run: node scripts/build-selfhost.mjs --all + + - name: Validate release source map + run: node scripts/validate-selfhost-sourcemap.mjs + + - name: Upload Sentry source maps + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_RELEASE: ${{ steps.version.outputs.release }} + run: | + test -n "$SENTRY_AUTH_TOKEN" + test -n "$SENTRY_ORG" + test -n "$SENTRY_PROJECT" + npx -y @sentry/cli@latest releases new "$SENTRY_RELEASE" + npx -y @sentry/cli@latest releases set-commits "$SENTRY_RELEASE" --auto + npx -y @sentry/cli@latest sourcemaps inject dist + node scripts/validate-selfhost-sourcemap.mjs + npx -y @sentry/cli@latest sourcemaps upload --release="$SENTRY_RELEASE" dist - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -70,13 +106,24 @@ jobs: uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . + target: runtime-prebuilt platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GITTENSORY_VERSION=${{ steps.version.outputs.release }} provenance: true sbom: true + - name: Finalize Sentry release + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_RELEASE: ${{ steps.version.outputs.release }} + run: npx -y @sentry/cli@latest releases finalize "$SENTRY_RELEASE" + - name: GitHub Release if: github.event_name == 'push' uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 diff --git a/.github/workflows/selfhost.yml b/.github/workflows/selfhost.yml index aa791e38a4..b3c94a0136 100644 --- a/.github/workflows/selfhost.yml +++ b/.github/workflows/selfhost.yml @@ -12,6 +12,7 @@ on: - "src/selfhost/**" - "src/server.ts" - "scripts/build-selfhost.mjs" + - "scripts/validate-selfhost-sourcemap.mjs" - "scripts/register-selfhost.mjs" - "Dockerfile" - "docker-compose.yml" @@ -24,6 +25,7 @@ on: - "src/selfhost/**" - "src/server.ts" - "scripts/build-selfhost.mjs" + - "scripts/validate-selfhost-sourcemap.mjs" - "scripts/register-selfhost.mjs" - "Dockerfile" - "docker-compose.yml" @@ -70,6 +72,9 @@ jobs: - name: Build the self-host bundle run: node scripts/build-selfhost.mjs + - name: Validate self-host source map + run: node scripts/validate-selfhost-sourcemap.mjs + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build the Docker image diff --git a/Dockerfile b/Dockerfile index c9320d21b6..38fd032273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,18 +15,19 @@ COPY . . # --all: bundle every dependency into one self-contained dist/server.mjs, so the runtime image needs no # node_modules (≈10× smaller). The bundle has zero `cloudflare:*` imports (stubbed at build), so no loader. RUN node scripts/build-selfhost.mjs --all +RUN node scripts/validate-selfhost-sourcemap.mjs -# --- runtime: slim, non-root ---------------------------------------------------------------------------- -FROM public.ecr.aws/docker/library/node:24-slim AS runtime +# --- runtime base: slim, non-root ----------------------------------------------------------------------- +FROM public.ecr.aws/docker/library/node:24-slim AS runtime-base WORKDIR /app +ARG GITTENSORY_VERSION= ENV NODE_ENV=production \ PLATFORM=self-hosted \ PORT=8787 \ DATABASE_PATH=/data/gittensory.sqlite \ MIGRATIONS_DIR=/app/migrations \ - NPM_CONFIG_PREFIX=/home/node/.npm-global -COPY --from=build /app/dist ./dist -COPY --from=build /app/migrations ./migrations + NPM_CONFIG_PREFIX=/home/node/.npm-global \ + GITTENSORY_VERSION=${GITTENSORY_VERSION} # Optional: bake the Claude Code / Codex CLIs so the `claude-code` / `codex` subscription providers (#979) # work in-image. Build with `--build-arg INSTALL_AI_CLIS=true`. No credentials are baked — operators mint # CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token`) / codex auth at run time and pass it via the env. @@ -44,7 +45,7 @@ USER root # Optional: enable visual review via an external Chrome sidecar (e.g. `browserless/chrome:latest`). # Build with `--build-arg INSTALL_VISUAL_REVIEW=true` then set BROWSER_WS_ENDPOINT= at runtime. ARG INSTALL_VISUAL_REVIEW=false -COPY --from=build /app/package*.json ./ +COPY package*.json ./ RUN if [ "$INSTALL_VISUAL_REVIEW" = "true" ]; then npm install puppeteer-core@22.13.1 --ignore-scripts; fi # Data dir (the SQLite file) — owned by the unprivileged node user; mount a volume here to persist. RUN mkdir -p /data && chown -R node:node /data /app @@ -59,3 +60,14 @@ EXPOSE 8787 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8787)+'/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "dist/server.mjs"] + +# Maintainer release images are built from the already-built, Sentry-injected bundle in the workflow. The source map +# is uploaded to Sentry there and is deliberately not copied into the runtime image. +FROM runtime-base AS runtime-prebuilt +COPY --chown=node:node dist/server.mjs ./dist/server.mjs +COPY --chown=node:node migrations ./migrations + +# Default local/operator builds still build the bundle inside Docker, but only the JS bundle reaches runtime. +FROM runtime-base AS runtime +COPY --from=build --chown=node:node /app/dist/server.mjs ./dist/server.mjs +COPY --from=build --chown=node:node /app/migrations ./migrations diff --git a/docs/self-host/configuration.md b/docs/self-host/configuration.md index c8c8cab882..f188741d17 100644 --- a/docs/self-host/configuration.md +++ b/docs/self-host/configuration.md @@ -85,7 +85,20 @@ See [ai-providers.md](./ai-providers.md) for the full provider/model/effort/time | `AI_EMBED_BASE_URL` / `_MODEL` / `_PROVIDER` | Dedicated RAG embed provider | | `GITTENSORY_REPO_CONFIG_DIR` | Container-private per-repo config dir | +## Sentry environment variables + +Sentry is optional and self-host-only. Unset `SENTRY_DSN` means no SDK import, no events, and no runtime overhead. + +| Var | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `SENTRY_DSN` | Enables self-host error reporting. Keep it in `.env` or a mounted secret. | +| `SENTRY_ENVIRONMENT` | Environment name, default `production`. | +| `SENTRY_TRACES_SAMPLE_RATE` | Trace sampling, default `0`; errors still report when tracing is off. | +| `SENTRY_RELEASE` | Custom images only, and only when source maps for that exact built bundle were uploaded under the same release id. | +| `GITTENSORY_VERSION` | Baked into future official images as `gittensory-selfhost@` and used as the Sentry release when `SENTRY_RELEASE` is not explicitly set. | + ## Secrets — never commit them -`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `INTERNAL_JOB_TOKEN`, `TOKEN_ENCRYPTION_SECRET`, the App private -key, and the webhook secret live in `.env` / mounted files **only** — keep your deploy directory out of any repo. +`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `INTERNAL_JOB_TOKEN`, `TOKEN_ENCRYPTION_SECRET`, +`SENTRY_DSN`, the App private key, and the webhook secret live in `.env` / mounted files **only** — keep your +deploy directory out of any repo. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index e2c06385c3..184116e9b9 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -245,6 +245,11 @@ content-lane are not yet per-repo toggleable and stay on the allowlist.) in-flight job, checkpoints the WAL, and closes the DB before exiting. - **Logs** are structured JSON (`selfhost_listening`, `selfhost_migrations_applied`, `selfhost_ai_provider`, `selfhost_queue_recovered`, `selfhost_job_dead`, `selfhost_cron_error`, `selfhost_shutdown`, …). +- **Sentry error tracking.** Set `SENTRY_DSN` to capture self-host runtime errors. The SDK release is + `SENTRY_RELEASE` when set, otherwise the baked `GITTENSORY_VERSION` value. Future official images bake + `GITTENSORY_VERSION=gittensory-selfhost@` and the maintainer release workflow uploads the matching + source maps before the image is pushed. Custom images should leave `SENTRY_RELEASE` unset unless you uploaded + source maps for that exact bundle under that exact release id. - **Data + backup.** Everything is the single SQLite file on the `gittensory-data` volume (WAL mode). Back up by snapshotting the volume or copying the `.sqlite` file. Migrations are idempotent and re-checked at boot. For **continuous, point-in-time backup**, enable the optional [Litestream](https://litestream.io) sidecar in @@ -253,6 +258,48 @@ content-lane are not yet per-repo toggleable and stay on the allowlist.) - **App-level metrics.** Enable `GITTENSORY_REVIEW_OPS=true` for the read-only gate-block anomaly scan and the bearer-gated `GET /v1/internal/ops/stats` aggregate. +### Sentry source maps and release tracking + +This repo is not required to publish an official image on every self-host tweak. The source-map upload path is only +wired into the maintainer release workflow (`selfhost-v*` tags or manual `release-selfhost` runs), so normal PRs and +local operator builds do not upload anything to Sentry and do not need extra commands. + +Set the Sentry GitHub code mapping for the Sentry project to: + +| Sentry field | Value | +| ----------------- | ----- | +| Stack Trace Root | `/app` | +| Source Code Root | `.` | +| Branch | `main` | + +The maintainer release workflow expects: + +| GitHub setting | Value | +| --------------------------- | --------------------------------------------------------------------- | +| Secret `SENTRY_AUTH_TOKEN` | Sentry auth token allowed to create releases and upload source maps | +| Variable `SENTRY_ORG` | Sentry organization slug | +| Variable `SENTRY_PROJECT` | Sentry project slug | +| Sentry GitHub integration | Installed for `JSONbored/gittensory`, with the code mapping above | + +The workflow builds `dist/server.mjs` with `dist/server.mjs.map`, validates the `sourceMappingURL` and embedded +`sourcesContent`, injects Sentry debug ids, creates release `gittensory-selfhost@`, associates commits with +`set-commits --auto`, uploads the source maps, and then builds the image from that injected `dist/server.mjs`. +`dist/server.mjs.map` is **not** copied into the runtime image and is not served by the app; it only exists as a +private Sentry release artifact. + +For a custom image, source maps only work when the deployed JS bundle is the exact post-injection bundle whose map was +uploaded. If you build locally and do not upload maps, leave `SENTRY_RELEASE` unset. Events still report to Sentry, +but stack frames can remain bundled at `/app/dist/server.mjs`. + +If a new event still shows `/app/dist/server.mjs`: + +1. Confirm the event's `release` exactly matches the release that has the uploaded artifact bundle. +2. Confirm the image was built from the injected `dist/server.mjs`, not from a later Docker-internal rebuild. +3. Confirm the Sentry code mapping is `/app` → `.` on branch `main`. +4. Confirm `dist/server.mjs` had `//# sourceMappingURL=server.mjs.map` before upload and the map includes + `sourcesContent`. +5. Trigger a fresh event after the upload; old events may need reprocessing before they pick up newly uploaded maps. + --- ## 7. Scaling out — Postgres + Redis (multi-instance) diff --git a/scripts/build-selfhost.mjs b/scripts/build-selfhost.mjs index a8c9ee1c4f..49b4a750d7 100644 --- a/scripts/build-selfhost.mjs +++ b/scripts/build-selfhost.mjs @@ -18,6 +18,8 @@ await esbuild.build({ format: "esm", target: "node22", outfile: resolve(root, "dist/server.mjs"), + sourcemap: true, + sourcesContent: true, // External: nothing (bundle all) vs every package (external). node: builtins are always external on node. ...(bundleAll ? {} : { packages: "external" }), // Bundling CJS deps into an ESM output needs require/__dirname/__filename shimmed (some deps call them). diff --git a/scripts/validate-selfhost-sourcemap.mjs b/scripts/validate-selfhost-sourcemap.mjs new file mode 100644 index 0000000000..2f6229d606 --- /dev/null +++ b/scripts/validate-selfhost-sourcemap.mjs @@ -0,0 +1,63 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const bundlePath = resolve(root, "dist/server.mjs"); +const mapPath = resolve(root, "dist/server.mjs.map"); + +function fail(message) { + console.error(`self-host sourcemap validation failed: ${message}`); + process.exit(1); +} + +if (!existsSync(bundlePath)) fail("dist/server.mjs is missing"); +if (!existsSync(mapPath)) fail("dist/server.mjs.map is missing"); + +const bundle = readFileSync(bundlePath, "utf8"); +if (!bundle.includes("//# sourceMappingURL=server.mjs.map")) { + fail("dist/server.mjs is missing the server.mjs.map sourceMappingURL"); +} + +let map; +try { + map = JSON.parse(readFileSync(mapPath, "utf8")); +} catch (error) { + fail(`dist/server.mjs.map is not valid JSON (${error instanceof Error ? error.message : String(error)})`); +} + +if (map.version !== 3) fail("dist/server.mjs.map is not a version 3 source map"); +if (!Array.isArray(map.sources) || map.sources.length === 0) { + fail("dist/server.mjs.map has no original sources"); +} +if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + fail("dist/server.mjs.map must include sourcesContent for every original source"); +} +const serverSourceIndex = map.sources.findIndex((source) => + String(source).endsWith("src/server.ts"), +); +if (serverSourceIndex === -1) { + fail("dist/server.mjs.map does not include src/server.ts"); +} +if (map.sourcesContent[serverSourceIndex]?.trim() === "") { + fail("dist/server.mjs.map has empty source content for src/server.ts"); +} +const repoSourceIndexes = map.sources + .map((source, index) => [String(source), index]) + .filter(([source]) => source.startsWith("../src/")) + .map(([, index]) => index); +if (repoSourceIndexes.length === 0) { + fail("dist/server.mjs.map does not include repository sources"); +} +if ( + repoSourceIndexes.some( + (index) => + typeof map.sourcesContent[index] !== "string" || + map.sourcesContent[index].trim() === "", + ) +) { + fail("dist/server.mjs.map is missing source content for a repository source"); +} + +console.log( + `self-host sourcemap validation passed (${map.sources.length} original sources)`, +); diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts index 12d5af3221..7f71af50b8 100644 --- a/src/selfhost/sentry.ts +++ b/src/selfhost/sentry.ts @@ -9,6 +9,17 @@ let active = false; const SECRET_KEY = /(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i; +function nonBlank(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveSentryRelease( + env: NodeJS.ProcessEnv, +): string | undefined { + return nonBlank(env.SENTRY_RELEASE) ?? nonBlank(env.GITTENSORY_VERSION); +} + /** beforeSend scrubber — redact anything token/secret-like before an event leaves the box (privacy boundary). */ export function scrubEvent(event: T): T { const redact = (obj: unknown, depth: number): void => { @@ -41,7 +52,7 @@ export async function initSentry(env: NodeJS.ProcessEnv): Promise { Sentry.init({ dsn: env.SENTRY_DSN, environment: env.SENTRY_ENVIRONMENT ?? "production", - release: env.SENTRY_RELEASE ?? env.GITTENSORY_VERSION, + release: resolveSentryRelease(env), tracesSampleRate: Number(env.SENTRY_TRACES_SAMPLE_RATE ?? "0"), serverName: env.PUBLIC_API_ORIGIN, beforeSend: (e) => scrubEvent(e), diff --git a/test/unit/selfhost-sentry-release.test.ts b/test/unit/selfhost-sentry-release.test.ts new file mode 100644 index 0000000000..a291ef5a4e --- /dev/null +++ b/test/unit/selfhost-sentry-release.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => readFileSync(path, "utf8"); + +describe("self-host Sentry release wiring", () => { + it("keeps source-map uploads in the maintainer release workflow only", () => { + const releaseWorkflow = read(".github/workflows/release-selfhost.yml"); + expect(releaseWorkflow).toContain('sourcemaps inject dist'); + expect(releaseWorkflow).toContain( + 'sourcemaps upload --release="$SENTRY_RELEASE" dist', + ); + expect(releaseWorkflow).toContain( + 'releases set-commits "$SENTRY_RELEASE" --auto', + ); + expect(releaseWorkflow).toContain("target: runtime-prebuilt"); + expect(releaseWorkflow).toContain( + "GITTENSORY_VERSION=${{ steps.version.outputs.release }}", + ); + + for (const path of [ + "scripts/build-selfhost.mjs", + "Dockerfile", + ".github/workflows/selfhost.yml", + ]) { + expect(read(path)).not.toContain("sourcemaps upload"); + } + }); + + it("does not copy source maps into the runtime image", () => { + const dockerfile = read("Dockerfile"); + expect(dockerfile).not.toContain("COPY --from=build /app/dist ./dist"); + expect(dockerfile).toContain( + "COPY --from=build --chown=node:node /app/dist/server.mjs ./dist/server.mjs", + ); + expect(dockerfile).toContain( + "COPY --chown=node:node dist/server.mjs ./dist/server.mjs", + ); + + const dockerignore = read(".dockerignore"); + expect(dockerignore).toContain("dist/*"); + expect(dockerignore).toContain("!dist/server.mjs"); + expect(dockerignore).not.toContain("!dist/server.mjs.map"); + }); +}); diff --git a/test/unit/selfhost-sentry.test.ts b/test/unit/selfhost-sentry.test.ts index 896d61a28a..180e777e79 100644 --- a/test/unit/selfhost-sentry.test.ts +++ b/test/unit/selfhost-sentry.test.ts @@ -27,6 +27,7 @@ import { flushSentry, forwardStructuredLogToSentry, installStructuredLogForwarding, + resolveSentryRelease, scrubEvent, resetSentryForTest, } from "../../src/selfhost/sentry"; @@ -87,6 +88,22 @@ describe("disabled when SENTRY_DSN is unset (modular opt-out → complete no-op) }); describe("enabled when SENTRY_DSN is set", () => { + it("resolves the Sentry release from explicit env, then the baked image version, ignoring blanks", () => { + expect( + resolveSentryRelease({ + SENTRY_RELEASE: " custom-release ", + GITTENSORY_VERSION: "gittensory-selfhost@0.1.0", + } as unknown as NodeJS.ProcessEnv), + ).toBe("custom-release"); + expect( + resolveSentryRelease({ + SENTRY_RELEASE: " ", + GITTENSORY_VERSION: " gittensory-selfhost@0.1.0 ", + } as unknown as NodeJS.ProcessEnv), + ).toBe("gittensory-selfhost@0.1.0"); + expect(resolveSentryRelease({} as unknown as NodeJS.ProcessEnv)).toBeUndefined(); + }); + it("returns true and wires init with defaults (?? right-hand branches) + the scrubber as beforeSend", async () => { expect( await initSentry({ @@ -96,6 +113,7 @@ describe("enabled when SENTRY_DSN is set", () => { expect(mocks.init).toHaveBeenCalledTimes(1); const opts = mocks.init.mock.calls[0]![0]; expect(opts.environment).toBe("production"); + expect(opts.release).toBeUndefined(); expect(opts.tracesSampleRate).toBe(0); expect( opts.beforeSend({ extra: { sessionToken: "s" } }).extra.sessionToken, @@ -117,6 +135,16 @@ describe("enabled when SENTRY_DSN is set", () => { expect(opts.serverName).toBe("https://self.host"); }); + it("uses the baked image version as the runtime release when SENTRY_RELEASE is unset", async () => { + await initSentry({ + SENTRY_DSN: "d", + GITTENSORY_VERSION: "gittensory-selfhost@0.1.0", + } as unknown as NodeJS.ProcessEnv); + expect(mocks.init.mock.calls[0]![0].release).toBe( + "gittensory-selfhost@0.1.0", + ); + }); + it("captureError sends with context, and without context skips setContext", async () => { await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); captureError(new Error("boom"), { kind: "job_dead" });