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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
dist/
.astro/
node_modules/
.DS_Store

# Mock Sentry server artifacts
.tmp_*
/tmp_mock_chunks
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
getArtifactBundles,
getAssembleRequests,
getChunkUploadPosts,
getDebugIdPairs,
getSourcemaps,
loadMockServerResults,
} from '@sentry-internal/test-utils';

// v10's `@sentry-internal/test-utils` has no build-output helpers, so the two used here are inlined.
const INJECTED_DEBUG_ID_PATTERN = /sentry-dbid-([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/gi;

function walkFiles(outputDir: string): string[] {
if (!fs.existsSync(outputDir)) {
throw new Error(`Output directory does not exist: ${outputDir}`);
}
return fs
.readdirSync(outputDir, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile())
.map(entry => path.join(entry.parentPath, entry.name));
}

/** Distinct lowercased debug IDs the bundler plugin injected into the emitted JavaScript. */
function findInjectedDebugIds({ outputDir }: { outputDir: string }): string[] {
const debugIds = new Set<string>();
for (const file of walkFiles(outputDir)) {
if (!['.js', '.mjs', '.cjs'].includes(path.extname(file))) continue;
for (const match of fs.readFileSync(file, 'utf8').matchAll(INJECTED_DEBUG_ID_PATTERN)) {
debugIds.add((match[1] as string).toLowerCase());
}
}
return [...debugIds];
}

/** Every `.map` file under `outputDir`, relative to the current directory. */
function findSourceMapFiles({ outputDir }: { outputDir: string }): string[] {
return walkFiles(outputDir)
.filter(file => file.endsWith('.map'))
.map(file => path.relative(process.cwd(), file));
}

const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static';

const BUILD_OUTPUT = 'dist';
const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')];

/** All markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
const PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER';

/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */
const debugIdsWithoutSourceMap = new Set(
fs.existsSync('.tmp_debug_ids_without_source_map')
? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean)
: [],
);

const requests = loadMockServerResults();

console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
for (const request of requests) {
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
}
console.log('');

assert.ok(
requests.some(r => r.authorization.includes('fake-auth-token')),
'Expected requests with the configured auth token',
);

assert.ok(
getChunkUploadPosts(requests).some(r => r.bodySize > 0),
'Expected at least one chunk upload POST with a non-empty body',
);

const assembleRequests = getAssembleRequests(requests);
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
for (const request of assembleRequests) {
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
}

const bundles = getArtifactBundles(requests);
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');

const sourcemaps = getSourcemaps(bundles);

const containsMarker = (marker: string): boolean =>
sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));

assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source');
if (!isStatic) {
assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source');
}
// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips
assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source');

const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr']
.filter(file => fs.existsSync(file))
.map(file => fs.readFileSync(file, 'utf8'))
.join('\n');
assert.ok(
!buildOutput.includes("Didn't find any matching sources for debug ID upload"),
'Expected every Vite build to find sources for the debug ID upload',
);

const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');

for (const outputDir of OUTPUT_DIRS) {
const injectedDebugIds = findInjectedDebugIds({ outputDir });
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);

const unuploaded = injectedDebugIds.filter(
debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId),
);
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
}

// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload.
// TODO: The default `filesToDeleteAfterUpload` globs miss static output, which writes maps to `dist/_astro`.
if (!isStatic) {
assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`);
}

console.log('All sourcemap assertions passed!');
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import node from '@astrojs/node';
import sentry from '@sentry/astro';
import { defineConfig } from 'astro/config';
import * as fs from 'node:fs';

const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static';

// Rolldown emits no map for chunks without mappable source (e.g. virtual modules or its runtime), so their debug
// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload.
function recordChunksWithoutSourceMap() {
return {
name: 'record-chunks-without-source-map',
apply: 'build',
enforce: 'post',
writeBundle(_options, bundle) {
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk' || bundle[`${output.fileName}.map`]) {
continue;
}
for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1].toLowerCase()}\n`);
}
}
},
};
}

// Static output allows no on-demand routes, so the SSR page is only added for server output
const ssrRoute = {
name: 'ssr-route',
hooks: {
'astro:config:setup': ({ injectRoute }) => {
injectRoute({ pattern: '/ssr', entrypoint: './src/routes/ssr.astro' });
},
},
};

export default defineConfig({
integrations: [
sentry({
sentryUrl: 'http://localhost:3032',
authToken: 'fake-auth-token',
org: 'test-org',
project: 'test-project',
// v10 has no build-time `release` option on the integration, so it goes straight to the Vite plugin
unstable_sentryVitePluginOptions: { release: { name: 'test-release' } },
debug: true,
}),
...(isStatic ? [] : [ssrRoute]),
],
// Keeps Astro from inlining the small client script into the HTML, where it would get no source map
vite: { build: { assetsInlineLimit: 0 }, plugins: [recordChunksWithoutSourceMap()] },
output: isStatic ? 'static' : 'server',
adapter: isStatic ? undefined : node({ mode: 'standalone' }),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "astro-7-sourcemaps",
"description": "E2E test app asserting what the Astro SDK uploads to Sentry and what it leaves behind in `dist`.",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "node start-mock-sentry-server.mjs & astro build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT",
"test:build": "pnpm install && pnpm build",
"test:assert": "node assert-build.ts",
"test:build:static": "E2E_ASTRO_OUTPUT=static pnpm test:build",
"test:assert:static": "E2E_ASTRO_OUTPUT=static pnpm test:assert"
},
"dependencies": {
"@astrojs/node": "^11.1.4",
"@sentry/astro": "file:../../packed/sentry-astro-packed.tgz",
"astro": "^7.2.8"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^22.20.0"
},
"volta": {
"node": "22.22.0",
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:static",
"assert-command": "pnpm test:assert:static",
"label": "astro-7-sourcemaps (static output)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as Sentry from '@sentry/astro';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as Sentry from '@sentry/astro';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SOURCEMAP_MARKER_CLIENT
document.querySelector('button')?.addEventListener('click', () => {
throw new Error('Client error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SOURCEMAP_MARKER_PRERENDER
export function getPrerenderGreeting(): string {
return `Prerendered at ${new Date().toISOString()}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SOURCEMAP_MARKER_SERVER
export function getServerGreeting(): string {
return `Rendered on request at ${new Date().toISOString()}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import { getPrerenderGreeting } from '../lib/prerender';

export const prerender = true;
---

<html lang="en">
<body>
<p>{getPrerenderGreeting()}</p>
<button>Throw error</button>
<script>
import '../lib/client';
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import { getServerGreeting } from '../lib/server';

export const prerender = false;
---

<html lang="en">
<body>
<p>{getServerGreeting()}</p>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startMockSentryServer } from '@sentry-internal/test-utils';

startMockSentryServer({ org: 'test-org', outputDir: 'tmp_mock_chunks' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}
18 changes: 12 additions & 6 deletions packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
sourcemaps: {
...sourcemaps,
// eslint-disable-next-line typescript/no-deprecated
assets: sourcemaps?.assets ?? uploadOptions.assets ?? [getSourcemapsAssetsGlob(config)],
assets: sourcemaps?.assets ?? uploadOptions.assets ?? getSourcemapsAssetsGlobs(config),
filesToDeleteAfterUpload:
sourcemaps?.filesToDeleteAfterUpload ??
// eslint-disable-next-line typescript/no-deprecated
Expand Down Expand Up @@ -294,28 +294,34 @@ function isCloudflarePages(): boolean {
return false;
}

function getSourcemapsAssetsGlob(config: AstroConfig): string {
function getSourcemapsAssetsGlobs(config: AstroConfig): string[] {
const outDirGlob = getOutDirGlob(config);
// Astro 7 writes the prerender build to a hidden `.prerender` folder, which `**` does not enter.
// Its chunks only run at build time to render static pages, but we still upload them for errors captured then.
return [`${outDirGlob}/**/*`, `${outDirGlob}/**/.prerender/**/*`];
}

function getOutDirGlob(config: AstroConfig): string {
// The vercel adapter puts the output into its .vercel directory
// However, the way this adapter is written, the config.outDir value is update too late for
// us to reliably detect it. Also, server files are first temporarily written to <root>/dist and then
// only copied over to <root>/.vercel. This seems to happen too late though.
// So we glob on both of these directories.
// Another case of "it ain't pretty but it works":(
if (config.adapter?.name?.startsWith('@astrojs/vercel')) {
return '{.vercel,dist}/**/*';
return '{.vercel,dist}';
}

// paths are stored as "file://" URLs
const outDirPathname = config.outDir && path.resolve(config.outDir.pathname);
const rootDirName = path.resolve(config.root?.pathname || process.cwd());

if (outDirPathname) {
const relativePath = path.relative(rootDirName, outDirPathname);
return `${relativePath}/**/*`;
return path.relative(rootDirName, outDirPathname);
}

// fallback to default output dir
return 'dist/**/*';
return 'dist';
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/astro/test/integration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('sentryAstro integration', () => {
debug: false,
bundleSizeOptimizations: {},
sourcemaps: {
assets: ['out/**/*'],
assets: ['out/**/*', 'out/**/.prerender/**/*'],
filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'],
},
_metaOptions: {
Expand Down Expand Up @@ -94,7 +94,7 @@ describe('sentryAstro integration', () => {
debug: false,
bundleSizeOptimizations: {},
sourcemaps: {
assets: ['dist/**/*'],
assets: ['dist/**/*', 'dist/**/.prerender/**/*'],
filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'],
},
_metaOptions: {
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('sentryAstro integration', () => {
debug: false,
bundleSizeOptimizations: {},
sourcemaps: {
assets: ['{.vercel,dist}/**/*'],
assets: ['{.vercel,dist}/**/*', '{.vercel,dist}/**/.prerender/**/*'],
filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'],
},
_metaOptions: {
Expand Down
Loading