From 0f5ca4beb4ab12c15525eca114e19e5e522061c3 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 9 Sep 2026 16:14:09 +0000 Subject: [PATCH 1/6] feat: upload browser source maps to Sentry by build release --- packages/core/package.json | 1 + packages/core/src/client/analytics.spec.ts | 20 ++ packages/core/src/client/analytics.ts | 11 ++ packages/core/src/vite/client.spec.ts | 43 ++++ packages/core/src/vite/client.ts | 16 ++ .../core/src/vite/sentry-source-maps.spec.ts | 151 +++++++++++++++ packages/core/src/vite/sentry-source-maps.ts | 130 +++++++++++++ pnpm-lock.yaml | 183 ++++++++++++++++++ 8 files changed, 555 insertions(+) create mode 100644 packages/core/src/vite/sentry-source-maps.spec.ts create mode 100644 packages/core/src/vite/sentry-source-maps.ts diff --git a/packages/core/package.json b/packages/core/package.json index b7bbb63137e..94c6e9d36d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -410,6 +410,7 @@ "@rrweb/record": "2.1.0", "@sentry/browser": "10.60.0", "@sentry/node": "10.60.0", + "@sentry/vite-plugin": "5.4.0", "@shadcn/react": "^0.2.0", "@standard-schema/spec": "^1.1.0", "@tanstack/react-table": "^8.21.3", diff --git a/packages/core/src/client/analytics.spec.ts b/packages/core/src/client/analytics.spec.ts index b8631b018d6..213d9bc4744 100644 --- a/packages/core/src/client/analytics.spec.ts +++ b/packages/core/src/client/analytics.spec.ts @@ -912,6 +912,7 @@ describe("browser analytics pageviews", () => { expect.objectContaining({ dsn: "https://public@example/4511270423822336", environment: "beta", + release: "agent-native-client@development", }), ); expect(sentryMock.setTag).toHaveBeenCalledWith("runtime", "browser"); @@ -921,6 +922,25 @@ describe("browser analytics pageviews", () => { ); }); + it("tags browser Sentry events with the build id as the release, matching uploaded source maps", async () => { + installBrowser(); + (globalThis as any).__AGENT_NATIVE_BUILD_ID__ = "deploy-99"; + (window as any).__AGENT_NATIVE_CONFIG__ = { + sentryDsn: "https://public@example/4511270423822336", + }; + const { configureTracking } = await freshAnalytics(); + + configureTracking({}); + await tick(); + + expect(sentryMock.init).toHaveBeenCalledWith( + expect.objectContaining({ + release: "agent-native-client@deploy-99", + }), + ); + delete (globalThis as any).__AGENT_NATIVE_BUILD_ID__; + }); + it("labels first-party analytics events with the deployment environment", async () => { installBrowser("https://beta.mail.agent-native.com/inbox"); const { analyticsCalls } = installFetch(); diff --git a/packages/core/src/client/analytics.ts b/packages/core/src/client/analytics.ts index a6403062a67..c063b9d789e 100644 --- a/packages/core/src/client/analytics.ts +++ b/packages/core/src/client/analytics.ts @@ -25,6 +25,7 @@ import { getOrCreateAnalyticsSessionId, } from "./analytics-session.js"; import { injectedAgentNativeConfig } from "./app-config.js"; +import { clientBuildId } from "./build-compatibility.js"; export { clearAnalyticsSessionId, setAnalyticsSessionId, @@ -1017,6 +1018,15 @@ function resolveClientDeploymentEnvironment(): string { ); } +/** + * Must match `resolveSentryClientRelease()` in `vite/sentry-source-maps.ts` + * exactly — that's the release name uploaded source maps are attached to, so + * a mismatch here means captured events never resolve against them. + */ +function resolveClientRelease(): string { + return `agent-native-client@${clientBuildId() || "development"}`; +} + function captureWithSentry( module: typeof Sentry, error: unknown, @@ -1060,6 +1070,7 @@ function ensureSentry(loadWithoutDsn = false): void { module.init({ dsn, environment: resolveClientDeploymentEnvironment(), + release: resolveClientRelease(), beforeSend(event) { if (isSyntheticBrowserTraffic()) return null; event.tags = { diff --git a/packages/core/src/vite/client.spec.ts b/packages/core/src/vite/client.spec.ts index fb46eaf0928..0302de1587c 100644 --- a/packages/core/src/vite/client.spec.ts +++ b/packages/core/src/vite/client.spec.ts @@ -1695,6 +1695,49 @@ describe("agentNative Vite plugin preset", () => { }); }); + it("leaves build.sourcemap off and adds no Sentry plugin without upload config", async () => { + const plugins = flatPlugins(agentNative()); + const configPlugin = plugins.find((p) => p?.name === "agent-native-config"); + + const config = (await configPlugin.config( + {}, + { command: "build", mode: "production" }, + )) as any; + + expect(config.build.sourcemap).toBe(false); + expect(plugins.map((p) => p?.name)).not.toContain("sentry-vite-plugin"); + }); + + it("emits hidden sourcemaps and adds the Sentry upload plugin when configured", async () => { + const previous = { + SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, + SENTRY_ORG: process.env.SENTRY_ORG, + SENTRY_PROJECT: process.env.SENTRY_PROJECT, + }; + try { + process.env.SENTRY_AUTH_TOKEN = "test-token"; + process.env.SENTRY_ORG = "acme"; + process.env.SENTRY_PROJECT = "web"; + + const plugins = flatPlugins(agentNative()); + const configPlugin = plugins.find( + (p) => p?.name === "agent-native-config", + ); + const config = (await configPlugin.config( + {}, + { command: "build", mode: "production" }, + )) as any; + + expect(config.build.sourcemap).toBe("hidden"); + expect(plugins.map((p) => p?.name)).toContain("sentry-vite-plugin"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it("stops the dep optimizer from writing prebundle sourcemaps", async () => { const plugins = flatPlugins(agentNative()); const configPlugin = plugins.find((p) => p?.name === "agent-native-config"); diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 8c49df85081..7f670d9add9 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -74,6 +74,10 @@ import { } from "./agent-native-config-loader.js"; import { agentsBundlePlugin } from "./agents-bundle-plugin.js"; import { resolveAgentNativePackageVersions } from "./package-versions.js"; +import { + createSentrySourceMapUploadPlugin, + isSentrySourceMapUploadEnabled, +} from "./sentry-source-maps.js"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -3645,6 +3649,10 @@ function createAgentNativePlugins( includeReactTransform ? createReactTransformPlugin() : null, createDesignSystemThemePlugin(options.designSystemTheme), createTailwindPlugin(options), + // No-ops (empty array) unless a Sentry auth token/org/project is + // configured. Safe to always include — its hooks only fire during a + // real `vite build`, never `vite dev`. + ...createSentrySourceMapUploadPlugin(options.outDir ?? "dist/spa"), ].filter(Boolean); } @@ -3982,6 +3990,14 @@ function createAgentNativeConfig( // the standard property survives the production pipeline. cssMinify: userConfig.build?.cssMinify ?? "esbuild", cssTarget: userConfig.build?.cssTarget ?? ["es2020", "safari18"], + // "hidden" emits `.map` files for Sentry to upload without adding a + // `//# sourceMappingURL` comment to the shipped JS, so production + // never serves real source maps publicly. Only turned on when a + // Sentry source-map upload is actually configured — otherwise this + // stays `false`, unchanged from the previous default. + sourcemap: + userConfig.build?.sourcemap ?? + (isSentrySourceMapUploadEnabled() ? "hidden" : false), }, // Bundle all non-Node.js deps into the production SSR server build. // Edge runtimes (CF Workers, Deno) don't have node_modules at runtime. diff --git a/packages/core/src/vite/sentry-source-maps.spec.ts b/packages/core/src/vite/sentry-source-maps.spec.ts new file mode 100644 index 00000000000..7e434068062 --- /dev/null +++ b/packages/core/src/vite/sentry-source-maps.spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const sentryVitePluginMock = vi.hoisted(() => + vi.fn(() => [{ name: "sentry-vite-plugin-mock", enforce: "pre" }]), +); + +vi.mock("@sentry/vite-plugin", () => ({ + sentryVitePlugin: sentryVitePluginMock, +})); + +import { + createSentrySourceMapUploadPlugin, + isSentrySourceMapUploadEnabled, + resolveSentryClientRelease, + resolveSentrySourceMapUploadConfig, +} from "./sentry-source-maps.js"; + +describe("vite/sentry-source-maps", () => { + beforeEach(() => { + sentryVitePluginMock.mockClear(); + }); + + describe("resolveSentryClientRelease", () => { + it("uses the build id env vars, matching resolveAgentNativeBuildId", () => { + expect( + resolveSentryClientRelease({ AGENT_NATIVE_BUILD_ID: "abc123" }), + ).toBe("agent-native-client@abc123"); + }); + + it("falls back to development when no build id is set", () => { + expect(resolveSentryClientRelease({})).toBe( + "agent-native-client@development", + ); + }); + }); + + describe("resolveSentrySourceMapUploadConfig", () => { + it("returns null when no auth token is set", () => { + expect( + resolveSentrySourceMapUploadConfig({ + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }), + ).toBeNull(); + }); + + it("returns null when a token is set but org/project are missing", () => { + expect( + resolveSentrySourceMapUploadConfig({ SENTRY_AUTH_TOKEN: "tok" }), + ).toBeNull(); + }); + + it("resolves a full config from SENTRY_ORG / SENTRY_PROJECT", () => { + const config = resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + AGENT_NATIVE_BUILD_ID: "deploy-42", + }); + expect(config).toEqual({ + authToken: "tok", + org: "acme", + project: "web", + url: undefined, + release: "agent-native-client@deploy-42", + }); + }); + + it("falls back to SENTRY_ORG_SLUG and SENTRY_PROJECT_ID", () => { + const config = resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG_SLUG: "bridge-tm", + SENTRY_PROJECT_ID: "4511270386466816", + }); + expect(config?.org).toBe("bridge-tm"); + expect(config?.project).toBe("4511270386466816"); + }); + + it("resolves a custom SENTRY_URL for self-hosted instances", () => { + const config = resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + SENTRY_URL: "https://sentry.internal.example.com", + }); + expect(config?.url).toBe("https://sentry.internal.example.com"); + }); + }); + + describe("isSentrySourceMapUploadEnabled", () => { + it("mirrors resolveSentrySourceMapUploadConfig's null-ness", () => { + expect(isSentrySourceMapUploadEnabled({})).toBe(false); + expect( + isSentrySourceMapUploadEnabled({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }), + ).toBe(true); + }); + }); + + describe("createSentrySourceMapUploadPlugin", () => { + it("returns an empty array and never calls sentryVitePlugin when disabled", () => { + const plugins = createSentrySourceMapUploadPlugin("dist/spa", {}); + expect(plugins).toEqual([]); + expect(sentryVitePluginMock).not.toHaveBeenCalled(); + }); + + it("calls sentryVitePlugin with the resolved config when enabled", () => { + const plugins = createSentrySourceMapUploadPlugin("dist/spa", { + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + AGENT_NATIVE_BUILD_ID: "deploy-42", + }); + expect(plugins).toHaveLength(1); + expect(sentryVitePluginMock).toHaveBeenCalledTimes(1); + const callArgs = sentryVitePluginMock.mock.calls[0][0]; + expect(callArgs).toMatchObject({ + org: "acme", + project: "web", + authToken: "tok", + telemetry: false, + release: { + name: "agent-native-client@deploy-42", + inject: false, + }, + sourcemaps: { + filesToDeleteAfterUpload: ["dist/spa/**/*.map"], + }, + }); + expect(callArgs.errorHandler).toBeInstanceOf(Function); + }); + + it("errorHandler swallows failures instead of throwing", () => { + createSentrySourceMapUploadPlugin("dist/spa", { + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }); + const { errorHandler } = sentryVitePluginMock.mock.calls[0][0]; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => errorHandler(new Error("bad token"))).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("bad token"), + ); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/core/src/vite/sentry-source-maps.ts b/packages/core/src/vite/sentry-source-maps.ts new file mode 100644 index 00000000000..185aa611ac6 --- /dev/null +++ b/packages/core/src/vite/sentry-source-maps.ts @@ -0,0 +1,130 @@ +/** + * Browser Sentry source-map upload for the Vite client build. + * + * `client/analytics.ts` has captured browser exceptions in every template + * since it was introduced, but nothing in the build pipeline ever uploaded a + * source map or created a Sentry release, so every captured stack trace + * pointed at minified production code. This closes that gap the same way + * every other Sentry integration in this framework is wired: read config + * from env, no-op silently when it's absent, never fail the build over an + * observability hiccup. + * + * The release name here MUST match what `client/analytics.ts` sends as + * `event.release` at runtime, or uploaded source maps never resolve against + * captured events. Both sides derive it from the same + * `resolveAgentNativeBuildId()` env-driven identifier — this module computes + * it at build time, `clientBuildId()` reads it back at runtime via the + * `__AGENT_NATIVE_BUILD_ID__` define both paths already share. + */ +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import type { Plugin } from "vite"; + +import { resolveAgentNativeBuildId } from "../shared/build-id.js"; + +function firstNonEmpty( + ...values: Array +): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +/** Shared with `client/analytics.ts`'s runtime `event.release`. */ +export function resolveSentryClientRelease( + env: Record, +): string { + return `agent-native-client@${resolveAgentNativeBuildId(env, "development")}`; +} + +export interface SentrySourceMapUploadConfig { + authToken: string; + org: string; + project: string; + url?: string; + release: string; +} + +/** + * Resolves upload config from env, or `null` when disabled. + * + * Requires all of an auth token, org, and project — a token alone isn't + * enough to safely guess org/project, and a half-configured plugin would + * fail every build rather than cleanly no-op. + */ +export function resolveSentrySourceMapUploadConfig( + env: Record = process.env, +): SentrySourceMapUploadConfig | null { + const authToken = firstNonEmpty(env.SENTRY_AUTH_TOKEN); + if (!authToken) return null; + const org = firstNonEmpty(env.SENTRY_ORG, env.SENTRY_ORG_SLUG); + const project = firstNonEmpty( + env.SENTRY_PROJECT, + env.SENTRY_CLIENT_PROJECT, + env.SENTRY_PROJECT_ID, + ); + if (!org || !project) return null; + return { + authToken, + org, + project, + url: firstNonEmpty(env.SENTRY_URL), + release: resolveSentryClientRelease(env), + }; +} + +/** `true` when `resolveSentrySourceMapUploadConfig` finds a usable config. */ +export function isSentrySourceMapUploadEnabled( + env: Record = process.env, +): boolean { + return resolveSentrySourceMapUploadConfig(env) !== null; +} + +/** + * Creates the source-map upload plugin for the client build, or `[]` when + * disabled. Safe to always include in the plugins array regardless of + * `vite build` vs `vite dev` — `@sentry/vite-plugin`'s own hooks only act + * during a real Rollup build, so it's inert during dev serving. + * + * `outDir` only drives the post-upload cleanup glob (removing `.map` files + * from the shipped `dist/` so production doesn't serve real source maps + * publicly); the upload itself reads Vite's build output directly and + * doesn't need to know the output directory. + */ +export function createSentrySourceMapUploadPlugin( + outDir: string, + env: Record = process.env, +): Plugin[] { + const config = resolveSentrySourceMapUploadConfig(env); + if (!config) return []; + return sentryVitePlugin({ + org: config.org, + project: config.project, + authToken: config.authToken, + url: config.url, + telemetry: false, + release: { + // Explicit name so the upload matches `client/analytics.ts`'s runtime + // `event.release` exactly. `inject: false` because that file already + // sets `release` itself — letting the plugin also inject its own + // auto-detected (git-SHA-based) release would create a second, + // divergent source of truth for the same field. + name: config.release, + inject: false, + }, + sourcemaps: { + filesToDeleteAfterUpload: [`${outDir}/**/*.map`], + }, + // Every other Sentry integration in this framework fails open + // (initServerSentry, ensureSentry) — a bad token, network blip, or + // org/project typo must never break a customer's production build. + errorHandler: (error) => { + console.warn( + `[agent-native] Sentry source map upload failed (build continues): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }, + }) as Plugin[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 094b85a2503..55cd035b975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,6 +459,9 @@ importers: '@sentry/node': specifier: 10.60.0 version: 10.60.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/vite-plugin': + specifier: 5.4.0 + version: 5.4.0(rollup@4.62.2) '@shadcn/react': specifier: ^0.2.0 version: 0.2.0(@types/react@19.2.17)(react@19.2.7) @@ -12505,10 +12508,78 @@ packages: resolution: {integrity: sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==} engines: {node: '>=18'} + '@sentry/bundler-plugins@10.73.0': + resolution: {integrity: sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=4.59.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + '@sentry/conventions@0.12.0': resolution: {integrity: sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==} engines: {node: '>=14'} + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + '@sentry/core@10.60.0': resolution: {integrity: sha512-szN7ccOJAEaLb1BBQzCQhABGMTJmKNUk0G2sc7rWhajeXoZoMKIbNkI9RvJrFuV69cbad/d/BKGBjbpJhySAzw==} engines: {node: '>=18'} @@ -12517,6 +12588,10 @@ packages: resolution: {integrity: sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==} engines: {node: '>=18'} + '@sentry/core@10.73.0': + resolution: {integrity: sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==} + engines: {node: '>=18'} + '@sentry/electron@7.14.0': resolution: {integrity: sha512-H2Ommi2B/I2QwUT2WJW1uqBiuzDXhuv4pw8hkVm63qBnwGZkSH54YdDOAzkXF9bmw3SHaIAjZBFzsUrvFAzgyw==} peerDependencies: @@ -12586,6 +12661,10 @@ packages: resolution: {integrity: sha512-SX+MzWM3nz5ttKT48rlfktm0ERyIpDLma+b6pYeWgW2oFHKcpIu0g0qMGJrZs4lKM3MlgV7IqLa4texMqTp9kQ==} engines: {node: '>=18'} + '@sentry/vite-plugin@5.4.0': + resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} + engines: {node: '>= 18'} + '@shadcn/react@0.2.0': resolution: {integrity: sha512-g/LMtyL0eiHCHkOCelhYf32RC5nTMdtUNag1ZQRhSDCW+jnHxDY1Dajk7P6zJosOg8z2N4wzfKOY5sh54QTDYA==} peerDependencies: @@ -16406,6 +16485,10 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -17479,6 +17562,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} @@ -18378,6 +18465,10 @@ packages: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-map@2.1.0: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} @@ -18814,6 +18905,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -28315,12 +28409,77 @@ snapshots: '@sentry/replay': 10.62.0 '@sentry/replay-canvas': 10.62.0 + '@sentry/bundler-plugins@10.73.0(rollup@4.62.2)': + dependencies: + '@babel/core': 7.29.7 + '@sentry/cli': 2.58.6 + '@sentry/core': 10.73.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.2 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/conventions@0.12.0': {} + '@sentry/conventions@0.16.0': {} + '@sentry/core@10.60.0': {} '@sentry/core@10.62.0': {} + '@sentry/core@10.73.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/electron@7.14.0': dependencies: '@sentry/browser': 10.60.0 @@ -28406,6 +28565,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry/vite-plugin@5.4.0(rollup@4.62.2)': + dependencies: + '@sentry/bundler-plugins': 10.73.0(rollup@4.62.2) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + '@shadcn/react@0.2.0(@types/react@19.2.17)(react@19.2.7)': optionalDependencies: '@types/react': 19.2.17 @@ -32765,6 +32933,11 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -33895,6 +34068,10 @@ snapshots: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.17.21: {} lodash-es@4.18.1: {} @@ -35173,6 +35350,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-map@2.1.0: {} p-map@7.0.4: {} @@ -35656,6 +35837,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pump@3.0.4: From bfb0654abcb94ed160abb17d5bfacf232e83eb22 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 9 Sep 2026 17:13:14 +0000 Subject: [PATCH 2/6] fix: configure Sentry source map uploads for client builds --- packages/core/src/client/analytics.spec.ts | 19 ----- packages/core/src/vite/client.ts | 11 +-- .../core/src/vite/sentry-source-maps.spec.ts | 73 ++----------------- packages/core/src/vite/sentry-source-maps.ts | 55 ++++---------- 4 files changed, 22 insertions(+), 136 deletions(-) diff --git a/packages/core/src/client/analytics.spec.ts b/packages/core/src/client/analytics.spec.ts index 213d9bc4744..344d0ede19f 100644 --- a/packages/core/src/client/analytics.spec.ts +++ b/packages/core/src/client/analytics.spec.ts @@ -922,25 +922,6 @@ describe("browser analytics pageviews", () => { ); }); - it("tags browser Sentry events with the build id as the release, matching uploaded source maps", async () => { - installBrowser(); - (globalThis as any).__AGENT_NATIVE_BUILD_ID__ = "deploy-99"; - (window as any).__AGENT_NATIVE_CONFIG__ = { - sentryDsn: "https://public@example/4511270423822336", - }; - const { configureTracking } = await freshAnalytics(); - - configureTracking({}); - await tick(); - - expect(sentryMock.init).toHaveBeenCalledWith( - expect.objectContaining({ - release: "agent-native-client@deploy-99", - }), - ); - delete (globalThis as any).__AGENT_NATIVE_BUILD_ID__; - }); - it("labels first-party analytics events with the deployment environment", async () => { installBrowser("https://beta.mail.agent-native.com/inbox"); const { analyticsCalls } = installFetch(); diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 7f670d9add9..975014541fa 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -3649,9 +3649,7 @@ function createAgentNativePlugins( includeReactTransform ? createReactTransformPlugin() : null, createDesignSystemThemePlugin(options.designSystemTheme), createTailwindPlugin(options), - // No-ops (empty array) unless a Sentry auth token/org/project is - // configured. Safe to always include — its hooks only fire during a - // real `vite build`, never `vite dev`. + // No-ops unless a Sentry auth token/org/project is configured. ...createSentrySourceMapUploadPlugin(options.outDir ?? "dist/spa"), ].filter(Boolean); } @@ -3990,11 +3988,8 @@ function createAgentNativeConfig( // the standard property survives the production pipeline. cssMinify: userConfig.build?.cssMinify ?? "esbuild", cssTarget: userConfig.build?.cssTarget ?? ["es2020", "safari18"], - // "hidden" emits `.map` files for Sentry to upload without adding a - // `//# sourceMappingURL` comment to the shipped JS, so production - // never serves real source maps publicly. Only turned on when a - // Sentry source-map upload is actually configured — otherwise this - // stays `false`, unchanged from the previous default. + // "hidden" writes .map files for upload without a public + // sourceMappingURL comment, so production never serves them directly. sourcemap: userConfig.build?.sourcemap ?? (isSentrySourceMapUploadEnabled() ? "hidden" : false), diff --git a/packages/core/src/vite/sentry-source-maps.spec.ts b/packages/core/src/vite/sentry-source-maps.spec.ts index 7e434068062..ae295f5e0fc 100644 --- a/packages/core/src/vite/sentry-source-maps.spec.ts +++ b/packages/core/src/vite/sentry-source-maps.spec.ts @@ -10,8 +10,6 @@ vi.mock("@sentry/vite-plugin", () => ({ import { createSentrySourceMapUploadPlugin, - isSentrySourceMapUploadEnabled, - resolveSentryClientRelease, resolveSentrySourceMapUploadConfig, } from "./sentry-source-maps.js"; @@ -20,20 +18,6 @@ describe("vite/sentry-source-maps", () => { sentryVitePluginMock.mockClear(); }); - describe("resolveSentryClientRelease", () => { - it("uses the build id env vars, matching resolveAgentNativeBuildId", () => { - expect( - resolveSentryClientRelease({ AGENT_NATIVE_BUILD_ID: "abc123" }), - ).toBe("agent-native-client@abc123"); - }); - - it("falls back to development when no build id is set", () => { - expect(resolveSentryClientRelease({})).toBe( - "agent-native-client@development", - ); - }); - }); - describe("resolveSentrySourceMapUploadConfig", () => { it("returns null when no auth token is set", () => { expect( @@ -50,11 +34,11 @@ describe("vite/sentry-source-maps", () => { ).toBeNull(); }); - it("resolves a full config from SENTRY_ORG / SENTRY_PROJECT", () => { + it("resolves a full config, falling back to ORG_SLUG/PROJECT_ID", () => { const config = resolveSentrySourceMapUploadConfig({ SENTRY_AUTH_TOKEN: "tok", - SENTRY_ORG: "acme", - SENTRY_PROJECT: "web", + SENTRY_ORG_SLUG: "acme", + SENTRY_PROJECT_ID: "web", AGENT_NATIVE_BUILD_ID: "deploy-42", }); expect(config).toEqual({ @@ -65,45 +49,11 @@ describe("vite/sentry-source-maps", () => { release: "agent-native-client@deploy-42", }); }); - - it("falls back to SENTRY_ORG_SLUG and SENTRY_PROJECT_ID", () => { - const config = resolveSentrySourceMapUploadConfig({ - SENTRY_AUTH_TOKEN: "tok", - SENTRY_ORG_SLUG: "bridge-tm", - SENTRY_PROJECT_ID: "4511270386466816", - }); - expect(config?.org).toBe("bridge-tm"); - expect(config?.project).toBe("4511270386466816"); - }); - - it("resolves a custom SENTRY_URL for self-hosted instances", () => { - const config = resolveSentrySourceMapUploadConfig({ - SENTRY_AUTH_TOKEN: "tok", - SENTRY_ORG: "acme", - SENTRY_PROJECT: "web", - SENTRY_URL: "https://sentry.internal.example.com", - }); - expect(config?.url).toBe("https://sentry.internal.example.com"); - }); - }); - - describe("isSentrySourceMapUploadEnabled", () => { - it("mirrors resolveSentrySourceMapUploadConfig's null-ness", () => { - expect(isSentrySourceMapUploadEnabled({})).toBe(false); - expect( - isSentrySourceMapUploadEnabled({ - SENTRY_AUTH_TOKEN: "tok", - SENTRY_ORG: "acme", - SENTRY_PROJECT: "web", - }), - ).toBe(true); - }); }); describe("createSentrySourceMapUploadPlugin", () => { it("returns an empty array and never calls sentryVitePlugin when disabled", () => { - const plugins = createSentrySourceMapUploadPlugin("dist/spa", {}); - expect(plugins).toEqual([]); + expect(createSentrySourceMapUploadPlugin("dist/spa", {})).toEqual([]); expect(sentryVitePluginMock).not.toHaveBeenCalled(); }); @@ -115,22 +65,14 @@ describe("vite/sentry-source-maps", () => { AGENT_NATIVE_BUILD_ID: "deploy-42", }); expect(plugins).toHaveLength(1); - expect(sentryVitePluginMock).toHaveBeenCalledTimes(1); const callArgs = sentryVitePluginMock.mock.calls[0][0]; expect(callArgs).toMatchObject({ org: "acme", project: "web", authToken: "tok", - telemetry: false, - release: { - name: "agent-native-client@deploy-42", - inject: false, - }, - sourcemaps: { - filesToDeleteAfterUpload: ["dist/spa/**/*.map"], - }, + release: { name: "agent-native-client@deploy-42", inject: false }, + sourcemaps: { filesToDeleteAfterUpload: ["dist/spa/**/*.map"] }, }); - expect(callArgs.errorHandler).toBeInstanceOf(Function); }); it("errorHandler swallows failures instead of throwing", () => { @@ -142,9 +84,6 @@ describe("vite/sentry-source-maps", () => { const { errorHandler } = sentryVitePluginMock.mock.calls[0][0]; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => errorHandler(new Error("bad token"))).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("bad token"), - ); warnSpy.mockRestore(); }); }); diff --git a/packages/core/src/vite/sentry-source-maps.ts b/packages/core/src/vite/sentry-source-maps.ts index 185aa611ac6..9f88ac6a8e7 100644 --- a/packages/core/src/vite/sentry-source-maps.ts +++ b/packages/core/src/vite/sentry-source-maps.ts @@ -1,20 +1,8 @@ /** - * Browser Sentry source-map upload for the Vite client build. - * - * `client/analytics.ts` has captured browser exceptions in every template - * since it was introduced, but nothing in the build pipeline ever uploaded a - * source map or created a Sentry release, so every captured stack trace - * pointed at minified production code. This closes that gap the same way - * every other Sentry integration in this framework is wired: read config - * from env, no-op silently when it's absent, never fail the build over an - * observability hiccup. - * * The release name here MUST match what `client/analytics.ts` sends as * `event.release` at runtime, or uploaded source maps never resolve against - * captured events. Both sides derive it from the same - * `resolveAgentNativeBuildId()` env-driven identifier — this module computes - * it at build time, `clientBuildId()` reads it back at runtime via the - * `__AGENT_NATIVE_BUILD_ID__` define both paths already share. + * captured events. Both derive it from the same `resolveAgentNativeBuildId()` + * identifier so they can't drift apart independently. */ import { sentryVitePlugin } from "@sentry/vite-plugin"; import type { Plugin } from "vite"; @@ -31,7 +19,6 @@ function firstNonEmpty( return undefined; } -/** Shared with `client/analytics.ts`'s runtime `event.release`. */ export function resolveSentryClientRelease( env: Record, ): string { @@ -46,13 +33,8 @@ export interface SentrySourceMapUploadConfig { release: string; } -/** - * Resolves upload config from env, or `null` when disabled. - * - * Requires all of an auth token, org, and project — a token alone isn't - * enough to safely guess org/project, and a half-configured plugin would - * fail every build rather than cleanly no-op. - */ +// A token alone can't safely guess org/project, and a half-configured plugin +// would fail every build rather than cleanly no-op. export function resolveSentrySourceMapUploadConfig( env: Record = process.env, ): SentrySourceMapUploadConfig | null { @@ -74,24 +56,16 @@ export function resolveSentrySourceMapUploadConfig( }; } -/** `true` when `resolveSentrySourceMapUploadConfig` finds a usable config. */ export function isSentrySourceMapUploadEnabled( env: Record = process.env, ): boolean { return resolveSentrySourceMapUploadConfig(env) !== null; } -/** - * Creates the source-map upload plugin for the client build, or `[]` when - * disabled. Safe to always include in the plugins array regardless of - * `vite build` vs `vite dev` — `@sentry/vite-plugin`'s own hooks only act - * during a real Rollup build, so it's inert during dev serving. - * - * `outDir` only drives the post-upload cleanup glob (removing `.map` files - * from the shipped `dist/` so production doesn't serve real source maps - * publicly); the upload itself reads Vite's build output directly and - * doesn't need to know the output directory. - */ +// Safe to always include in the plugins array regardless of `vite build` vs +// `vite dev` — `@sentry/vite-plugin`'s hooks only act during a real Rollup +// build. `outDir` only drives the post-upload `.map` cleanup glob; the +// upload itself reads Vite's build output directly. export function createSentrySourceMapUploadPlugin( outDir: string, env: Record = process.env, @@ -105,20 +79,17 @@ export function createSentrySourceMapUploadPlugin( url: config.url, telemetry: false, release: { - // Explicit name so the upload matches `client/analytics.ts`'s runtime - // `event.release` exactly. `inject: false` because that file already - // sets `release` itself — letting the plugin also inject its own - // auto-detected (git-SHA-based) release would create a second, - // divergent source of truth for the same field. + // inject: false — client/analytics.ts already sets `release` itself; + // letting the plugin also inject its own git-SHA-based release would + // create a second, divergent source of truth for the same field. name: config.release, inject: false, }, sourcemaps: { filesToDeleteAfterUpload: [`${outDir}/**/*.map`], }, - // Every other Sentry integration in this framework fails open - // (initServerSentry, ensureSentry) — a bad token, network blip, or - // org/project typo must never break a customer's production build. + // Every other Sentry integration in this framework fails open — a bad + // token or org/project typo must never break a customer's production build. errorHandler: (error) => { console.warn( `[agent-native] Sentry source map upload failed (build continues): ${ From 98a94b89c502152ff6590ad4966e4b45b99ff3f9 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 9 Sep 2026 18:19:15 +0000 Subject: [PATCH 3/6] fix(core): load Sentry config from Vite env files safely --- packages/core/src/vite/client.ts | 36 ++++++++++++++----- .../core/src/vite/sentry-source-maps.spec.ts | 22 ++++++++---- packages/core/src/vite/sentry-source-maps.ts | 25 +++++++------ 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 975014541fa..2c51c80b736 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -3595,6 +3595,24 @@ function authClientAssetPlugin(): Plugin { }; } +// `.env`/`.env.production` values aren't in `process.env` unless the shell +// exported them — Vite loads them separately via `loadEnv`. Env-gated +// checks that only read `process.env` silently miss file-only config, so +// this is the one merge both the plugin list and the build config use. +function resolveAgentNativeRuntimeEnv( + cwd: string, + mode: string, +): Record { + const workspaceRoot = findWorkspaceRoot(cwd); + return { + ...(workspaceRoot && workspaceRoot !== cwd + ? loadEnv(mode, workspaceRoot, "") + : {}), + ...loadEnv(mode, cwd, ""), + ...process.env, + }; +} + function createAgentNativePlugins( options: ClientConfigOptions | AgentNativeVitePluginOptions, { @@ -3613,6 +3631,12 @@ function createAgentNativePlugins( const nitroPlugin = createNitroDevPlugin(options, appBasePath); const includeNitro = !isBuildCommand(command); const presetMarkerPlugin = nitroPresetMarkerPlugin(options); + // Vite's real `mode` isn't resolved yet at this eager, pre-config-hook + // point — same fallback createAgentNativeConfig uses as its own default. + const runtimeEnv = resolveAgentNativeRuntimeEnv( + process.cwd(), + process.env.NODE_ENV === "production" ? "production" : "development", + ); return [ presetMarkerPlugin, @@ -3650,7 +3674,7 @@ function createAgentNativePlugins( createDesignSystemThemePlugin(options.designSystemTheme), createTailwindPlugin(options), // No-ops unless a Sentry auth token/org/project is configured. - ...createSentrySourceMapUploadPlugin(options.outDir ?? "dist/spa"), + ...createSentrySourceMapUploadPlugin(runtimeEnv), ].filter(Boolean); } @@ -3734,13 +3758,7 @@ function createAgentNativeConfig( const workspaceRoot = findWorkspaceRoot(cwd); const envDir = workspaceRoot && workspaceRoot !== cwd ? workspaceRoot : cwd; - const runtimeEnv = { - ...(workspaceRoot && workspaceRoot !== cwd - ? loadEnv(mode, workspaceRoot, "") - : {}), - ...loadEnv(mode, cwd, ""), - ...process.env, - }; + const runtimeEnv = resolveAgentNativeRuntimeEnv(cwd, mode); const appConfig = resolveAgentNativeConfig( mergeAgentNativeConfigs( mergeAgentNativeConfigs( @@ -3992,7 +4010,7 @@ function createAgentNativeConfig( // sourceMappingURL comment, so production never serves them directly. sourcemap: userConfig.build?.sourcemap ?? - (isSentrySourceMapUploadEnabled() ? "hidden" : false), + (isSentrySourceMapUploadEnabled(runtimeEnv) ? "hidden" : false), }, // Bundle all non-Node.js deps into the production SSR server build. // Edge runtimes (CF Workers, Deno) don't have node_modules at runtime. diff --git a/packages/core/src/vite/sentry-source-maps.spec.ts b/packages/core/src/vite/sentry-source-maps.spec.ts index ae295f5e0fc..e9f782a16e8 100644 --- a/packages/core/src/vite/sentry-source-maps.spec.ts +++ b/packages/core/src/vite/sentry-source-maps.spec.ts @@ -34,11 +34,21 @@ describe("vite/sentry-source-maps", () => { ).toBeNull(); }); - it("resolves a full config, falling back to ORG_SLUG/PROJECT_ID", () => { + it("does not accept the numeric SENTRY_PROJECT_ID as a project slug", () => { + expect( + resolveSentrySourceMapUploadConfig({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT_ID: "4511270423822336", + }), + ).toBeNull(); + }); + + it("resolves a full config, falling back to SENTRY_ORG_SLUG", () => { const config = resolveSentrySourceMapUploadConfig({ SENTRY_AUTH_TOKEN: "tok", SENTRY_ORG_SLUG: "acme", - SENTRY_PROJECT_ID: "web", + SENTRY_PROJECT: "web", AGENT_NATIVE_BUILD_ID: "deploy-42", }); expect(config).toEqual({ @@ -53,12 +63,12 @@ describe("vite/sentry-source-maps", () => { describe("createSentrySourceMapUploadPlugin", () => { it("returns an empty array and never calls sentryVitePlugin when disabled", () => { - expect(createSentrySourceMapUploadPlugin("dist/spa", {})).toEqual([]); + expect(createSentrySourceMapUploadPlugin({})).toEqual([]); expect(sentryVitePluginMock).not.toHaveBeenCalled(); }); it("calls sentryVitePlugin with the resolved config when enabled", () => { - const plugins = createSentrySourceMapUploadPlugin("dist/spa", { + const plugins = createSentrySourceMapUploadPlugin({ SENTRY_AUTH_TOKEN: "tok", SENTRY_ORG: "acme", SENTRY_PROJECT: "web", @@ -71,12 +81,12 @@ describe("vite/sentry-source-maps", () => { project: "web", authToken: "tok", release: { name: "agent-native-client@deploy-42", inject: false }, - sourcemaps: { filesToDeleteAfterUpload: ["dist/spa/**/*.map"] }, }); + expect(callArgs.sourcemaps).toBeUndefined(); }); it("errorHandler swallows failures instead of throwing", () => { - createSentrySourceMapUploadPlugin("dist/spa", { + createSentrySourceMapUploadPlugin({ SENTRY_AUTH_TOKEN: "tok", SENTRY_ORG: "acme", SENTRY_PROJECT: "web", diff --git a/packages/core/src/vite/sentry-source-maps.ts b/packages/core/src/vite/sentry-source-maps.ts index 9f88ac6a8e7..5b5984f8835 100644 --- a/packages/core/src/vite/sentry-source-maps.ts +++ b/packages/core/src/vite/sentry-source-maps.ts @@ -41,11 +41,11 @@ export function resolveSentrySourceMapUploadConfig( const authToken = firstNonEmpty(env.SENTRY_AUTH_TOKEN); if (!authToken) return null; const org = firstNonEmpty(env.SENTRY_ORG, env.SENTRY_ORG_SLUG); - const project = firstNonEmpty( - env.SENTRY_PROJECT, - env.SENTRY_CLIENT_PROJECT, - env.SENTRY_PROJECT_ID, - ); + // SENTRY_PROJECT_ID is the numeric DSN project id used elsewhere in this + // repo (sentry-config.ts) — not a valid value for the plugin's `project` + // option, which wants the project slug. Passing the numeric id would + // silently target the wrong project instead of cleanly no-oping. + const project = firstNonEmpty(env.SENTRY_PROJECT, env.SENTRY_CLIENT_PROJECT); if (!org || !project) return null; return { authToken, @@ -64,10 +64,8 @@ export function isSentrySourceMapUploadEnabled( // Safe to always include in the plugins array regardless of `vite build` vs // `vite dev` — `@sentry/vite-plugin`'s hooks only act during a real Rollup -// build. `outDir` only drives the post-upload `.map` cleanup glob; the -// upload itself reads Vite's build output directly. +// build. export function createSentrySourceMapUploadPlugin( - outDir: string, env: Record = process.env, ): Plugin[] { const config = resolveSentrySourceMapUploadConfig(env); @@ -85,11 +83,12 @@ export function createSentrySourceMapUploadPlugin( name: config.release, inject: false, }, - sourcemaps: { - filesToDeleteAfterUpload: [`${outDir}/**/*.map`], - }, - // Every other Sentry integration in this framework fails open — a bad - // token or org/project typo must never break a customer's production build. + // No `sourcemaps.filesToDeleteAfterUpload`: the plugin runs that deletion + // unconditionally, even when the upload itself failed and `errorHandler` + // below swallowed it — which would delete the only copies of the maps + // with nothing uploaded to replace them. Leaving `.map` files in `dist/` + // means a release is never worse than un-symbolicated; `sourcemap: + // "hidden"` already keeps them off the shipped JS's sourceMappingURL. errorHandler: (error) => { console.warn( `[agent-native] Sentry source map upload failed (build continues): ${ From 65d583abef33a3bf770ba0e70fa2be18eef38b24 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 9 Sep 2026 18:53:49 +0000 Subject: [PATCH 4/6] fix: load shared Sentry build credentials from the shell --- scripts/sync-template-netlify-env.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/sync-template-netlify-env.ts b/scripts/sync-template-netlify-env.ts index 93ef72bfe05..a7fc0daebcf 100644 --- a/scripts/sync-template-netlify-env.ts +++ b/scripts/sync-template-netlify-env.ts @@ -124,7 +124,10 @@ const HOSTED_TEMPLATE_ENV_ALLOWLIST_EXACT = new Set([ "NETLIFY_DATABASE_URL_UNPOOLED", "NITRO_PRESET", "SENDGRID_API_KEY", + "SENTRY_AUTH_TOKEN", "SENTRY_DSN", + "SENTRY_ORG", + "SENTRY_PROJECT", "SENTRY_SERVER_DSN", "SUPABASE_URL", "SUPABASE_ANON_KEY", @@ -137,9 +140,15 @@ const HOSTED_TEMPLATE_ALLOWED_SECRET_EXACT = new Set([ "NETLIFY_DATABASE_URL", "NETLIFY_DATABASE_URL_UNPOOLED", "SENDGRID_API_KEY", + "SENTRY_AUTH_TOKEN", "SENTRY_DSN", "SENTRY_SERVER_DSN", ]); +// Sentry build-time upload credentials are one org/project shared by every +// hosted site, unlike SENTRY_DSN which can vary per site. Pulling them from +// the invoking shell (rather than each template's committed .env) means the +// token is never written to disk in this repo. +const FLEET_WIDE_ENV_KEYS = ["SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT"]; const FORBIDDEN_HOSTED_TEMPLATE_ENV_EXACT = new Set([ "ANTHROPIC_API_KEY", "AMPLITUDE_API_KEY", @@ -209,6 +218,9 @@ Options: GA_MEASUREMENT_ID and GTM_CONTAINER_ID default to the hosted Agent-Native analytics configuration unless an env source overrides them. + SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are + read from this shell's environment (not any template + .env) since they're the same for every hosted site. --help Show this help. Known templates: @@ -369,6 +381,11 @@ function loadTemplateEnv(template: string, sources: string[]) { const foundSources: string[] = []; const sourcesByKey = new Map(); + for (const key of FLEET_WIDE_ENV_KEYS) { + const value = process.env[key]; + if (value) values.set(key, value); + } + for (const source of sources) { const filePath = path.join(REPO_ROOT, "templates", template, source); if (!existsSync(filePath)) continue; From f3d3ccad05ab4cccf11c2bcab9540cbf87209dc1 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 9 Sep 2026 19:02:30 +0000 Subject: [PATCH 5/6] fix: treat Sentry org and project as public config --- scripts/sync-template-netlify-env.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/sync-template-netlify-env.ts b/scripts/sync-template-netlify-env.ts index a7fc0daebcf..a2e70844191 100644 --- a/scripts/sync-template-netlify-env.ts +++ b/scripts/sync-template-netlify-env.ts @@ -173,6 +173,10 @@ const PUBLIC_KEY_EXACT = new Set([ "GOOGLE_PICKER_APP_ID", "NEON_AUTH_BASE_URL", "NITRO_PRESET", + // The org/project slugs identify a Sentry project, not a credential - + // SENTRY_AUTH_TOKEN is the actual secret and stays out of this set. + "SENTRY_ORG", + "SENTRY_PROJECT", "SUPABASE_URL", "SUPABASE_ANON_KEY", "ZOOM_CLIENT_ID", From 550b999449a61ae02a1996d6153ff31744a796c6 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Wed, 9 Sep 2026 15:59:09 -0400 Subject: [PATCH 6/6] fix based on feedback --- .changeset/secure-sentry-source-maps.md | 5 + .../core/src/vite/sentry-source-maps.spec.ts | 121 ++++++++++++++++-- packages/core/src/vite/sentry-source-maps.ts | 54 ++++++-- scripts/sync-template-netlify-env.spec.ts | 19 +++ scripts/sync-template-netlify-env.ts | 17 ++- 5 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 .changeset/secure-sentry-source-maps.md diff --git a/.changeset/secure-sentry-source-maps.md b/.changeset/secure-sentry-source-maps.md new file mode 100644 index 00000000000..e3c79b7578c --- /dev/null +++ b/.changeset/secure-sentry-source-maps.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Remove uploaded Sentry source maps from production build artifacts and fail builds when their upload fails. diff --git a/packages/core/src/vite/sentry-source-maps.spec.ts b/packages/core/src/vite/sentry-source-maps.spec.ts index e9f782a16e8..6cd9c4096af 100644 --- a/packages/core/src/vite/sentry-source-maps.spec.ts +++ b/packages/core/src/vite/sentry-source-maps.spec.ts @@ -1,9 +1,77 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { build, type Plugin } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const sentryUpload = vi.hoisted(() => vi.fn<() => Promise>()); const sentryVitePluginMock = vi.hoisted(() => - vi.fn(() => [{ name: "sentry-vite-plugin-mock", enforce: "pre" }]), + vi.fn((options: { errorHandler: (error: Error) => void }) => [ + { + name: "sentry-vite-plugin-mock", + enforce: "pre", + async writeBundle() { + try { + await sentryUpload(); + } catch (error) { + options.errorHandler(error as Error); + } + }, + }, + ]), ); +const temporaryDirectories: string[] = []; + +function temporaryBuild(): { + entryPath: string; + mapPath: string; + publishDirectory: string; +} { + const directory = mkdtempSync( + path.join(tmpdir(), "agent-native-sourcemaps-"), + ); + const entryPath = path.join(directory, "entry.js"); + const publishDirectory = path.join(directory, "publish"); + temporaryDirectories.push(directory); + mkdirSync(publishDirectory); + writeFileSync(entryPath, "console.log('built');"); + return { + entryPath, + mapPath: path.join(publishDirectory, "client.js.map"), + publishDirectory, + }; +} + +async function runViteBuild( + entryPath: string, + publishDirectory: string, + plugins: Plugin[], +): Promise { + await build({ + configFile: false, + logLevel: "silent", + plugins, + build: { + emptyOutDir: true, + lib: { + entry: entryPath, + fileName: () => "client.js", + formats: ["es"], + }, + outDir: publishDirectory, + sourcemap: true, + }, + }); +} + vi.mock("@sentry/vite-plugin", () => ({ sentryVitePlugin: sentryVitePluginMock, })); @@ -16,6 +84,14 @@ import { describe("vite/sentry-source-maps", () => { beforeEach(() => { sentryVitePluginMock.mockClear(); + sentryUpload.mockReset(); + sentryUpload.mockResolvedValue(); + }); + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } }); describe("resolveSentrySourceMapUploadConfig", () => { @@ -74,7 +150,7 @@ describe("vite/sentry-source-maps", () => { SENTRY_PROJECT: "web", AGENT_NATIVE_BUILD_ID: "deploy-42", }); - expect(plugins).toHaveLength(1); + expect(plugins).toHaveLength(2); const callArgs = sentryVitePluginMock.mock.calls[0][0]; expect(callArgs).toMatchObject({ org: "acme", @@ -83,18 +159,45 @@ describe("vite/sentry-source-maps", () => { release: { name: "agent-native-client@deploy-42", inject: false }, }); expect(callArgs.sourcemaps).toBeUndefined(); + expect(plugins.at(-1)?.name).toBe( + "agent-native:delete-uploaded-sentry-source-maps", + ); }); - it("errorHandler swallows failures instead of throwing", () => { - createSentrySourceMapUploadPlugin({ + it("removes source maps from the publish artifact after upload succeeds", async () => { + const { entryPath, mapPath, publishDirectory } = temporaryBuild(); + sentryUpload.mockImplementation(async () => { + expect(existsSync(mapPath)).toBe(true); + }); + const plugins = createSentrySourceMapUploadPlugin({ SENTRY_AUTH_TOKEN: "tok", SENTRY_ORG: "acme", SENTRY_PROJECT: "web", }); - const { errorHandler } = sentryVitePluginMock.mock.calls[0][0]; - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(() => errorHandler(new Error("bad token"))).not.toThrow(); - warnSpy.mockRestore(); + + await runViteBuild(entryPath, publishDirectory, plugins); + + expect(sentryUpload).toHaveBeenCalledOnce(); + expect(existsSync(mapPath)).toBe(false); + expect(existsSync(path.join(publishDirectory, "client.js"))).toBe(true); + }); + + it("keeps source maps and rejects when upload fails", async () => { + const { entryPath, mapPath, publishDirectory } = temporaryBuild(); + sentryUpload.mockImplementation(async () => { + expect(existsSync(mapPath)).toBe(true); + throw new Error("upload rejected"); + }); + const plugins = createSentrySourceMapUploadPlugin({ + SENTRY_AUTH_TOKEN: "tok", + SENTRY_ORG: "acme", + SENTRY_PROJECT: "web", + }); + + await expect( + runViteBuild(entryPath, publishDirectory, plugins), + ).rejects.toThrow("upload rejected"); + expect(existsSync(mapPath)).toBe(true); }); }); }); diff --git a/packages/core/src/vite/sentry-source-maps.ts b/packages/core/src/vite/sentry-source-maps.ts index 5b5984f8835..959547a1c0d 100644 --- a/packages/core/src/vite/sentry-source-maps.ts +++ b/packages/core/src/vite/sentry-source-maps.ts @@ -4,6 +4,9 @@ * captured events. Both derive it from the same `resolveAgentNativeBuildId()` * identifier so they can't drift apart independently. */ +import { readdir, rm } from "node:fs/promises"; +import path from "node:path"; + import { sentryVitePlugin } from "@sentry/vite-plugin"; import type { Plugin } from "vite"; @@ -62,6 +65,38 @@ export function isSentrySourceMapUploadEnabled( return resolveSentrySourceMapUploadConfig(env) !== null; } +async function removeSourceMaps(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await removeSourceMaps(filePath); + } else if (entry.name.endsWith(".map")) { + await rm(filePath, { force: true }); + } + }), + ); +} + +function createUploadedSourceMapCleanupPlugin(): Plugin { + return { + name: "agent-native:delete-uploaded-sentry-source-maps", + enforce: "post", + writeBundle: { + order: "post", + sequential: true, + async handler(outputOptions) { + if (outputOptions.dir) { + await removeSourceMaps(outputOptions.dir); + } else if (outputOptions.file) { + await rm(`${outputOptions.file}.map`, { force: true }); + } + }, + }, + }; +} + // Safe to always include in the plugins array regardless of `vite build` vs // `vite dev` — `@sentry/vite-plugin`'s hooks only act during a real Rollup // build. @@ -70,7 +105,7 @@ export function createSentrySourceMapUploadPlugin( ): Plugin[] { const config = resolveSentrySourceMapUploadConfig(env); if (!config) return []; - return sentryVitePlugin({ + const uploadPlugins = sentryVitePlugin({ org: config.org, project: config.project, authToken: config.authToken, @@ -83,18 +118,13 @@ export function createSentrySourceMapUploadPlugin( name: config.release, inject: false, }, - // No `sourcemaps.filesToDeleteAfterUpload`: the plugin runs that deletion - // unconditionally, even when the upload itself failed and `errorHandler` - // below swallowed it — which would delete the only copies of the maps - // with nothing uploaded to replace them. Leaving `.map` files in `dist/` - // means a release is never worse than un-symbolicated; `sourcemap: - // "hidden"` already keeps them off the shipped JS's sourceMappingURL. + // Sentry's built-in filesToDeleteAfterUpload runs in a finally block, even + // after a failed upload. Throw here so the build cannot publish an artifact + // whose maps were neither uploaded nor intentionally retained. errorHandler: (error) => { - console.warn( - `[agent-native] Sentry source map upload failed (build continues): ${ - error instanceof Error ? error.message : String(error) - }`, - ); + throw error; }, }) as Plugin[]; + + return [...uploadPlugins, createUploadedSourceMapCleanupPlugin()]; } diff --git a/scripts/sync-template-netlify-env.spec.ts b/scripts/sync-template-netlify-env.spec.ts index 19afcc14912..bd8beee93cc 100644 --- a/scripts/sync-template-netlify-env.spec.ts +++ b/scripts/sync-template-netlify-env.spec.ts @@ -5,6 +5,7 @@ import { isForbiddenHostedTemplateEnvKey, normalizeProductionUrlEntry, resolveNetlifyApiContext, + resolveNetlifyEnvScopes, resolveNetlifyTemplateName, } from "./sync-template-netlify-env"; @@ -131,6 +132,24 @@ describe("resolveNetlifyApiContext", () => { }); }); +describe("resolveNetlifyEnvScopes", () => { + it("limits the fleet-wide Sentry upload token to builds", () => { + expect( + resolveNetlifyEnvScopes("SENTRY_AUTH_TOKEN", [ + "builds", + "functions", + "runtime", + ]), + ).toEqual(["builds"]); + }); + + it("preserves configured scopes for other keys", () => { + expect( + resolveNetlifyEnvScopes("SENTRY_DSN", ["functions", "runtime"]), + ).toEqual(["functions", "runtime"]); + }); +}); + describe("resolveNetlifyTemplateName", () => { it("maps the legacy chat template name to the current starter site", () => { expect(resolveNetlifyTemplateName("chat")).toBe("starter"); diff --git a/scripts/sync-template-netlify-env.ts b/scripts/sync-template-netlify-env.ts index a2e70844191..5a59f905cca 100644 --- a/scripts/sync-template-netlify-env.ts +++ b/scripts/sync-template-netlify-env.ts @@ -80,6 +80,7 @@ const TEMPLATE_SITES: TemplateSite[] = Object.entries(NETLIFY_SITES) const SITE_BY_NAME = new Map(TEMPLATE_SITES.map((site) => [site.name, site])); const DEFAULT_SOURCES = [".env", ".env.local"]; const DEFAULT_SCOPES = ["builds", "functions", "runtime"]; +const ENV_SCOPES_BY_KEY = new Map([["SENTRY_AUTH_TOKEN", ["builds"]]]); const DEFAULT_CONTEXT = "production"; const DEFAULT_HOSTED_TEMPLATE_ENV = new Map([ ["GA_MEASUREMENT_ID", "G-ESF7FYXGN9"], @@ -148,7 +149,11 @@ const HOSTED_TEMPLATE_ALLOWED_SECRET_EXACT = new Set([ // hosted site, unlike SENTRY_DSN which can vary per site. Pulling them from // the invoking shell (rather than each template's committed .env) means the // token is never written to disk in this repo. -const FLEET_WIDE_ENV_KEYS = ["SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT"]; +const FLEET_WIDE_ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG", + "SENTRY_PROJECT", +]; const FORBIDDEN_HOSTED_TEMPLATE_ENV_EXACT = new Set([ "ANTHROPIC_API_KEY", "AMPLITUDE_API_KEY", @@ -225,6 +230,7 @@ Options: SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are read from this shell's environment (not any template .env) since they're the same for every hosted site. + SENTRY_AUTH_TOKEN is always scoped to builds only. --help Show this help. Known templates: @@ -522,6 +528,13 @@ export function resolveNetlifyApiContext(context: string): string { return isBetaContext(context) ? "production" : context; } +export function resolveNetlifyEnvScopes( + key: string, + scopes: string[], +): string[] { + return ENV_SCOPES_BY_KEY.get(key) ?? scopes; +} + function siteIdForContext(site: TemplateSite, context: string): string { if (!isBetaContext(context)) return site.siteId; if (!site.betaSiteId) { @@ -766,7 +779,7 @@ async function main() { accountId: options.accountId!, context: options.context, key, - scopes: options.scopes, + scopes: resolveNetlifyEnvScopes(key, options.scopes), siteId: targetSiteId, token: token!, value,