diff --git a/.eslintrc.js b/.eslintrc.js
index a9fb5f421af5..53944e16d9dc 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -21,6 +21,7 @@ module.exports = {
'examples/**',
'test/manual/**',
'types/**',
+ 'scripts/*.js',
],
reportUnusedDisableDirectives: true,
overrides: [
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ce139e9e7787..299ed59cd220 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1035,6 +1035,15 @@ jobs:
overwrite: true
retention-days: 7
+ - name: Upload E2E Test Event Dumps
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-event-dumps-job_e2e_playwright_tests-${{ matrix.test-application }}
+ path: dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/event-dumps
+ overwrite: true
+ retention-days: 7
+
- name: Upload test results to Codecov
if: cancelled() == false
continue-on-error: true
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore
index e799cc33c4e7..ebdbfc025b6a 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore
@@ -43,3 +43,4 @@ next-env.d.ts
.vscode
test-results
+event-dumps
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts
index 4f11a03dc6cc..40c3d68096c2 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts
@@ -2,4 +2,4 @@
///
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs
index 90d736790faa..959b40d253e8 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs
@@ -1,6 +1,14 @@
+import * as fs from 'fs';
+import * as path from 'path';
import { startEventProxyServer } from '@sentry-internal/test-utils';
+const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
+
startEventProxyServer({
port: 3031,
proxyServerName: 'nextjs-15',
+ envelopeDumpPath: path.join(
+ process.cwd(),
+ `event-dumps/next-${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
+ ),
});
diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts
index 17922a4f90aa..448dd6e34ef0 100644
--- a/dev-packages/test-utils/src/event-proxy-server.ts
+++ b/dev-packages/test-utils/src/event-proxy-server.ts
@@ -17,6 +17,8 @@ interface EventProxyServerOptions {
port: number;
/** The name for the proxy server used for referencing it with listener functions */
proxyServerName: string;
+ /** A path to optionally output all Envelopes to. Can be used to compare event payloads before and after changes. */
+ envelopeDumpPath?: string;
}
interface SentryRequestCallbackData {
@@ -167,6 +169,10 @@ export async function startProxyServer(
* option to this server (like this `tunnel: http://localhost:${port option}/`).
*/
export async function startEventProxyServer(options: EventProxyServerOptions): Promise {
+ if (options.envelopeDumpPath) {
+ await fs.promises.mkdir(path.dirname(path.resolve(options.envelopeDumpPath)), { recursive: true });
+ }
+
await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer) => {
const data: SentryRequestCallbackData = {
envelope: parseEnvelope(proxyRequestBody),
@@ -183,6 +189,10 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P
listener(dataString);
});
+ if (options.envelopeDumpPath) {
+ fs.appendFileSync(path.resolve(options.envelopeDumpPath), `${JSON.stringify(data.envelope)}\n`, 'utf-8');
+ }
+
return [
200,
'{}',
diff --git a/scripts/normalize-e2e-test-dump-transaction-events.js b/scripts/normalize-e2e-test-dump-transaction-events.js
new file mode 100644
index 000000000000..ba06a63fa020
--- /dev/null
+++ b/scripts/normalize-e2e-test-dump-transaction-events.js
@@ -0,0 +1,109 @@
+/* eslint-disable no-console */
+
+const fs = require('fs');
+const path = require('path');
+
+if (process.argv.length < 4) {
+ throw new Error('Please provide an input and output file path as an argument.');
+}
+
+const resolvedInputPath = path.resolve(process.argv[2]);
+const resolvedOutputPath = path.resolve(process.argv[3]);
+
+const fileContents = fs.readFileSync(resolvedInputPath, 'utf8');
+
+const transactionNodes = [];
+
+fileContents.split('\n').forEach(serializedEnvelope => {
+ let envelope;
+ try {
+ envelope = JSON.parse(serializedEnvelope);
+ } catch (e) {
+ return;
+ // noop
+ }
+
+ const envelopeItems = envelope[1];
+
+ envelopeItems.forEach(([envelopeItemHeader, transaction]) => {
+ if (envelopeItemHeader.type === 'transaction') {
+ const rootNode = {
+ runtime: transaction.contexts.runtime?.name,
+ op: transaction.contexts.trace.op,
+ name: transaction.transaction,
+ children: [],
+ };
+
+ const spanMap = new Map();
+ spanMap.set(transaction.contexts.trace.span_id, rootNode);
+
+ transaction.spans.forEach(span => {
+ const node = {
+ op: span.data['sentry.op'],
+ name: span.description,
+ parent_span_id: span.parent_span_id,
+ children: [],
+ };
+ spanMap.set(span.span_id, node);
+ });
+
+ transaction.spans.forEach(span => {
+ const node = spanMap.get(span.span_id);
+ if (node && node.parent_span_id) {
+ const parentNode = spanMap.get(node.parent_span_id);
+ parentNode.children.push(node);
+ }
+ });
+
+ transactionNodes.push(rootNode);
+ }
+ });
+});
+
+const output = transactionNodes
+ .sort((a, b) => {
+ const aSerialized = serializeNode(a);
+ const bSerialized = serializeNode(b);
+ if (aSerialized < bSerialized) {
+ return -1;
+ } else if (aSerialized > bSerialized) {
+ return 1;
+ } else {
+ return 0;
+ }
+ })
+ .map(node => buildDeterministicStringFromNode(node))
+ .join('\n\n-----------------------\n\n');
+
+fs.writeFileSync(resolvedOutputPath, output, 'utf-8');
+
+// ------- utility fns ----------
+
+function buildDeterministicStringFromNode(node, depth = 0) {
+ const mainParts = [];
+ if (node.runtime) {
+ mainParts.push(`(${node.runtime})`);
+ }
+ mainParts.push(`${node.op ?? 'default'} -`);
+ mainParts.push(node.name);
+ const main = mainParts.join(' ');
+ const children = node.children
+ .sort((a, b) => {
+ const aSerialized = serializeNode(a);
+ const bSerialized = serializeNode(b);
+ if (aSerialized < bSerialized) {
+ return -1;
+ } else if (aSerialized > bSerialized) {
+ return 1;
+ } else {
+ return 0;
+ }
+ })
+ .map(child => '\n' + buildDeterministicStringFromNode(child, depth + 1))
+ .join('');
+ return `${main}${children}`.split('\n').join('\n ');
+}
+
+function serializeNode(node) {
+ return [node.op, node.name, node.runtime].join('---');
+}