Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/secure-sentry-source-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Remove uploaded Sentry source maps from production build artifacts and fail builds when their upload fails.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/client/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/client/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1060,6 +1070,7 @@ function ensureSentry(loadWithoutDsn = false): void {
module.init({
dsn,
environment: resolveClientDeploymentEnvironment(),
release: resolveClientRelease(),
beforeSend(event) {
if (isSyntheticBrowserTraffic()) return null;
event.tags = {
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/vite/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
43 changes: 36 additions & 7 deletions packages/core/src/vite/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -3591,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<string, string | undefined> {
const workspaceRoot = findWorkspaceRoot(cwd);
return {
...(workspaceRoot && workspaceRoot !== cwd
? loadEnv(mode, workspaceRoot, "")
: {}),
...loadEnv(mode, cwd, ""),
...process.env,
};
}

function createAgentNativePlugins(
options: ClientConfigOptions | AgentNativeVitePluginOptions,
{
Expand All @@ -3609,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,
Expand Down Expand Up @@ -3645,6 +3673,8 @@ function createAgentNativePlugins(
includeReactTransform ? createReactTransformPlugin() : null,
createDesignSystemThemePlugin(options.designSystemTheme),
createTailwindPlugin(options),
// No-ops unless a Sentry auth token/org/project is configured.
...createSentrySourceMapUploadPlugin(runtimeEnv),
].filter(Boolean);
}

Expand Down Expand Up @@ -3728,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(
Expand Down Expand Up @@ -3982,6 +4006,11 @@ function createAgentNativeConfig(
// the standard property survives the production pipeline.
cssMinify: userConfig.build?.cssMinify ?? "esbuild",
cssTarget: userConfig.build?.cssTarget ?? ["es2020", "safari18"],
// "hidden" writes .map files for upload without a public
// sourceMappingURL comment, so production never serves them directly.
sourcemap:
userConfig.build?.sourcemap ??
(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.
Expand Down
203 changes: 203 additions & 0 deletions packages/core/src/vite/sentry-source-maps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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<void>>());
const sentryVitePluginMock = vi.hoisted(() =>
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<void> {
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,
}));

import {
createSentrySourceMapUploadPlugin,
resolveSentrySourceMapUploadConfig,
} from "./sentry-source-maps.js";

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", () => {
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("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: "web",
AGENT_NATIVE_BUILD_ID: "deploy-42",
});
expect(config).toEqual({
authToken: "tok",
org: "acme",
project: "web",
url: undefined,
release: "agent-native-client@deploy-42",
});
});
});

describe("createSentrySourceMapUploadPlugin", () => {
it("returns an empty array and never calls sentryVitePlugin when disabled", () => {
expect(createSentrySourceMapUploadPlugin({})).toEqual([]);
expect(sentryVitePluginMock).not.toHaveBeenCalled();
});

it("calls sentryVitePlugin with the resolved config when enabled", () => {
const plugins = createSentrySourceMapUploadPlugin({
SENTRY_AUTH_TOKEN: "tok",
SENTRY_ORG: "acme",
SENTRY_PROJECT: "web",
AGENT_NATIVE_BUILD_ID: "deploy-42",
});
expect(plugins).toHaveLength(2);
const callArgs = sentryVitePluginMock.mock.calls[0][0];
expect(callArgs).toMatchObject({
org: "acme",
project: "web",
authToken: "tok",
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("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",
});

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);
});
});
});
Loading
Loading