From ef7b5e9c0610d33425a78a2aed4e2d251d08bfe7 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 13 Apr 2026 15:30:40 -0400 Subject: [PATCH 01/77] Add a TanStack Start tunnel route helper --- .../tanstackstart-react/src/server/index.ts | 1 + .../src/server/tunnelRoute.ts | 43 +++ .../test/server/tunnelRoute.test.ts | 48 +++ yarn.lock | 307 +----------------- 4 files changed, 100 insertions(+), 299 deletions(-) create mode 100644 packages/tanstackstart-react/src/server/tunnelRoute.ts create mode 100644 packages/tanstackstart-react/test/server/tunnelRoute.test.ts diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 4fe781b6d778..0ae0968e574b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -9,6 +9,7 @@ export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; export { wrapMiddlewaresWithSentry } from './middleware'; export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware'; +export { createSentryTunnelRoute } from './tunnelRoute'; /** * A no-op stub of the browser tracing integration for the server. Router setup code is shared between client and server, diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts new file mode 100644 index 000000000000..44ff32934a56 --- /dev/null +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -0,0 +1,43 @@ +import { handleTunnelRequest } from '@sentry/core'; + +export interface CreateSentryTunnelRouteOptions { + allowedDsns: string[]; +} + +type SentryTunnelRouteHandlerContext = { + request: Request; +}; + +type SentryTunnelRoute = { + handlers: { + POST: (context: SentryTunnelRouteHandlerContext) => Promise; + }; +}; + +/** + * Creates a TanStack Start server route configuration for tunneling Sentry envelopes. + * + * @example + * ```ts + * import { createFileRoute } from '@tanstack/react-router'; + * import * as Sentry from '@sentry/tanstackstart-react'; + * + * export const Route = createFileRoute('/monitoring')({ + * server: Sentry.createSentryTunnelRoute({ + * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + * }), + * }); + * ``` + */ +export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions): SentryTunnelRoute { + return { + handlers: { + POST: async ({ request }) => { + return handleTunnelRequest({ + request, + allowedDsns: options.allowedDsns, + }); + }, + }, + }; +} diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts new file mode 100644 index 000000000000..b638b779e39e --- /dev/null +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const handleTunnelRequestSpy = vi.fn(); + +vi.mock('@sentry/core', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args), + }; +}); + +const { createSentryTunnelRoute } = await import('../../src/server/tunnelRoute'); + +describe('createSentryTunnelRoute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns a server route config with only a POST handler', () => { + const route = createSentryTunnelRoute({ + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + }); + + expect(Object.keys(route.handlers)).toEqual(['POST']); + expect(route.handlers.POST).toBeTypeOf('function'); + }); + + it('forwards the request and allowed DSNs to handleTunnelRequest', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const allowedDsns = ['https://public@o0.ingest.sentry.io/0']; + const response = new Response('ok', { status: 200 }); + + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({ allowedDsns }); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns, + }); + expect(options.allowedDsns).toBe(allowedDsns); + expect(result).toBe(response); + }); +}); diff --git a/yarn.lock b/yarn.lock index 95937c3d01b6..77a5ac686356 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,25 +2,6 @@ # yarn lockfile v1 -"@actions/artifact@5.0.3": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@actions/artifact/-/artifact-5.0.3.tgz#e3ca31d98a5836c23d4c5b829429b5aa6f71f0ff" - integrity sha512-FIEG8Kum0wABZnktJvFi1xuVPc31xrunhZwLCvjrCGISQOm0ifyo7cjqf6PHiEeqoWMa5HIGOsB+lGM4aKCseA== - dependencies: - "@actions/core" "^2.0.0" - "@actions/github" "^6.0.1" - "@actions/http-client" "^3.0.2" - "@azure/storage-blob" "^12.29.1" - "@octokit/core" "^5.2.1" - "@octokit/plugin-request-log" "^1.0.4" - "@octokit/plugin-retry" "^3.0.9" - "@octokit/request" "^8.4.1" - "@octokit/request-error" "^5.1.1" - "@protobuf-ts/plugin" "^2.2.3-alpha.1" - archiver "^7.0.1" - jwt-decode "^3.1.2" - unzip-stream "^0.3.1" - "@actions/artifact@^6.1.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@actions/artifact/-/artifact-6.1.0.tgz#6d30eb1837b1f047dce2ebe364aa60a7881f202d" @@ -49,14 +30,6 @@ "@actions/http-client" "^2.0.1" uuid "^8.3.2" -"@actions/core@^2.0.0": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@actions/core/-/core-2.0.3.tgz#b05e8cf407ab393e5d10282357a74e1ee2315eee" - integrity sha512-Od9Thc3T1mQJYddvVPM4QGiLUewdh+3txmDYHHxoNdkqysR1MbCT+rFOtNUxYAz+7+6RIsqipVahY2GJqGPyxA== - dependencies: - "@actions/exec" "^2.0.0" - "@actions/http-client" "^3.0.2" - "@actions/core@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@actions/core/-/core-3.0.0.tgz#89cb07c119e9b46a649ad5f355e77de9b3108cf8" @@ -72,13 +45,6 @@ dependencies: "@actions/io" "^1.0.1" -"@actions/exec@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-2.0.0.tgz#35e829723389f80e362ec2cc415697ec74362ad8" - integrity sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw== - dependencies: - "@actions/io" "^2.0.0" - "@actions/exec@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-3.0.0.tgz#8c3464d20f0aa4068707757021d7e3c01a7ee203" @@ -96,19 +62,6 @@ "@octokit/plugin-paginate-rest" "^2.17.0" "@octokit/plugin-rest-endpoint-methods" "^5.13.0" -"@actions/github@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/@actions/github/-/github-6.0.1.tgz#76e5f96df062c90635a7181ef45ff1c4ac21306e" - integrity sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw== - dependencies: - "@actions/http-client" "^2.2.0" - "@octokit/core" "^5.0.1" - "@octokit/plugin-paginate-rest" "^9.2.2" - "@octokit/plugin-rest-endpoint-methods" "^10.4.0" - "@octokit/request" "^8.4.1" - "@octokit/request-error" "^5.1.1" - undici "^5.28.5" - "@actions/github@^9.0.0": version "9.0.0" resolved "https://registry.yarnpkg.com/@actions/github/-/github-9.0.0.tgz#c86dae4128b2a6987271e2663bee9e766464840a" @@ -130,7 +83,7 @@ "@actions/core" "^3.0.0" minimatch "^3.0.4" -"@actions/http-client@^2.0.1", "@actions/http-client@^2.2.0": +"@actions/http-client@^2.0.1": version "2.2.3" resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-2.2.3.tgz#31fc0b25c0e665754ed39a9f19a8611fc6dab674" integrity sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA== @@ -159,11 +112,6 @@ resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== -"@actions/io@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@actions/io/-/io-2.0.0.tgz#3ad1271ba3cd515324f2215e8d4c1c0c3864d65b" - integrity sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg== - "@actions/io@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@actions/io/-/io-3.0.2.tgz#6f89b27a159d109836d983efa283997c23b92284" @@ -578,11 +526,6 @@ resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06" integrity sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg== -"@assemblyscript/loader@^0.19.21": - version "0.19.23" - resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.19.23.tgz#7fccae28d0a2692869f1d1219d36093bc24d5e72" - integrity sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw== - "@astrojs/compiler@^2.3.0": version "2.12.2" resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.12.2.tgz#5913b6ec7efffebdfb37fae9a50122802ae08c64" @@ -1581,7 +1524,7 @@ jsonwebtoken "^9.0.0" uuid "^8.3.0" -"@azure/storage-blob@^12.29.1", "@azure/storage-blob@^12.30.0": +"@azure/storage-blob@^12.30.0": version "12.31.0" resolved "https://registry.yarnpkg.com/@azure/storage-blob/-/storage-blob-12.31.0.tgz#97b09be2bf6ab59739b862edd8124798362ce720" integrity sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg== @@ -3097,11 +3040,6 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@colors/colors@1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" - integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== - "@colors/colors@1.6.0", "@colors/colors@^1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" @@ -5340,13 +5278,6 @@ semver "^7.5.3" tar "^7.4.0" -"@minimistjs/subarg@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@minimistjs/subarg/-/subarg-1.0.0.tgz#484fdfebda9dc32087d7c7999ec6350684fb42d2" - integrity sha512-Q/ONBiM2zNeYUy0mVSO44mWWKYM3UHuEK43PKIOzJCbvUnPoMH1K+gk3cf1kgnCVJFlWmddahQQCmrmBGlk9jQ== - dependencies: - minimist "^1.1.0" - "@mjackson/node-fetch-server@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz#577c0c25d8aae9f69a97738b7b0d03d1471cdc49" @@ -5919,11 +5850,6 @@ dependencies: "@octokit/types" "^6.0.3" -"@octokit/auth-token@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-4.0.0.tgz#40d203ea827b9f17f42a29c6afb93b7745ef80c7" - integrity sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA== - "@octokit/auth-token@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-6.0.0.tgz#b02e9c08a2d8937df09a2a981f226ad219174c53" @@ -5942,19 +5868,6 @@ before-after-hook "^2.2.0" universal-user-agent "^6.0.0" -"@octokit/core@^5.0.1", "@octokit/core@^5.2.1": - version "5.2.2" - resolved "https://registry.yarnpkg.com/@octokit/core/-/core-5.2.2.tgz#252805732de9b4e8e4f658d34b80c4c9b2534761" - integrity sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg== - dependencies: - "@octokit/auth-token" "^4.0.0" - "@octokit/graphql" "^7.1.0" - "@octokit/request" "^8.4.1" - "@octokit/request-error" "^5.1.1" - "@octokit/types" "^13.0.0" - before-after-hook "^2.2.0" - universal-user-agent "^6.0.0" - "@octokit/core@^7.0.6": version "7.0.6" resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.6.tgz#0d58704391c6b681dec1117240ea4d2a98ac3916" @@ -5985,14 +5898,6 @@ is-plain-object "^5.0.0" universal-user-agent "^6.0.0" -"@octokit/endpoint@^9.0.6": - version "9.0.6" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-9.0.6.tgz#114d912108fe692d8b139cfe7fc0846dfd11b6c0" - integrity sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw== - dependencies: - "@octokit/types" "^13.1.0" - universal-user-agent "^6.0.0" - "@octokit/graphql@^4.5.8": version "4.8.0" resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.8.0.tgz#664d9b11c0e12112cbf78e10f49a05959aa22cc3" @@ -6002,15 +5907,6 @@ "@octokit/types" "^6.0.3" universal-user-agent "^6.0.0" -"@octokit/graphql@^7.1.0": - version "7.1.1" - resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-7.1.1.tgz#79d9f3d0c96a8fd13d64186fe5c33606d48b79cc" - integrity sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g== - dependencies: - "@octokit/request" "^8.4.1" - "@octokit/types" "^13.0.0" - universal-user-agent "^6.0.0" - "@octokit/graphql@^9.0.3": version "9.0.3" resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.3.tgz#5b8341c225909e924b466705c13477face869456" @@ -6025,16 +5921,6 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== -"@octokit/openapi-types@^20.0.0": - version "20.0.0" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-20.0.0.tgz#9ec2daa0090eeb865ee147636e0c00f73790c6e5" - integrity sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA== - -"@octokit/openapi-types@^24.2.0": - version "24.2.0" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-24.2.0.tgz#3d55c32eac0d38da1a7083a9c3b0cca77924f7d3" - integrity sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg== - "@octokit/openapi-types@^27.0.0": version "27.0.0" resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-27.0.0.tgz#374ea53781965fd02a9d36cacb97e152cefff12d" @@ -6054,30 +5940,11 @@ dependencies: "@octokit/types" "^6.40.0" -"@octokit/plugin-paginate-rest@^9.2.2": - version "9.2.2" - resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz#c516bc498736bcdaa9095b9a1d10d9d0501ae831" - integrity sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ== - dependencies: - "@octokit/types" "^12.6.0" - -"@octokit/plugin-request-log@^1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" - integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== - "@octokit/plugin-request-log@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz#de1c1e557df6c08adb631bf78264fa741e01b317" integrity sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q== -"@octokit/plugin-rest-endpoint-methods@^10.4.0": - version "10.4.1" - resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz#41ba478a558b9f554793075b2e20cd2ef973be17" - integrity sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg== - dependencies: - "@octokit/types" "^12.6.0" - "@octokit/plugin-rest-endpoint-methods@^17.0.0": version "17.0.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz#8c54397d3a4060356a1c8a974191ebf945924105" @@ -6093,14 +5960,6 @@ "@octokit/types" "^6.39.0" deprecation "^2.3.1" -"@octokit/plugin-retry@^3.0.9": - version "3.0.9" - resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-3.0.9.tgz#ae625cca1e42b0253049102acd71c1d5134788fe" - integrity sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ== - dependencies: - "@octokit/types" "^6.0.3" - bottleneck "^2.15.3" - "@octokit/plugin-retry@^8.0.0": version "8.1.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz#e25c2fb5e0a09cfe674ef9df75d7ca4fafa16c11" @@ -6119,15 +5978,6 @@ deprecation "^2.0.0" once "^1.4.0" -"@octokit/request-error@^5.1.1": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.1.1.tgz#b9218f9c1166e68bb4d0c89b638edc62c9334805" - integrity sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g== - dependencies: - "@octokit/types" "^13.1.0" - deprecation "^2.0.0" - once "^1.4.0" - "@octokit/request-error@^7.0.2", "@octokit/request-error@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.1.0.tgz#440fa3cae310466889778f5a222b47a580743638" @@ -6158,30 +6008,6 @@ node-fetch "^2.6.7" universal-user-agent "^6.0.0" -"@octokit/request@^8.4.1": - version "8.4.1" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-8.4.1.tgz#715a015ccf993087977ea4365c44791fc4572486" - integrity sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw== - dependencies: - "@octokit/endpoint" "^9.0.6" - "@octokit/request-error" "^5.1.1" - "@octokit/types" "^13.1.0" - universal-user-agent "^6.0.0" - -"@octokit/types@^12.6.0": - version "12.6.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-12.6.0.tgz#8100fb9eeedfe083aae66473bd97b15b62aedcb2" - integrity sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw== - dependencies: - "@octokit/openapi-types" "^20.0.0" - -"@octokit/types@^13.0.0", "@octokit/types@^13.1.0": - version "13.10.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.10.0.tgz#3e7c6b19c0236c270656e4ea666148c2b51fd1a3" - integrity sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA== - dependencies: - "@octokit/openapi-types" "^24.2.0" - "@octokit/types@^16.0.0": version "16.0.0" resolved "https://registry.yarnpkg.com/@octokit/types/-/types-16.0.0.tgz#fbd7fa590c2ef22af881b1d79758bfaa234dbb7c" @@ -11794,35 +11620,6 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -autocannon@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/autocannon/-/autocannon-8.0.0.tgz#72b3ade6ec63dca0dc3be157c873d0a27e3f3745" - integrity sha512-fMMcWc2JPFcUaqHeR6+PbmEpTxCrPZyBUM95oG4w3ngJ8NfBNas/ZXA+pTHXLqJ0UlFVTcy05GC25WxKx/M20A== - dependencies: - "@minimistjs/subarg" "^1.0.0" - chalk "^4.1.0" - char-spinner "^1.0.1" - cli-table3 "^0.6.0" - color-support "^1.1.1" - cross-argv "^2.0.0" - form-data "^4.0.0" - has-async-hooks "^1.0.0" - hdr-histogram-js "^3.0.0" - hdr-histogram-percentiles-obj "^3.0.0" - http-parser-js "^0.5.2" - hyperid "^3.0.0" - lodash.chunk "^4.2.0" - lodash.clonedeep "^4.5.0" - lodash.flatten "^4.4.0" - manage-path "^2.0.0" - on-net-listen "^1.1.1" - pretty-bytes "^5.4.1" - progress "^2.0.3" - reinterval "^1.1.0" - retimer "^3.0.0" - semver "^7.3.2" - timestring "^6.0.0" - autoprefixer@^10.4.13, autoprefixer@^10.4.19, autoprefixer@^10.4.21, autoprefixer@^10.4.8: version "10.4.24" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.24.tgz#2c29595f3abd820a79976a609d0bf40eecf212fb" @@ -12977,7 +12774,7 @@ buffer-more-ints@~1.0.0: resolved "https://registry.yarnpkg.com/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz#ef4f8e2dddbad429ed3828a9c55d44f05c611422" integrity sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg== -buffer@^5.2.1, buffer@^5.5.0, buffer@^5.6.0: +buffer@^5.5.0, buffer@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -13328,11 +13125,6 @@ chalk@^5.0.0, chalk@^5.2.0, chalk@^5.3.0: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== -char-spinner@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/char-spinner/-/char-spinner-1.0.1.tgz#e6ea67bd247e107112983b7ab0479ed362800081" - integrity sha512-acv43vqJ0+N0rD+Uw3pDHSxP30FHrywu2NO6/wBaHChJIizpDeBUd6NjqhNhy9LGaEAhZAXn46QzmlAvIWd16g== - character-entities-html4@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" @@ -13523,15 +13315,6 @@ cli-spinners@^2.0.0, cli-spinners@^2.5.0, cli-spinners@^2.9.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.1.tgz#9c0b9dad69a6d47cbb4333c14319b060ed395a35" integrity sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ== -cli-table3@^0.6.0: - version "0.6.5" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.5.tgz#013b91351762739c16a9567c21a04632e449bf2f" - integrity sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ== - dependencies: - string-width "^4.2.0" - optionalDependencies: - "@colors/colors" "1.5.0" - cli-table@^0.3.1: version "0.3.6" resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc" @@ -13661,7 +13444,7 @@ color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" -color-support@^1.1.1, color-support@^1.1.3: +color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -14155,11 +13938,6 @@ croner@^9.1.0: resolved "https://registry.yarnpkg.com/croner/-/croner-9.1.0.tgz#94ccbba2570bca329f60f36ec19875dccf9a63aa" integrity sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g== -cross-argv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/cross-argv/-/cross-argv-2.0.0.tgz#2e7907ba3246f82c967623a3e8525925bbd6c0ad" - integrity sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg== - cross-inspect@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cross-inspect/-/cross-inspect-1.0.1.tgz#15f6f65e4ca963cf4cc1a2b5fef18f6ca328712b" @@ -18602,11 +18380,6 @@ has-ansi@^3.0.0: dependencies: ansi-regex "^3.0.0" -has-async-hooks@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-async-hooks/-/has-async-hooks-1.0.0.tgz#3df965ade8cd2d9dbfdacfbca3e0a5152baaf204" - integrity sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw== - has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -18818,15 +18591,6 @@ hdr-histogram-js@^2.0.1: base64-js "^1.2.0" pako "^1.0.3" -hdr-histogram-js@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-3.0.1.tgz#b281e90d6ca80ee656bc378dafa39d7239b90855" - integrity sha512-l3GSdZL1Jr1C0kyb461tUjEdrRPZr8Qry7jByltf5JGrA0xvqOSrxRBfcrJqqV/AMEtqqhHhC6w8HW0gn76tRQ== - dependencies: - "@assemblyscript/loader" "^0.19.21" - base64-js "^1.2.0" - pako "^1.0.3" - hdr-histogram-percentiles-obj@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz#9409f4de0c2dda78e61de2d9d78b1e9f3cba283c" @@ -19078,7 +18842,7 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-parser-js@>=0.5.1, http-parser-js@^0.5.2: +http-parser-js@>=0.5.1: version "0.5.10" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== @@ -19197,15 +18961,6 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -hyperid@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/hyperid/-/hyperid-3.3.0.tgz#2042bb296b7f1d5ba0797a5705469af0899c8556" - integrity sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ== - dependencies: - buffer "^5.2.1" - uuid "^8.3.2" - uuid-parse "^1.1.0" - iconv-lite@0.6.3, iconv-lite@^0.6.2, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -20580,11 +20335,6 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" -jwt-decode@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" - integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== - jwt-decode@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" @@ -21006,11 +20756,6 @@ lodash.camelcase@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= -lodash.chunk@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc" - integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w== - lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -21046,11 +20791,6 @@ lodash.flatten@^3.0.2: lodash._baseflatten "^3.0.0" lodash._isiterateecall "^3.0.0" -lodash.flatten@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" - integrity sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g== - lodash.foreach@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" @@ -21445,11 +21185,6 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" -manage-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/manage-path/-/manage-path-2.0.0.tgz#f4cf8457b926eeee2a83b173501414bc76eb9597" - integrity sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A== - map-age-cleaner@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" @@ -22269,7 +22004,7 @@ minimist@^0.2.1: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.4.tgz#0085d5501e29033748a2f2a4da0180142697a475" integrity sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ== -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -23686,11 +23421,6 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -on-net-listen@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/on-net-listen/-/on-net-listen-1.1.2.tgz#671e55a81c910fa7e5b1e4d506545e9ea0f2e11c" - integrity sha512-y1HRYy8s/RlcBvDUwKXSmkODMdx4KSuIvloCnQYJ2LdBBC1asY4HtfhXwe3UWknLakATZDnbzht2Ijw3M1EqFg== - once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -25419,7 +25149,7 @@ prettier@^3.6.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: +pretty-bytes@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== @@ -26353,11 +26083,6 @@ rehype@^12.0.1: rehype-stringify "^9.0.0" unified "^10.0.0" -reinterval@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/reinterval/-/reinterval-1.1.0.tgz#3361ecfa3ca6c18283380dd0bb9546f390f5ece7" - integrity sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ== - relateurl@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -26681,11 +26406,6 @@ retext@^8.1.0: retext-stringify "^3.0.0" unified "^10.0.0" -retimer@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/retimer/-/retimer-3.0.0.tgz#98b751b1feaf1af13eb0228f8ea68b8f9da530df" - integrity sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA== - retry-request@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.1.3.tgz#d5f74daf261372cff58d08b0a1979b4d7cab0fde" @@ -28562,7 +28282,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -29006,11 +28725,6 @@ tildify@2.0.0: resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== -timestring@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/timestring/-/timestring-6.0.0.tgz#b0c7c331981ecf2066ce88bcfb8ee3ae32e7a0f6" - integrity sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA== - tiny-glob@0.2.9, tiny-glob@^0.2.9: version "0.2.9" resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" @@ -29678,7 +29392,7 @@ undici@7.18.2: resolved "https://registry.yarnpkg.com/undici/-/undici-7.18.2.tgz#6cf724ef799a67d94fd55adf66b1e184176efcdf" integrity sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw== -undici@^5.25.4, undici@^5.28.5: +undici@^5.25.4: version "5.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" integrity sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg== @@ -30189,11 +29903,6 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid-parse@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.1.0.tgz#7061c5a1384ae0e1f943c538094597e1b5f3a65b" - integrity sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A== - uuid-v4@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/uuid-v4/-/uuid-v4-0.1.0.tgz#62d7b310406f6cecfea1528c69f1e8e0bcec5a3a" From 316be89709dfe19862f97ec9d4a2c0c01941ab70 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 13 Apr 2026 16:43:25 -0400 Subject: [PATCH 02/77] Add TanStack Start tunnel route e2e coverage --- .../tanstackstart-react/package.json | 14 ++++++++- .../tanstackstart-react/src/globals.d.ts | 2 ++ .../tanstackstart-react/src/router.tsx | 4 +-- .../tanstackstart-react/src/routes/monitor.ts | 13 +++++++++ .../tanstackstart-react/tests/errors.test.ts | 4 +++ .../tests/middleware.test.ts | 4 +++ .../tests/transaction.test.ts | 4 +++ .../tanstackstart-react/tests/tunnel.test.ts | 29 +++++++++++++++++++ .../tanstackstart-react/vite.config.ts | 10 +++++++ 9 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index bcfb3279f684..ee12059975cf 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,8 +9,11 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", + "test:build:tunnel": "pnpm install && E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert:proxy": "pnpm test", + "test:assert": "pnpm test:assert:proxy && E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel", + "test:assert:tunnel": "E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "latest || *", @@ -35,5 +38,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "tunnel", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts new file mode 100644 index 000000000000..d22cc4c9dd9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts @@ -0,0 +1,2 @@ +declare const __APP_DSN__: string; +declare const __APP_TUNNEL__: string; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx index b1c6f7727a26..9a39b6f35c42 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/router.tsx @@ -11,13 +11,13 @@ export const getRouter = () => { if (!router.isServer) { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@dsn.ingest.sentry.io/1337', + dsn: __APP_DSN__, integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, release: 'e2e-test', - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __APP_TUNNEL__, }); } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts new file mode 100644 index 000000000000..303cee2a04c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/tanstackstart-react'; +import { createFileRoute } from '@tanstack/react-router'; + +const USE_TUNNEL_ROUTE = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const TUNNEL_DSN = 'http://public@localhost:3031/1337'; + +export const Route = createFileRoute('/monitor')({ + server: Sentry.createSentryTunnelRoute({ + allowedDsns: [USE_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts index 04d93e550824..58797757107d 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts @@ -1,6 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => { const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts index dffab8ea2aa3..e64ae941d2da 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -1,6 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ page, }) => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index 5186514d277a..97d516d9fd30 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -1,6 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); + test('Sends a server function transaction with auto-instrumentation', async ({ page }) => { const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts new file mode 100644 index 000000000000..db911825da25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +test.skip(!useTunnelRoute, 'Tunnel assertions only run in the tunnel variant'); + +test('Sends client-side errors through the monitor tunnel route', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto('/'); + + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + const monitorResponsePromise = page.waitForResponse(response => { + return response.url().endsWith('/monitor') && response.request().method() === 'POST'; + }); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + const monitorResponse = await monitorResponsePromise; + const errorEvent = await errorEventPromise; + + expect(monitorResponse.status()).toBe(200); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); + expect(errorEvent.transaction).toBe('/'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index a2b39609717d..6dffa8366b59 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -5,10 +5,20 @@ import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; + +const appDsn = useTunnelRoute ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337'; + +const appTunnel = useTunnelRoute ? '/monitor' : 'http://localhost:3031/'; + export default defineConfig({ server: { port: 3000, }, + define: { + __APP_DSN__: JSON.stringify(appDsn), + __APP_TUNNEL__: JSON.stringify(appTunnel), + }, plugins: [ tsConfigPaths(), tanstackStart(), From 08b415402b6172fbaffeb59ac2d6279777c50158 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 15 Apr 2026 17:23:10 -0400 Subject: [PATCH 03/77] Refactor TanStack Start e2e app to cover managed tunnel route variants --- .../tanstackstart-react/package.json | 26 ++- .../tanstackstart-react/src/globals.d.ts | 2 +- .../src/routes/custom-monitor.ts | 18 ++ .../tanstackstart-react/src/routes/monitor.ts | 13 -- .../tanstackstart-react/tests/errors.test.ts | 5 +- .../tests/middleware.test.ts | 5 +- .../tests/transaction.test.ts | 5 +- .../tanstackstart-react/tests/tunnel.test.ts | 31 ++- .../tanstackstart-react/vite.config.ts | 37 +++- .../tanstackstart-react/src/client/sdk.ts | 2 + .../src/client/tunnelRoute.ts | 37 ++++ .../tanstackstart-react/src/vite/index.ts | 1 + .../src/vite/sentryTanstackStart.ts | 26 ++- .../src/vite/tunnelRoute.ts | 203 ++++++++++++++++++ .../test/client/sdk.test.ts | 11 + .../test/client/tunnelRoute.test.ts | 59 +++++ .../test/vite/sentryTanstackStart.test.ts | 49 ++++- .../test/vite/tunnelRoute.test.ts | 117 ++++++++++ 18 files changed, 601 insertions(+), 46 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts delete mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts create mode 100644 packages/tanstackstart-react/src/client/tunnelRoute.ts create mode 100644 packages/tanstackstart-react/src/vite/tunnelRoute.ts create mode 100644 packages/tanstackstart-react/test/client/tunnelRoute.test.ts create mode 100644 packages/tanstackstart-react/test/vite/tunnelRoute.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index ee12059975cf..1b6d9761fc94 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,11 +9,15 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:build:tunnel": "pnpm install && E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm build", + "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build", + "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test", - "test:assert": "pnpm test:assert:proxy && E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel", - "test:assert:tunnel": "E2E_TEST_USE_TUNNEL_ROUTE=1 pnpm test" + "test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom", + "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm test", + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "latest || *", @@ -42,9 +46,19 @@ "sentryTest": { "variants": [ { - "label": "tunnel", - "build-command": "pnpm test:build:tunnel", - "assert-command": "pnpm test:assert:tunnel" + "label": "tunnel-generated", + "build-command": "pnpm test:build:tunnel-generated", + "assert-command": "pnpm test:assert:tunnel-generated" + }, + { + "label": "tunnel-static", + "build-command": "pnpm test:build:tunnel-static", + "assert-command": "pnpm test:assert:tunnel-static" + }, + { + "label": "tunnel-custom", + "build-command": "pnpm test:build:tunnel-custom", + "assert-command": "pnpm test:assert:tunnel-custom" } ] } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts index d22cc4c9dd9d..6e7d31c7a4e6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/globals.d.ts @@ -1,2 +1,2 @@ declare const __APP_DSN__: string; -declare const __APP_TUNNEL__: string; +declare const __APP_TUNNEL__: string | undefined; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts new file mode 100644 index 000000000000..dd069a821059 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts @@ -0,0 +1,18 @@ +import * as Sentry from "@sentry/tanstackstart-react"; +import { createFileRoute } from "@tanstack/react-router"; + +const USE_CUSTOM_TUNNEL_ROUTE = + process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === "1"; + +const DEFAULT_DSN = "https://public@dsn.ingest.sentry.io/1337"; +const TUNNEL_DSN = "http://public@localhost:3031/1337"; + +// Example of a manually defined tunnel endpoint without relying on the +// managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`. +// If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's +// `Sentry.init()` call so browser events are sent to the same endpoint. +export const Route = createFileRoute("/custom-monitor")({ + server: Sentry.createSentryTunnelRoute({ + allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts deleted file mode 100644 index 303cee2a04c1..000000000000 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/monitor.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Sentry from '@sentry/tanstackstart-react'; -import { createFileRoute } from '@tanstack/react-router'; - -const USE_TUNNEL_ROUTE = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; - -const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -const TUNNEL_DSN = 'http://public@localhost:3031/1337'; - -export const Route = createFileRoute('/monitor')({ - server: Sentry.createSentryTunnelRoute({ - allowedDsns: [USE_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], - }), -}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts index 58797757107d..a49b77a293b1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; -test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => { const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts index e64ae941d2da..ab31ce5e022a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; -test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ page, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index 97d516d9fd30..f5e70a676432 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; -test.skip(useTunnelRoute, 'Default e2e suites run only in the proxy variant'); +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); test('Sends a server function transaction with auto-instrumentation', async ({ page }) => { const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index db911825da25..1a585446c0f4 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -1,29 +1,46 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; +const tunnelRouteMode = + process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? + (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); -test.skip(!useTunnelRoute, 'Tunnel assertions only run in the tunnel variant'); +test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); -test('Sends client-side errors through the monitor tunnel route', async ({ page }) => { +test('Sends client-side errors through the configured tunnel route', async ({ page }) => { const errorEventPromise = waitForError('tanstackstart-react', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; }); await page.goto('/'); + const pageOrigin = new URL(page.url()).origin; await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); - const monitorResponsePromise = page.waitForResponse(response => { - return response.url().endsWith('/monitor') && response.request().method() === 'POST'; + const managedTunnelResponsePromise = page.waitForResponse(response => { + const responseUrl = new URL(response.url()); + + return responseUrl.origin === pageOrigin && response.request().method() === 'POST'; }); await page.locator('button').filter({ hasText: 'Break the client' }).click(); - const monitorResponse = await monitorResponsePromise; + const managedTunnelResponse = await managedTunnelResponsePromise; + const managedTunnelUrl = new URL(managedTunnelResponse.url()); const errorEvent = await errorEventPromise; - expect(monitorResponse.status()).toBe(200); + expect(managedTunnelResponse.status()).toBe(200); + expect(managedTunnelUrl.origin).toBe(pageOrigin); + + if (tunnelRouteMode === 'static') { + expect(managedTunnelUrl.pathname).toBe('/monitor'); + } else if (tunnelRouteMode === 'custom') { + expect(managedTunnelUrl.pathname).toBe('/custom-monitor'); + } else { + expect(managedTunnelUrl.pathname).toMatch(/^\/[a-z0-9]{8}$/); + expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + } + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); expect(errorEvent.transaction).toBe('/'); }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 6dffa8366b59..8ee4c5ce28a2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -1,15 +1,30 @@ -import { defineConfig } from 'vite'; -import tsConfigPaths from 'vite-tsconfig-paths'; -import { tanstackStart } from '@tanstack/react-start/plugin/vite'; -import viteReact from '@vitejs/plugin-react-swc'; -import { nitro } from 'nitro/vite'; -import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; +import { defineConfig } from "vite"; +import tsConfigPaths from "vite-tsconfig-paths"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react-swc"; +import { nitro } from "nitro/vite"; +import { sentryTanstackStart } from "@sentry/tanstackstart-react/vite"; -const useTunnelRoute = process.env.E2E_TEST_USE_TUNNEL_ROUTE === '1'; +const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? "off"; +const useManagedTunnelRoute = tunnelRouteMode !== "off"; +const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === "1"; -const appDsn = useTunnelRoute ? 'http://public@localhost:3031/1337' : 'https://public@dsn.ingest.sentry.io/1337'; +const appDsn = useManagedTunnelRoute || useCustomTunnelRoute + ? "http://public@localhost:3031/1337" + : "https://public@dsn.ingest.sentry.io/1337"; -const appTunnel = useTunnelRoute ? '/monitor' : 'http://localhost:3031/'; +const appTunnel = useManagedTunnelRoute + ? undefined + : useCustomTunnelRoute + ? "/custom-monitor" + : "http://localhost:3031/"; + +const tunnelRoute = + tunnelRouteMode === "dynamic" + ? { allowedDsns: [appDsn], tunnel: true as const } + : tunnelRouteMode === "static" + ? { allowedDsns: [appDsn], tunnel: "/monitor" } + : undefined; export default defineConfig({ server: { @@ -17,7 +32,8 @@ export default defineConfig({ }, define: { __APP_DSN__: JSON.stringify(appDsn), - __APP_TUNNEL__: JSON.stringify(appTunnel), + __APP_TUNNEL__: + appTunnel === undefined ? "undefined" : JSON.stringify(appTunnel), }, plugins: [ tsConfigPaths(), @@ -30,6 +46,7 @@ export default defineConfig({ project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, debug: true, + tunnelRoute, }), ], }); diff --git a/packages/tanstackstart-react/src/client/sdk.ts b/packages/tanstackstart-react/src/client/sdk.ts index b0ee3b53053f..0998c027f112 100644 --- a/packages/tanstackstart-react/src/client/sdk.ts +++ b/packages/tanstackstart-react/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as initReactSDK } from '@sentry/react'; +import { applyTunnelRouteOption } from './tunnelRoute'; /** * Initializes the TanStack Start React SDK @@ -14,6 +15,7 @@ export function init(options: ReactBrowserOptions): Client | undefined { ...options, }; + applyTunnelRouteOption(sentryOptions); applySdkMetadata(sentryOptions, 'tanstackstart-react', ['tanstackstart-react', 'react']); return initReactSDK(sentryOptions); diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts new file mode 100644 index 000000000000..8817a6637f55 --- /dev/null +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -0,0 +1,37 @@ +import { consoleSandbox } from '@sentry/core'; +import type { BrowserOptions as ReactBrowserOptions } from '@sentry/react'; + +declare const __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: string | undefined; + +let hasWarnedAboutManagedTunnelRouteOverride = false; + +/** + * Applies the managed tunnel route from `sentryTanstackStart({ tunnelRoute: ... })` unless the user already + * configured an explicit runtime `tunnel` option in `Sentry.init()`. + */ +export function applyTunnelRouteOption(options: ReactBrowserOptions): void { + const managedTunnelRoute = + typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' + ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ + : undefined; + + if (!managedTunnelRoute) { + return; + } + + if (options.tunnel) { + if (!hasWarnedAboutManagedTunnelRouteOverride) { + hasWarnedAboutManagedTunnelRouteOverride = true; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + } + + return; + } + + options.tunnel = managedTunnelRoute; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts index 85143344028d..b7f65e26f5d2 100644 --- a/packages/tanstackstart-react/src/vite/index.ts +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -1,2 +1,3 @@ export { sentryTanstackStart } from './sentryTanstackStart'; export type { SentryTanstackStartOptions } from './sentryTanstackStart'; +export type { TunnelRouteOptions } from './tunnelRoute'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index fd5d5b2f0d05..17cbc1b465a1 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,8 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import type { TunnelRouteOptions } from './tunnelRoute'; +import { makeTunnelRoutePlugin } from './tunnelRoute'; /** * Build-time options for the Sentry TanStack Start SDK. @@ -19,6 +21,18 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * Configures a framework-managed same-origin tunnel route for Sentry envelopes. + * + * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path + * as the default `tunnel` option on the client. Use `tunnel: true` to generate an opaque route path per dev session + * or production build, or provide a static absolute path string to control the route name yourself. + * + * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because + * the managed tunnel route is being bypassed. + */ + tunnelRoute?: TunnelRouteOptions; } /** @@ -46,13 +60,21 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - // only add plugins in production builds + const tunnelRoutePlugin = options.tunnelRoute + ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) + : undefined; + + // only add build-time plugins in production builds if (process.env.NODE_ENV === 'development') { - return []; + return tunnelRoutePlugin ? [tunnelRoutePlugin] : []; } const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + if (tunnelRoutePlugin) { + plugins.push(tunnelRoutePlugin); + } + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts new file mode 100644 index 000000000000..aef2b8933f44 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -0,0 +1,203 @@ +import type { Plugin } from "vite"; + +export interface TunnelRouteOptions { + /** + * A list of DSNs that are allowed to use the managed tunnel route. + */ + allowedDsns: string[]; + + /** + * Controls the public route path used by the managed tunnel route. + * + * - `true` generates an opaque path once per dev session or production build. + * - `'/custom-path'` uses a fixed absolute route path. + * + * @default true + */ + tunnel?: true | string; +} + +const MANAGED_TUNNEL_ROUTE_IMPORT = "SentryManagedTunnelRouteImport"; +const MANAGED_TUNNEL_ROUTE_NAME = "SentryManagedTunnelRoute"; +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = + "__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__"; + +const VIRTUAL_TUNNEL_ROUTE_ID = + "virtual:sentry-tanstackstart-react/tunnel-route"; +const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; + +function generateRandomTunnelRoute(): string { + return `/${Math.random().toString(36).substring(2, 10)}`; +} + +export function resolveTunnelRoute(tunnel: true | string): string { + if (typeof tunnel === "string") { + return tunnel; + } + + if (process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]) { + return process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + } + + const resolvedTunnelRoute = generateRandomTunnelRoute(); + process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY] = resolvedTunnelRoute; + return resolvedTunnelRoute; +} + +function validateTunnelRouteOptions(options: TunnelRouteOptions): string { + if (options.allowedDsns.length === 0) { + throw new Error( + "[@sentry/tanstackstart-react] `sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN.", + ); + } + + const tunnelRoute = options.tunnel ?? true; + + if ( + typeof tunnelRoute === "string" && + (!tunnelRoute.startsWith("/") || + tunnelRoute.includes("?") || + tunnelRoute.includes("#")) + ) { + throw new Error( + "[@sentry/tanstackstart-react] `tunnelRoute.tunnel` must be `true` or an absolute route path starting with `/` and without query or hash segments.", + ); + } + + return resolveTunnelRoute(tunnelRoute); +} + +function hasRouteConflict( + source: string, + resolvedTunnelRoute: string, +): boolean { + return ( + source.includes(`fullPath: '${resolvedTunnelRoute}'`) || + source.includes(`path: '${resolvedTunnelRoute}'`) || + source.includes(`id: '${resolvedTunnelRoute}'`) + ); +} + +function injectAfterLastImport(source: string, statement: string): string { + const importMatches = [...source.matchAll(/^import .+$/gm)]; + const lastImport = importMatches.at(-1); + + if (!lastImport || lastImport.index === undefined) { + throw new Error( + "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.", + ); + } + + const insertIndex = lastImport.index + lastImport[0].length; + return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; +} + +export function injectManagedTunnelRoute( + source: string, + resolvedTunnelRoute: string, +): string { + if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { + return source; + } + + if (hasRouteConflict(source, resolvedTunnelRoute)) { + throw new Error( + `[@sentry/tanstackstart-react] Cannot register managed tunnel route "${resolvedTunnelRoute}" because an existing TanStack Start route already uses that path.`, + ); + } + + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + + let transformedSource = injectAfterLastImport( + source, + `import { Route as ${MANAGED_TUNNEL_ROUTE_IMPORT} } from '${VIRTUAL_TUNNEL_ROUTE_ID}'`, + ); + + const rootRouteChildrenMatch = transformedSource.match( + /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, + ); + + if (!rootRouteChildrenMatch || rootRouteChildrenMatch.index === undefined) { + throw new Error( + "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.", + ); + } + + const injectedRootRouteChildrenDeclaration = `const ${MANAGED_TUNNEL_ROUTE_NAME} = ${MANAGED_TUNNEL_ROUTE_IMPORT}.update({ + id: ${serializedTunnelRoute}, + path: ${serializedTunnelRoute}, + getParentRoute: () => rootRouteImport, +} as any) + +${rootRouteChildrenMatch[0]} + ${MANAGED_TUNNEL_ROUTE_NAME}: ${MANAGED_TUNNEL_ROUTE_NAME}, +`; + + transformedSource = `${transformedSource.slice(0, rootRouteChildrenMatch.index)}${injectedRootRouteChildrenDeclaration}${transformedSource.slice(rootRouteChildrenMatch.index + rootRouteChildrenMatch[0].length)}`; + + return transformedSource; +} + +export function makeTunnelRoutePlugin( + options: TunnelRouteOptions, + debug?: boolean, +): Plugin { + const resolvedTunnelRoute = validateTunnelRouteOptions(options); + const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); + const serializedAllowedDsns = JSON.stringify(options.allowedDsns); + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`, + ); + } + + return { + name: "sentry-tanstackstart-tunnel-route", + enforce: "pre", + config() { + return { + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: serializedTunnelRoute, + }, + }; + }, + resolveId(source) { + return source === VIRTUAL_TUNNEL_ROUTE_ID + ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID + : null; + }, + load(id) { + if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { + return null; + } + + return `import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute(${serializedTunnelRoute})({ + server: { + handlers: { + async POST({ request }) { + const Sentry = await import('@sentry/tanstackstart-react'); + return Sentry.createSentryTunnelRoute({ + allowedDsns: ${serializedAllowedDsns}, + }).handlers.POST({ request }); + }, + }, + }, +}); +`; + }, + transform(source, id) { + if ( + !id.endsWith("/routeTree.gen.ts") && + !id.endsWith("\\routeTree.gen.ts") + ) { + return null; + } + + return injectManagedTunnelRoute(source, resolvedTunnelRoute); + }, + }; +} diff --git a/packages/tanstackstart-react/test/client/sdk.test.ts b/packages/tanstackstart-react/test/client/sdk.test.ts index 4cba4a199ef5..400bbe877dc1 100644 --- a/packages/tanstackstart-react/test/client/sdk.test.ts +++ b/packages/tanstackstart-react/test/client/sdk.test.ts @@ -9,6 +9,7 @@ describe('TanStack Start React Client SDK', () => { describe('init', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('Adds TanStack Start React client metadata to the SDK options', () => { @@ -41,5 +42,15 @@ describe('TanStack Start React Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('applies the managed tunnel route when no runtime tunnel is provided', () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ tunnel: '/managed-tunnel' })); + }); }); }); diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts new file mode 100644 index 000000000000..90b91481305b --- /dev/null +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -0,0 +1,59 @@ +import type { BrowserOptions } from '@sentry/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('applyTunnelRouteOption()', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('applies the managed tunnel route when no runtime tunnel is set', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/managed-tunnel'); + }); + + it('does not override an explicit runtime tunnel and warns instead', async () => { + vi.stubGlobal('__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__', '/managed-tunnel'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: '/runtime-tunnel', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBe('/runtime-tunnel'); + expect(warnSpy).toHaveBeenCalledWith( + '[@sentry/tanstackstart-react] `Sentry.init({ tunnel: ... })` overrides the managed `sentryTanstackStart({ tunnelRoute: ... })` route. Remove the runtime `tunnel` option if you want the managed tunnel route to be used.', + ); + }); + + it('does nothing when no managed tunnel route was injected', async () => { + const { applyTunnelRouteOption } = await import('../../src/client/tunnelRoute'); + + const options: BrowserOptions = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + applyTunnelRouteOption(options); + + expect(options.tunnel).toBeUndefined(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..004251c63b66 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -2,6 +2,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', @@ -28,6 +29,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockTunnelRoutePlugin: Plugin = { + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', + transform: vi.fn(), +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +44,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/tunnelRoute', () => ({ + makeTunnelRoutePlugin: vi.fn(() => mockTunnelRoutePlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,7 +65,7 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); }); - it('returns no plugins in development mode', () => { + it('returns no plugins in development mode when tunnelRoute is not configured', () => { process.env.NODE_ENV = 'development'; const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); @@ -62,6 +73,17 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([]); }); + it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { + process.env.NODE_ENV = 'development'; + + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, + }); + + expect(plugins).toEqual([mockTunnelRoutePlugin]); + }); + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false, @@ -127,4 +149,29 @@ describe('sentryTanstackStart()', () => { expect(makeAutoInstrumentMiddlewarePlugin).toHaveBeenCalledWith({ enabled: true, debug: undefined }); }); }); + + describe('managed tunnel route', () => { + it('includes the managed tunnel route plugin in production when configured', () => { + const plugins = sentryTanstackStart({ + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + tunnel: '/monitor', + }, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockTunnelRoutePlugin, mockMiddlewarePlugin]); + }); + + it('passes tunnelRoute options through to the tunnel route plugin', () => { + const tunnelRoute = { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + tunnel: '/monitor' as const, + }; + + sentryTanstackStart({ tunnelRoute, sourcemaps: { disable: true } }); + + expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(tunnelRoute, undefined); + }); + }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts new file mode 100644 index 000000000000..aca2e207213c --- /dev/null +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; + +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__'; + +const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) +`; + +const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( + 'const rootRouteChildren: RootRouteChildren = {', + 'const rootRouteChildren = {', +); + +describe('tunnelRoute vite plugin', () => { + beforeEach(() => { + delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + }); + + afterEach(() => { + delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + }); + + it('reuses the same generated tunnel route within one process', () => { + const firstTunnelRoute = resolveTunnelRoute(true); + const secondTunnelRoute = resolveTunnelRoute(true); + + expect(firstTunnelRoute).toBe(secondTunnelRoute); + expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); + }); + + it('returns the provided static tunnel route without reusing a generated one', () => { + resolveTunnelRoute(true); + + expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); + }); + + it('rejects empty allowedDsns', () => { + expect(() => makeTunnelRoutePlugin({ allowedDsns: [] })).toThrow( + '`sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN', + ); + }); + + it('rejects invalid static tunnel routes', () => { + expect(() => makeTunnelRoutePlugin({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'], tunnel: 'monitor' })).toThrow( + '`tunnelRoute.tunnel` must be `true` or an absolute route path', + ); + }); + + it('injects the managed tunnel route into the generated TanStack route tree', () => { + const transformedRouteTree = injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain( + "import { Route as SentryManagedTunnelRouteImport } from 'virtual:sentry-tanstackstart-react/tunnel-route'", + ); + expect(transformedRouteTree).toContain('const SentryManagedTunnelRoute = SentryManagedTunnelRouteImport.update({'); + expect(transformedRouteTree).toContain('id: "/monitor"'); + expect(transformedRouteTree).toContain('path: "/monitor"'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + expect(transformedRouteTree).toContain('IndexRoute: IndexRoute,'); + }); + + it('injects the managed tunnel route when rootRouteChildren is untyped', () => { + const transformedRouteTree = injectManagedTunnelRoute(UNTYPED_ROUTE_TREE_SOURCE, '/monitor'); + + expect(transformedRouteTree).toContain('const rootRouteChildren = {'); + expect(transformedRouteTree).toContain('SentryManagedTunnelRoute: SentryManagedTunnelRoute,'); + }); + + it('fails when the managed tunnel route conflicts with an existing route', () => { + expect(() => injectManagedTunnelRoute(ROUTE_TREE_SOURCE, '/')).toThrow( + 'Cannot register managed tunnel route "/" because an existing TanStack Start route already uses that path.', + ); + }); + + it('loads a virtual managed tunnel route module for a static tunnel path', async () => { + const plugin = makeTunnelRoutePlugin({ + allowedDsns: ['http://public@localhost:3031/1337'], + tunnel: '/monitor', + }); + + expect(plugin.config && plugin.config()).toEqual({ + define: { + __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__: '"/monitor"', + }, + }); + + expect(plugin.resolveId && plugin.resolveId('virtual:sentry-tanstackstart-react/tunnel-route')).toBe( + '\0virtual:sentry-tanstackstart-react/tunnel-route', + ); + + const virtualRouteModule = + plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); + }); +}); From 98202a900b7a39c32c8039a0e8b20c5a8b8151e5 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Wed, 15 Apr 2026 17:48:18 -0400 Subject: [PATCH 04/77] Generate stable 8-character tunnel route paths --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 6 +++++- packages/tanstackstart-react/test/vite/tunnelRoute.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index aef2b8933f44..f50a30ea7bac 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -27,7 +27,11 @@ const VIRTUAL_TUNNEL_ROUTE_ID = const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; function generateRandomTunnelRoute(): string { - return `/${Math.random().toString(36).substring(2, 10)}`; + const randomPath = Array.from({ length: 8 }, () => + Math.floor(Math.random() * 36).toString(36), + ).join(""); + + return `/${randomPath}`; } export function resolveTunnelRoute(tunnel: true | string): string { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index aca2e207213c..486a557e093e 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__'; @@ -48,6 +49,12 @@ describe('tunnelRoute vite plugin', () => { expect(firstTunnelRoute).toMatch(/^\/[a-z0-9]{8}$/); }); + it('always generates an 8-character tunnel route', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + expect(resolveTunnelRoute(true)).toBe('/iiiiiiii'); + }); + it('returns the provided static tunnel route without reusing a generated one', () => { resolveTunnelRoute(true); From 320a55f40503ae539f77fcc5e54d44b94fec359b Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 16 Apr 2026 10:00:34 -0400 Subject: [PATCH 05/77] refactor(tanstackstart-react): mark tunnel route cache key internal --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 2 +- packages/tanstackstart-react/test/vite/tunnelRoute.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index f50a30ea7bac..a64c98edbf6d 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -20,7 +20,7 @@ export interface TunnelRouteOptions { const MANAGED_TUNNEL_ROUTE_IMPORT = "SentryManagedTunnelRouteImport"; const MANAGED_TUNNEL_ROUTE_NAME = "SentryManagedTunnelRoute"; const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = - "__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__"; + "__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__"; const VIRTUAL_TUNNEL_ROUTE_ID = "virtual:sentry-tanstackstart-react/tunnel-route"; diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 486a557e093e..c9411dc1745a 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { vi } from 'vitest'; import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__'; +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' From 73fbbeb9cd233e04add9962b95c377aa257bc0ac Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 16 Apr 2026 10:18:54 -0400 Subject: [PATCH 06/77] test(tanstackstart-react): narrow tunnel response matcher --- .../tanstackstart-react/tests/tunnel.test.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 1a585446c0f4..d5bf24e72c97 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -4,6 +4,12 @@ import { waitForError } from '@sentry-internal/test-utils'; const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const expectedTunnelPathMatcher = + tunnelRouteMode === 'static' + ? '/monitor' + : tunnelRouteMode === 'custom' + ? '/custom-monitor' + : /^\/[a-z0-9]{8}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); @@ -20,7 +26,13 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa const managedTunnelResponsePromise = page.waitForResponse(response => { const responseUrl = new URL(response.url()); - return responseUrl.origin === pageOrigin && response.request().method() === 'POST'; + return ( + responseUrl.origin === pageOrigin && + response.request().method() === 'POST' && + (typeof expectedTunnelPathMatcher === 'string' + ? responseUrl.pathname === expectedTunnelPathMatcher + : expectedTunnelPathMatcher.test(responseUrl.pathname)) + ); }); await page.locator('button').filter({ hasText: 'Break the client' }).click(); @@ -32,12 +44,10 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa expect(managedTunnelResponse.status()).toBe(200); expect(managedTunnelUrl.origin).toBe(pageOrigin); - if (tunnelRouteMode === 'static') { - expect(managedTunnelUrl.pathname).toBe('/monitor'); - } else if (tunnelRouteMode === 'custom') { - expect(managedTunnelUrl.pathname).toBe('/custom-monitor'); + if (typeof expectedTunnelPathMatcher === 'string') { + expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); } else { - expect(managedTunnelUrl.pathname).toMatch(/^\/[a-z0-9]{8}$/); + expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); expect(managedTunnelUrl.pathname).not.toBe('/monitor'); } From ee3da8ddad983816f983427ad2e2ef0b08b59f89 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 16 Apr 2026 10:50:25 -0400 Subject: [PATCH 07/77] fix(tanstackstart-react): stub createSentryTunnelRoute on client --- .../tanstackstart-react/src/client/index.ts | 17 +++++++++++++++++ packages/tanstackstart-react/src/index.types.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 3e762580830c..7607c32faaa3 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -2,6 +2,7 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { TanStackMiddlewareBase } from '../common/types'; +import type { CreateSentryTunnelRouteOptions } from '../server/tunnelRoute'; export * from '@sentry/react'; @@ -26,3 +27,19 @@ export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { '~types': * The actual implementation is server-only, but this stub is needed to prevent rendering errors. */ export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} }; + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent rendering errors. + */ +export function createSentryTunnelRoute(_options: CreateSentryTunnelRouteOptions): { + handlers: { + POST: () => Promise; + }; +} { + return { + handlers: { + POST: async () => new Response(null, { status: 500 }), + }, + }; +} diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 76e4ced73c92..79edcef0bbfa 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -42,3 +42,4 @@ export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewares export declare const tanstackRouterBrowserTracingIntegration: typeof clientSdk.tanstackRouterBrowserTracingIntegration; export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware; export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware; +export declare const createSentryTunnelRoute: typeof serverSdk.createSentryTunnelRoute; From 1d827f5dd7113699be9b9d12f53cc8ad182453ea Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 20 Apr 2026 16:21:30 -0400 Subject: [PATCH 08/77] Refactor TanStack Start tunnel route handling --- .../tanstackstart-react/package.json | 8 +- .../tanstackstart-react/vite.config.ts | 4 +- .../src/server/tunnelRoute.ts | 24 ++++- .../src/vite/sentryTanstackStart.ts | 9 +- .../src/vite/sourceMaps.ts | 4 +- .../src/vite/tunnelRoute.ts | 91 ++++++++++++------- .../test/server/tunnelRoute.test.ts | 44 +++++++++ .../test/vite/tunnelRoute.test.ts | 28 ++++-- 8 files changed, 156 insertions(+), 56 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 1b6d9761fc94..ff18677ee970 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,14 +9,14 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build", - "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build", + "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test", "test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom", - "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm test", - "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm test", + "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 8ee4c5ce28a2..ec12139be62c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -21,9 +21,9 @@ const appTunnel = useManagedTunnelRoute const tunnelRoute = tunnelRouteMode === "dynamic" - ? { allowedDsns: [appDsn], tunnel: true as const } + ? true : tunnelRouteMode === "static" - ? { allowedDsns: [appDsn], tunnel: "/monitor" } + ? "/monitor" : undefined; export default defineConfig({ diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index 44ff32934a56..d2dae0b1ddac 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -1,7 +1,7 @@ -import { handleTunnelRequest } from '@sentry/core'; +import { dsnToString, getClient, handleTunnelRequest } from '@sentry/core'; export interface CreateSentryTunnelRouteOptions { - allowedDsns: string[]; + allowedDsns?: string[]; } type SentryTunnelRouteHandlerContext = { @@ -33,9 +33,27 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) return { handlers: { POST: async ({ request }) => { + const allowedDsnsFromOptions = + options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; + + const allowedDsns = + allowedDsnsFromOptions ?? + (() => { + const client = getClient(); + const dsn = client?.getDsn(); + return dsn ? [dsnToString(dsn)] : undefined; + })(); + + if (!allowedDsns) { + return new Response( + 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', + { status: 500 }, + ); + } + return handleTunnelRequest({ request, - allowedDsns: options.allowedDsns, + allowedDsns, }); }, }, diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 17cbc1b465a1..be124f86e359 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -26,8 +26,13 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * Configures a framework-managed same-origin tunnel route for Sentry envelopes. * * This creates a TanStack Start server route backed by `createSentryTunnelRoute()` and applies the resulting path - * as the default `tunnel` option on the client. Use `tunnel: true` to generate an opaque route path per dev session - * or production build, or provide a static absolute path string to control the route name yourself. + * as the default `tunnel` option on the client. + * + * You can pass: + * - `true` to generate an opaque route path per dev session or production build. + * - `'/custom-path'` to use a fixed static route path. + * - `{ allowedDsns, path }` for full control. If `allowedDsns` is omitted or empty, the tunnel route derives the DSN + * from the active server Sentry client at runtime. * * If you also pass `tunnel` to `Sentry.init()`, that explicit runtime option wins and a warning is emitted because * the managed tunnel route is being bypassed. diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 288c725dbc93..296e8582cde8 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -65,7 +65,9 @@ export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[] assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + // BuildTimeOptionsBase types can lag behind bundler plugin options in some local setups. + // Keep runtime support while staying resilient to type version skew. + rewriteSources: (sourcemaps as unknown as { rewriteSources?: unknown } | undefined)?.rewriteSources as never, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, telemetry: telemetry ?? true, diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index a64c98edbf6d..6db84b397255 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,21 +1,23 @@ import type { Plugin } from "vite"; -export interface TunnelRouteOptions { - /** - * A list of DSNs that are allowed to use the managed tunnel route. - */ - allowedDsns: string[]; - - /** - * Controls the public route path used by the managed tunnel route. - * - * - `true` generates an opaque path once per dev session or production build. - * - `'/custom-path'` uses a fixed absolute route path. - * - * @default true - */ - tunnel?: true | string; -} +export type TunnelRouteOptions = + | true + | string + | { + /** + * A list of DSNs that are allowed to use the managed tunnel route. + * + * If omitted or empty, the tunnel route will derive the allowed DSN from the active server Sentry SDK at runtime. + */ + allowedDsns?: string[]; + + /** + * Controls the public route path used by the managed tunnel route. + * + * If omitted, an opaque path is generated once per dev session or production build. + */ + path?: string; + }; const MANAGED_TUNNEL_ROUTE_IMPORT = "SentryManagedTunnelRouteImport"; const MANAGED_TUNNEL_ROUTE_NAME = "SentryManagedTunnelRoute"; @@ -48,27 +50,45 @@ export function resolveTunnelRoute(tunnel: true | string): string { return resolvedTunnelRoute; } -function validateTunnelRouteOptions(options: TunnelRouteOptions): string { - if (options.allowedDsns.length === 0) { +type NormalizedTunnelRouteOptions = { + resolvedPath: string; + allowedDsns: string[] | undefined; +}; + +function validateStaticPath(path: string): void { + if (!path.startsWith("/") || path.includes("?") || path.includes("#")) { throw new Error( - "[@sentry/tanstackstart-react] `sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN.", + "[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.", ); } +} + +function normalizeTunnelRouteOptions( + options: TunnelRouteOptions, +): NormalizedTunnelRouteOptions { + if (options === true) { + return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; + } - const tunnelRoute = options.tunnel ?? true; + if (typeof options === "string") { + validateStaticPath(options); + return { + resolvedPath: resolveTunnelRoute(options), + allowedDsns: undefined, + }; + } - if ( - typeof tunnelRoute === "string" && - (!tunnelRoute.startsWith("/") || - tunnelRoute.includes("?") || - tunnelRoute.includes("#")) - ) { - throw new Error( - "[@sentry/tanstackstart-react] `tunnelRoute.tunnel` must be `true` or an absolute route path starting with `/` and without query or hash segments.", - ); + const allowedDsns = + options.allowedDsns && options.allowedDsns.length > 0 + ? options.allowedDsns + : undefined; + const path = options.path; + + if (path) { + validateStaticPath(path); } - return resolveTunnelRoute(tunnelRoute); + return { resolvedPath: resolveTunnelRoute(path ?? true), allowedDsns }; } function hasRouteConflict( @@ -146,9 +166,12 @@ export function makeTunnelRoutePlugin( options: TunnelRouteOptions, debug?: boolean, ): Plugin { - const resolvedTunnelRoute = validateTunnelRouteOptions(options); + const normalized = normalizeTunnelRouteOptions(options); + const resolvedTunnelRoute = normalized.resolvedPath; const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - const serializedAllowedDsns = JSON.stringify(options.allowedDsns); + const serializedAllowedDsns = normalized.allowedDsns + ? JSON.stringify(normalized.allowedDsns) + : undefined; if (debug) { // eslint-disable-next-line no-console @@ -184,9 +207,7 @@ export const Route = createFileRoute(${serializedTunnelRoute})({ handlers: { async POST({ request }) { const Sentry = await import('@sentry/tanstackstart-react'); - return Sentry.createSentryTunnelRoute({ - allowedDsns: ${serializedAllowedDsns}, - }).handlers.POST({ request }); + return Sentry.createSentryTunnelRoute(${serializedAllowedDsns ? `{ allowedDsns: ${serializedAllowedDsns} }` : `{}`}).handlers.POST({ request }); }, }, }, diff --git a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts index b638b779e39e..59e9ae130e0e 100644 --- a/packages/tanstackstart-react/test/server/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/server/tunnelRoute.test.ts @@ -1,12 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const handleTunnelRequestSpy = vi.fn(); +const getClientSpy = vi.fn(); vi.mock('@sentry/core', async importOriginal => { const original = await importOriginal(); return { ...original, handleTunnelRequest: (...args: unknown[]) => handleTunnelRequestSpy(...args), + getClient: (...args: unknown[]) => getClientSpy(...args), }; }); @@ -45,4 +47,46 @@ describe('createSentryTunnelRoute', () => { expect(options.allowedDsns).toBe(allowedDsns); expect(result).toBe(response); }); + + it('derives the allowed DSN from the active server Sentry client when allowedDsns is omitted', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + const response = new Response('ok', { status: 200 }); + + getClientSpy.mockReturnValueOnce({ + getDsn: () => ({ + protocol: 'http', + publicKey: 'public', + pass: '', + host: 'localhost', + port: '3031', + path: '', + projectId: '1337', + }), + }); + handleTunnelRequestSpy.mockResolvedValueOnce(response); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).toHaveBeenCalledTimes(1); + const [options] = handleTunnelRequestSpy.mock.calls[0]!; + expect(options).toEqual({ + request, + allowedDsns: ['http://public@localhost:3031/1337'], + }); + expect(result).toBe(response); + }); + + it('returns 500 when allowedDsns is omitted and no active server Sentry client DSN exists', async () => { + const request = new Request('http://localhost:3000/monitoring', { method: 'POST', body: 'envelope' }); + + getClientSpy.mockReturnValueOnce(undefined); + + const route = createSentryTunnelRoute({}); + const result = await route.handlers.POST({ request }); + + expect(handleTunnelRequestSpy).not.toHaveBeenCalled(); + expect(result.status).toBe(500); + await expect(result.text()).resolves.toContain('Tunnel route requires Sentry server SDK initialized with a DSN'); + }); }); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index c9411dc1745a..681b4db4f1b5 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -61,15 +61,15 @@ describe('tunnelRoute vite plugin', () => { expect(resolveTunnelRoute('/monitor')).toBe('/monitor'); }); - it('rejects empty allowedDsns', () => { - expect(() => makeTunnelRoutePlugin({ allowedDsns: [] })).toThrow( - '`sentryTanstackStart({ tunnelRoute })` requires at least one allowed DSN', - ); - }); - it('rejects invalid static tunnel routes', () => { - expect(() => makeTunnelRoutePlugin({ allowedDsns: ['https://public@o0.ingest.sentry.io/0'], tunnel: 'monitor' })).toThrow( - '`tunnelRoute.tunnel` must be `true` or an absolute route path', + expect(() => makeTunnelRoutePlugin('monitor')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin('/monitor?x=1')).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', + ); + expect(() => makeTunnelRoutePlugin({ path: 'monitor' })).toThrow( + 'static paths must start with `/` and must not contain query or hash segments', ); }); @@ -102,7 +102,7 @@ describe('tunnelRoute vite plugin', () => { it('loads a virtual managed tunnel route module for a static tunnel path', async () => { const plugin = makeTunnelRoutePlugin({ allowedDsns: ['http://public@localhost:3031/1337'], - tunnel: '/monitor', + path: '/monitor', }); expect(plugin.config && plugin.config()).toEqual({ @@ -121,4 +121,14 @@ describe('tunnelRoute vite plugin', () => { expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); }); + + it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { + const plugin = makeTunnelRoutePlugin('/monitor'); + + const virtualRouteModule = + plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + + expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); + expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); + }); }); From 4390705d994f815d184c9406a13e46f950b25088 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 20 Apr 2026 16:25:52 -0400 Subject: [PATCH 09/77] Fix tunnel route lint issues --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 4 ++-- packages/tanstackstart-react/test/vite/tunnelRoute.test.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 6db84b397255..2ea92f0f9987 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -106,7 +106,7 @@ function injectAfterLastImport(source: string, statement: string): string { const importMatches = [...source.matchAll(/^import .+$/gm)]; const lastImport = importMatches.at(-1); - if (!lastImport || lastImport.index === undefined) { + if (lastImport?.index === undefined) { throw new Error( "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.", ); @@ -141,7 +141,7 @@ export function injectManagedTunnelRoute( /const rootRouteChildren(?:\s*:\s*RootRouteChildren)?\s*=\s*\{/, ); - if (!rootRouteChildrenMatch || rootRouteChildrenMatch.index === undefined) { + if (rootRouteChildrenMatch?.index === undefined) { throw new Error( "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.", ); diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 681b4db4f1b5..7923fc3a6454 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -2,8 +2,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { vi } from 'vitest'; import { injectManagedTunnelRoute, makeTunnelRoutePlugin, resolveTunnelRoute } from '../../src/vite/tunnelRoute'; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; - const ROUTE_TREE_SOURCE = `import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' @@ -34,11 +32,11 @@ const UNTYPED_ROUTE_TREE_SOURCE = ROUTE_TREE_SOURCE.replace( describe('tunnelRoute vite plugin', () => { beforeEach(() => { - delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; }); afterEach(() => { - delete process.env[MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY]; + delete process.env.__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__; }); it('reuses the same generated tunnel route within one process', () => { From e68a2106c7d8aa6a8ef1dd61bfb2f53e59641561 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Mon, 20 Apr 2026 16:33:41 -0400 Subject: [PATCH 10/77] Refactor TanStack tunnel route config --- .../src/routes/custom-monitor.ts | 13 ++-- .../tanstackstart-react/tests/tunnel.test.ts | 9 +-- .../tanstackstart-react/vite.config.ts | 39 +++++----- .../src/client/tunnelRoute.ts | 4 +- .../src/vite/sentryTanstackStart.ts | 4 +- .../src/vite/tunnelRoute.ts | 73 ++++++------------- .../test/vite/sentryTanstackStart.test.ts | 7 +- .../test/vite/tunnelRoute.test.ts | 6 +- 8 files changed, 58 insertions(+), 97 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts index dd069a821059..1409123f0402 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/custom-monitor.ts @@ -1,17 +1,16 @@ -import * as Sentry from "@sentry/tanstackstart-react"; -import { createFileRoute } from "@tanstack/react-router"; +import * as Sentry from '@sentry/tanstackstart-react'; +import { createFileRoute } from '@tanstack/react-router'; -const USE_CUSTOM_TUNNEL_ROUTE = - process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === "1"; +const USE_CUSTOM_TUNNEL_ROUTE = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; -const DEFAULT_DSN = "https://public@dsn.ingest.sentry.io/1337"; -const TUNNEL_DSN = "http://public@localhost:3031/1337"; +const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const TUNNEL_DSN = 'http://public@localhost:3031/1337'; // Example of a manually defined tunnel endpoint without relying on the // managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`. // If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's // `Sentry.init()` call so browser events are sent to the same endpoint. -export const Route = createFileRoute("/custom-monitor")({ +export const Route = createFileRoute('/custom-monitor')({ server: Sentry.createSentryTunnelRoute({ allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], }), diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index d5bf24e72c97..110d2df31d60 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -2,14 +2,9 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; const tunnelRouteMode = - process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? - (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); + process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); const expectedTunnelPathMatcher = - tunnelRouteMode === 'static' - ? '/monitor' - : tunnelRouteMode === 'custom' - ? '/custom-monitor' - : /^\/[a-z0-9]{8}$/; + tunnelRouteMode === 'static' ? '/monitor' : tunnelRouteMode === 'custom' ? '/custom-monitor' : /^\/[a-z0-9]{8}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index ec12139be62c..ca46cd58d53e 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -1,30 +1,26 @@ -import { defineConfig } from "vite"; -import tsConfigPaths from "vite-tsconfig-paths"; -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import viteReact from "@vitejs/plugin-react-swc"; -import { nitro } from "nitro/vite"; -import { sentryTanstackStart } from "@sentry/tanstackstart-react/vite"; +import { defineConfig } from 'vite'; +import tsConfigPaths from 'vite-tsconfig-paths'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; -const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? "off"; -const useManagedTunnelRoute = tunnelRouteMode !== "off"; -const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === "1"; +const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; +const useManagedTunnelRoute = tunnelRouteMode !== 'off'; +const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; -const appDsn = useManagedTunnelRoute || useCustomTunnelRoute - ? "http://public@localhost:3031/1337" - : "https://public@dsn.ingest.sentry.io/1337"; +const appDsn = + useManagedTunnelRoute || useCustomTunnelRoute + ? 'http://public@localhost:3031/1337' + : 'https://public@dsn.ingest.sentry.io/1337'; const appTunnel = useManagedTunnelRoute ? undefined : useCustomTunnelRoute - ? "/custom-monitor" - : "http://localhost:3031/"; + ? '/custom-monitor' + : 'http://localhost:3031/'; -const tunnelRoute = - tunnelRouteMode === "dynamic" - ? true - : tunnelRouteMode === "static" - ? "/monitor" - : undefined; +const tunnelRoute = tunnelRouteMode === 'dynamic' ? true : tunnelRouteMode === 'static' ? '/monitor' : undefined; export default defineConfig({ server: { @@ -32,8 +28,7 @@ export default defineConfig({ }, define: { __APP_DSN__: JSON.stringify(appDsn), - __APP_TUNNEL__: - appTunnel === undefined ? "undefined" : JSON.stringify(appTunnel), + __APP_TUNNEL__: appTunnel === undefined ? 'undefined' : JSON.stringify(appTunnel), }, plugins: [ tsConfigPaths(), diff --git a/packages/tanstackstart-react/src/client/tunnelRoute.ts b/packages/tanstackstart-react/src/client/tunnelRoute.ts index 8817a6637f55..b22612c123d0 100644 --- a/packages/tanstackstart-react/src/client/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/client/tunnelRoute.ts @@ -11,9 +11,7 @@ let hasWarnedAboutManagedTunnelRouteOverride = false; */ export function applyTunnelRouteOption(options: ReactBrowserOptions): void { const managedTunnelRoute = - typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' - ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ - : undefined; + typeof __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ !== 'undefined' ? __SENTRY_TANSTACKSTART_TUNNEL_ROUTE__ : undefined; if (!managedTunnelRoute) { return; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index be124f86e359..75e9963c0387 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -65,9 +65,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const tunnelRoutePlugin = options.tunnelRoute - ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) - : undefined; + const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined; // only add build-time plugins in production builds if (process.env.NODE_ENV === 'development') { diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 2ea92f0f9987..e78146c8889a 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "vite"; +import type { Plugin } from 'vite'; export type TunnelRouteOptions = | true @@ -19,25 +19,21 @@ export type TunnelRouteOptions = path?: string; }; -const MANAGED_TUNNEL_ROUTE_IMPORT = "SentryManagedTunnelRouteImport"; -const MANAGED_TUNNEL_ROUTE_NAME = "SentryManagedTunnelRoute"; -const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = - "__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__"; +const MANAGED_TUNNEL_ROUTE_IMPORT = 'SentryManagedTunnelRouteImport'; +const MANAGED_TUNNEL_ROUTE_NAME = 'SentryManagedTunnelRoute'; +const MANAGED_TUNNEL_ROUTE_PATH_ENV_KEY = '__SENTRY_INTERNAL_TANSTACKSTART_TUNNEL_ROUTE__'; -const VIRTUAL_TUNNEL_ROUTE_ID = - "virtual:sentry-tanstackstart-react/tunnel-route"; +const VIRTUAL_TUNNEL_ROUTE_ID = 'virtual:sentry-tanstackstart-react/tunnel-route'; const RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID = `\0${VIRTUAL_TUNNEL_ROUTE_ID}`; function generateRandomTunnelRoute(): string { - const randomPath = Array.from({ length: 8 }, () => - Math.floor(Math.random() * 36).toString(36), - ).join(""); + const randomPath = Array.from({ length: 8 }, () => Math.floor(Math.random() * 36).toString(36)).join(''); return `/${randomPath}`; } export function resolveTunnelRoute(tunnel: true | string): string { - if (typeof tunnel === "string") { + if (typeof tunnel === 'string') { return tunnel; } @@ -56,21 +52,19 @@ type NormalizedTunnelRouteOptions = { }; function validateStaticPath(path: string): void { - if (!path.startsWith("/") || path.includes("?") || path.includes("#")) { + if (!path.startsWith('/') || path.includes('?') || path.includes('#')) { throw new Error( - "[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.", + '[@sentry/tanstackstart-react] `tunnelRoute` static paths must start with `/` and must not contain query or hash segments.', ); } } -function normalizeTunnelRouteOptions( - options: TunnelRouteOptions, -): NormalizedTunnelRouteOptions { +function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTunnelRouteOptions { if (options === true) { return { resolvedPath: resolveTunnelRoute(true), allowedDsns: undefined }; } - if (typeof options === "string") { + if (typeof options === 'string') { validateStaticPath(options); return { resolvedPath: resolveTunnelRoute(options), @@ -78,10 +72,7 @@ function normalizeTunnelRouteOptions( }; } - const allowedDsns = - options.allowedDsns && options.allowedDsns.length > 0 - ? options.allowedDsns - : undefined; + const allowedDsns = options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; const path = options.path; if (path) { @@ -91,10 +82,7 @@ function normalizeTunnelRouteOptions( return { resolvedPath: resolveTunnelRoute(path ?? true), allowedDsns }; } -function hasRouteConflict( - source: string, - resolvedTunnelRoute: string, -): boolean { +function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { return ( source.includes(`fullPath: '${resolvedTunnelRoute}'`) || source.includes(`path: '${resolvedTunnelRoute}'`) || @@ -108,7 +96,7 @@ function injectAfterLastImport(source: string, statement: string): string { if (lastImport?.index === undefined) { throw new Error( - "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.", + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because `routeTree.gen.ts` imports could not be located.', ); } @@ -116,10 +104,7 @@ function injectAfterLastImport(source: string, statement: string): string { return `${source.slice(0, insertIndex)}\n${statement}${source.slice(insertIndex)}`; } -export function injectManagedTunnelRoute( - source: string, - resolvedTunnelRoute: string, -): string { +export function injectManagedTunnelRoute(source: string, resolvedTunnelRoute: string): string { if (source.includes(VIRTUAL_TUNNEL_ROUTE_ID)) { return source; } @@ -143,7 +128,7 @@ export function injectManagedTunnelRoute( if (rootRouteChildrenMatch?.index === undefined) { throw new Error( - "[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.", + '[@sentry/tanstackstart-react] Failed to inject the managed tunnel route because the generated TanStack route tree did not contain `rootRouteChildren`.', ); } @@ -162,27 +147,20 @@ ${rootRouteChildrenMatch[0]} return transformedSource; } -export function makeTunnelRoutePlugin( - options: TunnelRouteOptions, - debug?: boolean, -): Plugin { +export function makeTunnelRoutePlugin(options: TunnelRouteOptions, debug?: boolean): Plugin { const normalized = normalizeTunnelRouteOptions(options); const resolvedTunnelRoute = normalized.resolvedPath; const serializedTunnelRoute = JSON.stringify(resolvedTunnelRoute); - const serializedAllowedDsns = normalized.allowedDsns - ? JSON.stringify(normalized.allowedDsns) - : undefined; + const serializedAllowedDsns = normalized.allowedDsns ? JSON.stringify(normalized.allowedDsns) : undefined; if (debug) { // eslint-disable-next-line no-console - console.log( - `[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`, - ); + console.log(`[@sentry/tanstackstart-react] Registered tunnel route: ${resolvedTunnelRoute}`); } return { - name: "sentry-tanstackstart-tunnel-route", - enforce: "pre", + name: 'sentry-tanstackstart-tunnel-route', + enforce: 'pre', config() { return { define: { @@ -191,9 +169,7 @@ export function makeTunnelRoutePlugin( }; }, resolveId(source) { - return source === VIRTUAL_TUNNEL_ROUTE_ID - ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID - : null; + return source === VIRTUAL_TUNNEL_ROUTE_ID ? RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID : null; }, load(id) { if (id !== RESOLVED_VIRTUAL_TUNNEL_ROUTE_ID) { @@ -215,10 +191,7 @@ export const Route = createFileRoute(${serializedTunnelRoute})({ `; }, transform(source, id) { - if ( - !id.endsWith("/routeTree.gen.ts") && - !id.endsWith("\\routeTree.gen.ts") - ) { + if (!id.endsWith('/routeTree.gen.ts') && !id.endsWith('\\routeTree.gen.ts')) { return null; } diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 004251c63b66..da9614372cbf 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -160,7 +160,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockTunnelRoutePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockTunnelRoutePlugin, + mockMiddlewarePlugin, + ]); }); it('passes tunnelRoute options through to the tunnel route plugin', () => { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 7923fc3a6454..95aa41913014 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -113,8 +113,7 @@ describe('tunnelRoute vite plugin', () => { '\0virtual:sentry-tanstackstart-react/tunnel-route', ); - const virtualRouteModule = - plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); expect(virtualRouteModule).toContain('allowedDsns: ["http://public@localhost:3031/1337"]'); @@ -123,8 +122,7 @@ describe('tunnelRoute vite plugin', () => { it('omits allowedDsns from the virtual managed tunnel route module when not provided', async () => { const plugin = makeTunnelRoutePlugin('/monitor'); - const virtualRouteModule = - plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); + const virtualRouteModule = plugin.load && (await plugin.load('\0virtual:sentry-tanstackstart-react/tunnel-route')); expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); From c993616d1940ec899bc8bdf5e9545bcd117efe09 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 21 Apr 2026 11:23:08 -0400 Subject: [PATCH 11/77] fix(tanstackstart-react): treat empty tunnel path as omitted Made-with: Cursor --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 2 +- .../tanstackstart-react/test/vite/tunnelRoute.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index e78146c8889a..32e99849e07d 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -79,7 +79,7 @@ function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTun validateStaticPath(path); } - return { resolvedPath: resolveTunnelRoute(path ?? true), allowedDsns }; + return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; } function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index 95aa41913014..e0193d05ea26 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -127,4 +127,13 @@ describe('tunnelRoute vite plugin', () => { expect(virtualRouteModule).toContain('createFileRoute("/monitor")'); expect(virtualRouteModule).toContain('createSentryTunnelRoute({})'); }); + + it('treats an empty string `path` like omitted and uses a generated tunnel route', () => { + const plugin = makeTunnelRoutePlugin({ path: '' }); + + const defined = plugin.config && plugin.config(); + const serialized = defined?.define?.__SENTRY_TANSTACKSTART_TUNNEL_ROUTE__; + expect(typeof serialized).toBe('string'); + expect(serialized).toMatch(/^"\/[a-z0-9]{8}"$/); + }); }); From 52c33cbff25be17d8f794d12002927780b3c69ab Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 21 Apr 2026 11:39:28 -0400 Subject: [PATCH 12/77] fix(tanstackstart-react): use path in tunnelRoute tests and type options Align sentryTanstackStart tests with TunnelRouteOptions.path and annotate spy case with SentryTanstackStartOptions so options match the public API. Made-with: Cursor --- .../test/vite/sentryTanstackStart.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index da9614372cbf..eb12780def57 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -1,7 +1,10 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; -import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { + sentryTanstackStart, + type SentryTanstackStartOptions, +} from '../../src/vite/sentryTanstackStart'; import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { @@ -155,7 +158,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'], - tunnel: '/monitor', + path: '/monitor', }, sourcemaps: { disable: true }, }); @@ -169,14 +172,17 @@ describe('sentryTanstackStart()', () => { }); it('passes tunnelRoute options through to the tunnel route plugin', () => { - const tunnelRoute = { - allowedDsns: ['https://public@o0.ingest.sentry.io/0'], - tunnel: '/monitor' as const, + const options: SentryTanstackStartOptions = { + tunnelRoute: { + allowedDsns: ['https://public@o0.ingest.sentry.io/0'], + path: '/monitor' as const, + }, + sourcemaps: { disable: true }, }; - sentryTanstackStart({ tunnelRoute, sourcemaps: { disable: true } }); + sentryTanstackStart(options); - expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(tunnelRoute, undefined); + expect(makeTunnelRoutePlugin).toHaveBeenCalledWith(options.tunnelRoute, undefined); }); }); }); From 29a5d89996ddf1fb40f720bccb2021744e176df9 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 21 Apr 2026 12:26:08 -0400 Subject: [PATCH 13/77] style(tanstackstart-react): format sentryTanstackStart test imports Made-with: Cursor --- .../test/vite/sentryTanstackStart.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index eb12780def57..516edadd0bb0 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -1,10 +1,7 @@ import type { Plugin } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; -import { - sentryTanstackStart, - type SentryTanstackStartOptions, -} from '../../src/vite/sentryTanstackStart'; +import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; import { makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; const mockSourceMapsConfigPlugin: Plugin = { From c356b377a67713348e5688d5d9f5b87cce609e00 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 21 Apr 2026 13:08:41 -0400 Subject: [PATCH 14/77] fix(tanstackstart-react): detect tunnel route conflicts under both tsr quote styles Made-with: Cursor --- packages/tanstackstart-react/src/vite/tunnelRoute.ts | 11 ++++++----- .../tanstackstart-react/test/vite/tunnelRoute.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/tunnelRoute.ts b/packages/tanstackstart-react/src/vite/tunnelRoute.ts index 32e99849e07d..6a9400b32c95 100644 --- a/packages/tanstackstart-react/src/vite/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/vite/tunnelRoute.ts @@ -82,12 +82,13 @@ function normalizeTunnelRouteOptions(options: TunnelRouteOptions): NormalizedTun return { resolvedPath: resolveTunnelRoute(path || true), allowedDsns }; } +// `routeTree.gen.ts` quote style follows `tsr.config.json#quoteStyle` (`single` | `double`), +// so we check both forms for each route-identifying key. +const ROUTE_CONFLICT_KEYS = ['fullPath', 'path', 'id'] as const; + function hasRouteConflict(source: string, resolvedTunnelRoute: string): boolean { - return ( - source.includes(`fullPath: '${resolvedTunnelRoute}'`) || - source.includes(`path: '${resolvedTunnelRoute}'`) || - source.includes(`id: '${resolvedTunnelRoute}'`) - ); + const literals = [`'${resolvedTunnelRoute}'`, `"${resolvedTunnelRoute}"`]; + return ROUTE_CONFLICT_KEYS.some(key => literals.some(literal => source.includes(`${key}: ${literal}`))); } function injectAfterLastImport(source: string, statement: string): string { diff --git a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts index e0193d05ea26..822a01aeeeff 100644 --- a/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/vite/tunnelRoute.test.ts @@ -97,6 +97,17 @@ describe('tunnelRoute vite plugin', () => { ); }); + it('fails on route conflict when routeTree.gen.ts uses double quotes (tsr quoteStyle: double)', () => { + const doubleQuotedMonitorTree = ROUTE_TREE_SOURCE.replace("path: '/'", 'path: "/monitor"').replace( + "id: '/'", + 'id: "/monitor"', + ); + + expect(() => injectManagedTunnelRoute(doubleQuotedMonitorTree, '/monitor')).toThrow( + 'Cannot register managed tunnel route "/monitor" because an existing TanStack Start route already uses that path.', + ); + }); + it('loads a virtual managed tunnel route module for a static tunnel path', async () => { const plugin = makeTunnelRoutePlugin({ allowedDsns: ['http://public@localhost:3031/1337'], From a2a1befc59371c315047b885852c02702c9a1c09 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 23 Apr 2026 17:09:45 +0200 Subject: [PATCH 15/77] chore(test): Reduce unneeded `idleTimeout` test config (#20467) After looking a bit at what the slowest browser-integration-tests are, I found an easy-to-fix pattern in our tests - we had _a lot_ of tests there that set some arbitrary `idleTimeout` to the `browserIntegrationTest`. This massively made these tests slower as it takes much longer to flush any pageload span. Many test runtimes reduced from 10-20s to 1-5s due to this change. A few places remain where this seems to be necessary, but there are way fewer now. FYI 1000ms is the default, we also had some defining this for some reason, I also removed those to be more consistent. --- .../public-api/diagnoseSdkConnectivity/init.js | 7 ++++++- .../backgroundtab-custom/init.js | 1 - .../http-timings-streamed/init.js | 1 - .../browserTracingIntegration/http-timings/init.js | 1 - .../ignoreMeasureSpans/init.js | 1 - .../interactions-streamed/init.js | 1 - .../browserTracingIntegration/interactions/init.js | 1 - .../init.js | 1 - .../long-animation-frame-before-navigation/init.js | 1 - .../long-animation-frame-disabled-streamed/init.js | 5 ++++- .../long-animation-frame-disabled/init.js | 5 ++++- .../long-animation-frame-enabled-streamed/init.js | 1 - .../long-animation-frame-enabled/init.js | 1 - .../long-animation-frame-non-chromium/init.js | 5 ++++- .../init.js | 1 - .../long-tasks-and-animation-frame-enabled/init.js | 1 - .../long-tasks-before-navigation-streamed/init.js | 1 - .../long-tasks-before-navigation/init.js | 1 - .../long-tasks-disabled-streamed/init.js | 5 ++++- .../long-tasks-disabled/init.js | 5 ++++- .../long-tasks-enabled-streamed/init.js | 1 - .../long-tasks-enabled/init.js | 1 - .../long-tasks-no-animation-frame/init.js | 1 - .../navigation-aborting-pageload/init.js | 6 +++++- .../on-request-span-end/init.js | 1 - .../on-request-span-start/init.js | 1 - .../resource-spans-ignored/init.js | 1 - .../suites/tracing/metrics/connection-rtt/test.ts | 4 ++-- .../suites/tracing/metrics/init.js | 6 +----- .../init.js | 6 +----- .../tracing/metrics/pageload-measure-spans/init.js | 6 +----- .../metrics/web-vitals-cls-standalone-spans/init.js | 3 +-- .../suites/tracing/metrics/web-vitals-cls/init.js | 13 +++++++++++++ .../tracing/metrics/web-vitals-inp-late/init.js | 1 - .../web-vitals-inp-parametrized-late/init.js | 1 - .../metrics/web-vitals-inp-streamed-spans/init.js | 7 ++++++- .../metrics/web-vitals-lcp-standalone-spans/init.js | 2 +- 37 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js diff --git a/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js b/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js index 8c0a0cd9fca4..9bb07ba0cc11 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/diagnoseSdkConnectivity/init.js @@ -4,6 +4,11 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 3000, childSpanTimeout: 3000 })], + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 3000, + childSpanTimeout: 3000, + }), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js index 635210f2252c..9a11fe3682bb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/backgroundtab-custom/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, instrumentPageLoad: false, instrumentNavigation: false, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js index 7eff1a54e9ff..8ec045483e0c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, _experiments: { enableHTTPTimings: true, }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js index e32d09a13fab..575e3fa65693 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, _experiments: { enableHTTPTimings: true, }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js index 409d1e4e7906..cd7948aadbc1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/ignoreMeasureSpans/init.js @@ -7,7 +7,6 @@ Sentry.init({ integrations: [ Sentry.browserTracingIntegration({ ignorePerformanceApiSpans: ['measure-ignore', /mark-i/], - idleTimeout: 9000, }), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js index 385e9ed6b6cf..768ed5defbe8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, _experiments: { enableInteractions: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js index 846538e7f3f0..6a7a77dff8b7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, _experiments: { enableInteractions: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js index ee197adaa33c..d3c861a8ae26 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: false, enableLongAnimationFrame: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js index f00d680435bb..019eece018f6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-before-navigation/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: false, enableLongAnimationFrame: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js index 965613d5464e..3c004b952f80 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled-streamed/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), Sentry.spanStreamingIntegration(), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js index e1b3f6b13b01..0339c3fa3dbf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-disabled/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js index 1f6cc0a8f463..2be5f32985d7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: false, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js index 4be408ceab7e..f16f7af33b5c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: false, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js index ca1bf10dcddd..382115cbc8b9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-animation-frame-non-chromium/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: true, enableLongAnimationFrame: true, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: true, + enableLongAnimationFrame: true, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js index 3e3eedaf49b7..b0dc0a8d7ff5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongTask: true, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js index d81b8932803c..382115cbc8b9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-and-animation-frame-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: true, enableLongAnimationFrame: true, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js index f6e5ce777e06..9f36511d8ff5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongAnimationFrame: false, instrumentPageLoad: false, instrumentNavigation: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js index 5986089e5aa4..ccf669361e37 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-before-navigation/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongAnimationFrame: false, instrumentPageLoad: false, instrumentNavigation: true, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js index 965613d5464e..3c004b952f80 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled-streamed/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 2000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), Sentry.spanStreamingIntegration(), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js index e1b3f6b13b01..0339c3fa3dbf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-disabled/init.js @@ -5,7 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ - Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 9000 }), + Sentry.browserTracingIntegration({ + enableLongTask: false, + enableLongAnimationFrame: false, + }), ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js index 484350c14fcf..977ff9c6425e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled-streamed/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 2000, enableLongAnimationFrame: false, }), Sentry.spanStreamingIntegration(), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js index 319dfaadd4a8..47e9fd0b92d8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-enabled/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongAnimationFrame: false, }), ], diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js index 0e35db50764f..723707736f2e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/long-tasks-no-animation-frame/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, enableLongTask: true, enableLongAnimationFrame: false, }), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js index 06caf2c2c239..fcce12c3dca2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js @@ -4,7 +4,11 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 2000, detectRedirects: false })], + integrations: [ + Sentry.browserTracingIntegration({ + detectRedirects: false, + }), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js index 9627bfc003e7..207473572df5 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, onRequestSpanEnd(span, { headers }) { if (headers) { span.setAttribute('hook.called.response-type', headers.get('x-response-type')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js index 2c85bd05b765..680dfd7304df 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, onRequestSpanStart(span, { headers }) { if (headers) { span.setAttribute('hook.called.headers', headers.get('foo')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js index 70c0b30a03a5..3a4c2cb1cdf4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/resource-spans-ignored/init.js @@ -7,7 +7,6 @@ Sentry.init({ integrations: [ Sentry.browserTracingIntegration({ ignoreResourceSpans: ['resource.script'], - idleTimeout: 9000, }), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts index 1cb951621e21..6f4b885e71d1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/connection-rtt/test.ts @@ -15,14 +15,14 @@ async function createSessionWithLatency(page: Page, latency: number) { await session.send('Network.emulateNetworkConditions', { offline: false, latency: latency, - downloadThroughput: (25 * 1024) / 8, + downloadThroughput: (100 * 1024) / 8, uploadThroughput: (5 * 1024) / 8, }); return session; } -sentryTest('should capture a `connection.rtt` metric.', async ({ getLocalTestUrl, page }) => { +sentryTest('should capture a `connection.rtt` metric. xxx', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js index ad1d8832b228..83076460599f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/init.js @@ -4,10 +4,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js index f4df5dbe13e8..77a2ced629d8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js @@ -23,11 +23,7 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration({})], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js index f3e6fa567911..179f899527e8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js @@ -11,10 +11,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [ - Sentry.browserTracingIntegration({ - idleTimeout: 9000, - }), - ], + integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js index 32fbb07fbbae..dce8cd2508fd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js @@ -6,12 +6,11 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, + idleTimeout: 5000, _experiments: { enableStandaloneClsSpans: true, }, }), ], tracesSampleRate: 1, - debug: true, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js new file mode 100644 index 000000000000..361367d4b8a4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls/init.js @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 5000, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js index 1044a4b68bda..9a26371a9461 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, enableInp: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js index 895e6f60ff42..9451c499ae82 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/init.js @@ -6,7 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 1000, enableLongTask: false, enableInp: true, instrumentPageLoad: false, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js index 469f44076e73..8be8d8420f85 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/init.js @@ -5,6 +5,11 @@ window._testBaseTimestamp = performance.timeOrigin / 1000; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ idleTimeout: 4000 }), Sentry.spanStreamingIntegration()], + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 5000, + }), + Sentry.spanStreamingIntegration(), + ], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js index 8da426e106b8..d09eeab5f565 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js @@ -6,7 +6,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ - idleTimeout: 9000, + idleTimeout: 5000, _experiments: { enableStandaloneLcpSpans: true, }, From 9e04b81949e98ce18dc9835602db2fc184134aaf Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 23 Apr 2026 12:09:06 -0400 Subject: [PATCH 16/77] refactor(tanstackstart-react): simplify allowedDsns options check Use optional chaining on `.length` to collapse the redundant existence + length-guard ternary into a single expression. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/tanstackstart-react/src/server/tunnelRoute.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/server/tunnelRoute.ts b/packages/tanstackstart-react/src/server/tunnelRoute.ts index d2dae0b1ddac..815a8d635c9f 100644 --- a/packages/tanstackstart-react/src/server/tunnelRoute.ts +++ b/packages/tanstackstart-react/src/server/tunnelRoute.ts @@ -33,8 +33,7 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions) return { handlers: { POST: async ({ request }) => { - const allowedDsnsFromOptions = - options.allowedDsns && options.allowedDsns.length > 0 ? options.allowedDsns : undefined; + const allowedDsnsFromOptions = options.allowedDsns?.length ? options.allowedDsns : undefined; const allowedDsns = allowedDsnsFromOptions ?? From d6e3e41f5d0eb3aa51462f56108f69afe8312ac3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 12:17:18 -0400 Subject: [PATCH 17/77] fix: update prisma v7 spans descriptions (#20456) Applies the same fix as in #19924 closes #18797 Co-authored-by: Julien Blatecky Co-authored-by: Claude Opus 4.7 (1M context) --- .../suites/tracing/prisma-orm-v7/test.ts | 13 +++++++++---- packages/node/src/integrations/tracing/prisma.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts index f9fb22606772..e48feac3c793 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts @@ -37,7 +37,6 @@ conditionalTest({ min: 20 })('Prisma ORM v7 Tests', () => { expect(spanDescriptions).toContain('prisma:client:operation'); expect(spanDescriptions).toContain('prisma:client:serialize'); expect(spanDescriptions).toContain('prisma:client:connect'); - expect(spanDescriptions).toContain('prisma:client:db_query'); // Verify the create operation has correct metadata const createSpan = prismaSpans.find( @@ -48,11 +47,17 @@ conditionalTest({ min: 20 })('Prisma ORM v7 Tests', () => { ); expect(createSpan).toBeDefined(); - // Verify db_query span has system info and correct op (v7 uses db.system.name) - const dbQuerySpan = prismaSpans.find(span => span.description === 'prisma:client:db_query'); + // Verify db_query span has system info and correct op (v7 uses db.system.name). + // The SDK should rewrite the span name to the actual SQL text (same as v5/v6 + // `prisma:engine:db_query`), so we find it via op/origin rather than description. + const dbQuerySpan = prismaSpans.find( + span => span.data?.['sentry.op'] === 'db' && span.data?.['db.query.text'], + ); + expect(dbQuerySpan).toBeDefined(); expect(dbQuerySpan?.data?.['db.system.name']).toBe('postgresql'); - expect(dbQuerySpan?.data?.['sentry.op']).toBe('db'); expect(dbQuerySpan?.op).toBe('db'); + expect(dbQuerySpan?.description).toBe(dbQuerySpan?.data?.['db.query.text']); + expect(dbQuerySpan?.description).not.toBe('prisma:client:db_query'); }, }) .start() diff --git a/packages/node/src/integrations/tracing/prisma.ts b/packages/node/src/integrations/tracing/prisma.ts index b81adc9552a8..71ec0fd3b49e 100644 --- a/packages/node/src/integrations/tracing/prisma.ts +++ b/packages/node/src/integrations/tracing/prisma.ts @@ -224,8 +224,12 @@ export const prismaIntegration = defineIntegration((options?: PrismaOptions) => span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.prisma'); } - // Make sure we use the query text as the span name, for ex. SELECT * FROM "User" WHERE "id" = $1 - if (spanJSON.description === 'prisma:engine:db_query' && spanJSON.data['db.query.text']) { + // Make sure we use the query text as the span name, for ex. SELECT * FROM "User" WHERE "id" = $1. + // v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits `prisma:client:db_query`. + if ( + (spanJSON.description === 'prisma:engine:db_query' || spanJSON.description === 'prisma:client:db_query') && + spanJSON.data['db.query.text'] + ) { span.updateName(spanJSON.data['db.query.text'] as string); } From 8f32e18517df2a5f4161021e89b3a4dce2fe6189 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 23 Apr 2026 12:45:11 -0400 Subject: [PATCH 18/77] feat(nitro): Nitro SDK (#19224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR just isolates the mundane changes needed for a new SDK to keep the next stacked PRs clean, it adds the Nitro SDK to the monorepo. This PR is a base of a stack, the stacked PRs will be merged into it. I thought this will be easier to review. --- **This PR is part of a stack:** - https://github.com/getsentry/sentry-javascript/pull/20358 - https://github.com/getsentry/sentry-javascript/pull/19224 👈 - https://github.com/getsentry/sentry-javascript/pull/19225 - https://github.com/getsentry/sentry-javascript/pull/19304 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .craft.yml | 9 + .github/ISSUE_TEMPLATE/bug.yml | 1 + .github/workflows/canary.yml | 3 + .github/workflows/issue-package-label.yml | 3 + CHANGELOG.md | 5 + README.md | 1 + .../test-applications/nitro-3/index.html | 11 + .../test-applications/nitro-3/instrument.mjs | 8 + .../test-applications/nitro-3/package.json | 29 ++ .../nitro-3/playwright.config.mjs | 7 + .../nitro-3/server/api/index.ts | 5 + .../nitro-3/server/api/test-error.ts | 5 + .../nitro-3/server/api/test-isolation/[id].ts | 10 + .../nitro-3/server/api/test-nesting.ts | 16 + .../nitro-3/server/api/test-param/[id].ts | 6 + .../nitro-3/server/api/test-transaction.ts | 5 + .../nitro-3/server/middleware/test.ts | 10 + .../test-applications/nitro-3/src/main.ts | 10 + .../nitro-3/start-event-proxy.mjs | 6 + .../nitro-3/tests/errors.test.ts | 45 ++ .../nitro-3/tests/isolation.test.ts | 25 ++ .../nitro-3/tests/middleware.test.ts | 40 ++ .../nitro-3/tests/span-nesting.test.ts | 146 ++++++ .../nitro-3/tests/trace-propagation.test.ts | 16 + .../nitro-3/tests/transactions.test.ts | 78 ++++ .../test-applications/nitro-3/tsconfig.json | 14 + .../test-applications/nitro-3/vite.config.ts | 15 + .../e2e-tests/verdaccio-config/config.yaml | 294 ++++++++++++ package.json | 1 + packages/nitro/.oxlintrc.json | 7 + packages/nitro/LICENSE | 21 + packages/nitro/README.md | 129 ++++++ packages/nitro/package.json | 84 ++++ packages/nitro/rollup.npm.config.mjs | 13 + packages/nitro/src/common/debug-build.ts | 8 + packages/nitro/src/config.ts | 33 ++ packages/nitro/src/index.ts | 4 + .../nitro/src/instruments/instrumentServer.ts | 12 + packages/nitro/src/module.ts | 17 + packages/nitro/src/runtime/README.md | 5 + .../src/runtime/hooks/captureErrorHook.ts | 76 ++++ .../src/runtime/hooks/captureTracingEvents.ts | 280 ++++++++++++ .../runtime/hooks/setServerTimingHeaders.ts | 27 ++ packages/nitro/src/runtime/plugins/server.ts | 9 + packages/nitro/src/sdk.ts | 32 ++ packages/nitro/src/sourceMaps.ts | 192 ++++++++ packages/nitro/src/utils/plugin.ts | 9 + packages/nitro/src/utils/resolver.ts | 25 ++ packages/nitro/test/index.test.ts | 11 + .../runtime/hooks/captureErrorHook.test.ts | 168 +++++++ packages/nitro/test/sourceMaps.test.ts | 367 +++++++++++++++ packages/nitro/test/tsconfig.json | 3 + packages/nitro/tsconfig.json | 11 + packages/nitro/tsconfig.test.json | 10 + packages/nitro/tsconfig.types.json | 9 + packages/nitro/vite.config.ts | 11 + packages/opentelemetry/src/tracingChannel.ts | 12 +- yarn.lock | 421 ++++++++++-------- 58 files changed, 2636 insertions(+), 194 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/index.html create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts create mode 100644 dev-packages/e2e-tests/verdaccio-config/config.yaml create mode 100644 packages/nitro/.oxlintrc.json create mode 100644 packages/nitro/LICENSE create mode 100644 packages/nitro/README.md create mode 100644 packages/nitro/package.json create mode 100644 packages/nitro/rollup.npm.config.mjs create mode 100644 packages/nitro/src/common/debug-build.ts create mode 100644 packages/nitro/src/config.ts create mode 100644 packages/nitro/src/index.ts create mode 100644 packages/nitro/src/instruments/instrumentServer.ts create mode 100644 packages/nitro/src/module.ts create mode 100644 packages/nitro/src/runtime/README.md create mode 100644 packages/nitro/src/runtime/hooks/captureErrorHook.ts create mode 100644 packages/nitro/src/runtime/hooks/captureTracingEvents.ts create mode 100644 packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts create mode 100644 packages/nitro/src/runtime/plugins/server.ts create mode 100644 packages/nitro/src/sdk.ts create mode 100644 packages/nitro/src/sourceMaps.ts create mode 100644 packages/nitro/src/utils/plugin.ts create mode 100644 packages/nitro/src/utils/resolver.ts create mode 100644 packages/nitro/test/index.test.ts create mode 100644 packages/nitro/test/runtime/hooks/captureErrorHook.test.ts create mode 100644 packages/nitro/test/sourceMaps.test.ts create mode 100644 packages/nitro/test/tsconfig.json create mode 100644 packages/nitro/tsconfig.json create mode 100644 packages/nitro/tsconfig.test.json create mode 100644 packages/nitro/tsconfig.types.json create mode 100644 packages/nitro/vite.config.ts diff --git a/.craft.yml b/.craft.yml index 7e2ee3217533..eb42b5cc5de4 100644 --- a/.craft.yml +++ b/.craft.yml @@ -139,6 +139,9 @@ targets: - name: npm id: '@sentry/react-router' includeNames: /^sentry-react-router-\d.*\.tgz$/ + - name: npm + id: '@sentry/nitro' + includeNames: /^sentry-nitro-\d.*\.tgz$/ ## 7. Other Packages ## 7.1 @@ -256,3 +259,9 @@ targets: packageUrl: 'https://www.npmjs.com/package/@sentry/elysia' mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/elysia/' onlyIfPresent: /^sentry-elysia-\d.*\.tgz$/ + 'npm:@sentry/nitro': + name: 'Sentry Nitro SDK' + sdkName: 'sentry.javascript.nitro' + packageUrl: 'https://www.npmjs.com/package/@sentry/nitro' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/guides/nitro/' + onlyIfPresent: /^sentry-nitro-\d.*\.tgz$/ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 47edbfeed264..499244434f82 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -52,6 +52,7 @@ body: - '@sentry/google-cloud-serverless' - '@sentry/nestjs' - '@sentry/nextjs' + - '@sentry/nitro' - '@sentry/nuxt' - '@sentry/react' - '@sentry/react-router' diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index e28d6988d9a1..5b16ebe112e2 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -120,6 +120,9 @@ jobs: - test-application: 'nestjs-microservices' build-command: 'test:build-latest' label: 'nestjs-microservices (latest)' + - test-application: 'nitro-3' + build-command: 'test:build-canary' + label: 'nitro-3 (canary)' steps: - name: Check out current commit diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 323b17219b1a..fb77747f336f 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -65,6 +65,9 @@ jobs: "@sentry.nestjs": { "label": "Nest.js" }, + "@sentry.nitro": { + "label": "Nitro" + }, "@sentry.nextjs": { "label": "Next.js" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcace55cda1..84e8740b73cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **feat(nitro): Add `@sentry/nitro` SDK** + + A new `@sentry/nitro` package provides first-class Sentry support for [Nitro](https://nitro.build/) applications, with HTTP handler and error instrumentation, middleware tracing, request isolation, and build-time source map uploading via `withSentryConfig`. + Read more in the [Nitro SDK docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) and the [Nitro SDK readme](https://github.com/getsentry/sentry-javascript/blob/develop/packages/nitro/README.md). + ## 10.50.0 ### Important Changes diff --git a/README.md b/README.md index 841a6380b5e2..71ae65bbe406 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ package. Please refer to the README and instructions of those SDKs for more deta - [`@sentry/gatsby`](https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby): SDK for Gatsby - [`@sentry/nestjs`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs): SDK for NestJS - [`@sentry/nextjs`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs): SDK for Next.js +- [`@sentry/nitro`](https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro): SDK for Nitro - [`@sentry/remix`](https://github.com/getsentry/sentry-javascript/tree/master/packages/remix): SDK for Remix - [`@sentry/tanstackstart-react`](https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react): SDK for TanStack Start React - [`@sentry/aws-serverless`](https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless): SDK diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/index.html b/dev-packages/e2e-tests/test-applications/nitro-3/index.html new file mode 100644 index 000000000000..4e9315ac391e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/index.html @@ -0,0 +1,11 @@ + + + + + Nitro E2E Test + + +

Nitro E2E Test App

+ + + diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs new file mode 100644 index 000000000000..53b80d309a5b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nitro'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json new file mode 100644 index 000000000000..ab92769115d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -0,0 +1,29 @@ +{ + "name": "nitro-3", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' node .output/server/index.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/browser": "latest || *", + "@sentry/nitro": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *", + "nitro": "^3.0.260415-beta", + "rolldown": "latest", + "vite": "latest" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts new file mode 100644 index 000000000000..a9fca21eecfb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { status: 'ok' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts new file mode 100644 index 000000000000..170efb1977ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + throw new Error('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts new file mode 100644 index 000000000000..a8c2cd7a99f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts @@ -0,0 +1,10 @@ +import { getDefaultIsolationScope, setTag } from '@sentry/core'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + setTag('my-isolated-tag', true); + // Check if the tag leaked into the default (global) isolation scope + setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); + + throw new Error('Isolation test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts new file mode 100644 index 000000000000..687c6f3f1e9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts @@ -0,0 +1,16 @@ +import { startSpan } from '@sentry/nitro'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + startSpan({ name: 'db.select', op: 'db' }, () => { + // simulate a select query + }); + + startSpan({ name: 'db.insert', op: 'db' }, () => { + startSpan({ name: 'db.serialize', op: 'serialize' }, () => { + // simulate serializing data before insert + }); + }); + + return { status: 'ok', nesting: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts new file mode 100644 index 000000000000..ef67525b36ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(event => { + const id = event.req.url; + return { id }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts new file mode 100644 index 000000000000..b488b371310d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { status: 'ok', transaction: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts new file mode 100644 index 000000000000..92d8f80c3756 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -0,0 +1,10 @@ +import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; + +export default defineHandler(event => { + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); + + const query = getQuery(event); + if (query['middleware-error'] === '1') { + throw new Error('Middleware error'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts new file mode 100644 index 000000000000..d27d0ba1763a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +// Let's us test trace propagation +Sentry.init({ + environment: 'qa', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: 'http://localhost:3031/', // proxy server + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs new file mode 100644 index 000000000000..928e68908661 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nitro-3', +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts new file mode 100644 index 000000000000..8e419ac9ba62 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends an error event to Sentry', async ({ request }) => { + const errorEventPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error'); + }); + + await request.get('/api/test-error'); + + const errorEvent = await errorEventPromise; + + // Nitro wraps thrown errors in an HTTPError with .cause, producing a chained exception + expect(errorEvent.exception?.values).toHaveLength(2); + + // The innermost exception (values[0]) is the original thrown error + expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.function.nitro.captureErrorHook', + }), + ); + + // The outermost exception (values[1]) is the HTTPError wrapper + expect(errorEvent.exception?.values?.[1]?.type).toBe('HTTPError'); + expect(errorEvent.exception?.values?.[1]?.value).toBe('This is a test error'); +}); + +test('Does not send 404 errors to Sentry', async ({ request }) => { + let errorReceived = false; + + void waitForError('nitro-3', event => { + if (!event.type) { + errorReceived = true; + return true; + } + return false; + }); + + await request.get('/api/non-existent-route'); + + expect(errorReceived).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts new file mode 100644 index 000000000000..7234fa0948ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Isolation scope prevents tag leaking between requests', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-isolation/:id'; + }); + + const errorPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error'); + }); + + await request.get('/api/test-isolation/1').catch(() => { + // noop - route throws + }); + + const transactionEvent = await transactionEventPromise; + const error = await errorPromise; + + // Assert that isolation scope works properly + expect(error.tags?.['my-isolated-tag']).toBe(true); + expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); + expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts new file mode 100644 index 000000000000..eec281d28f98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Creates middleware spans for requests', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-transaction'; + }); + + const response = await request.get('/api/test-transaction'); + + expect(response.headers()['x-sentry-test-middleware']).toBe('executed'); + + const transactionEvent = await transactionEventPromise; + + // h3 middleware spans have origin auto.http.nitro.h3 and op middleware.nitro + const h3MiddlewareSpans = transactionEvent.spans?.filter( + span => span.origin === 'auto.http.nitro.h3' && span.op === 'middleware.nitro', + ); + expect(h3MiddlewareSpans?.length).toBeGreaterThanOrEqual(1); +}); + +test('Captures errors thrown in middleware with error status on span', async ({ request }) => { + const errorEventPromise = waitForError('nitro-3', event => { + return !event.type && !!event.exception?.values?.some(v => v.value === 'Middleware error'); + }); + + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-transaction' && event?.contexts?.trace?.status === 'internal_error'; + }); + + await request.get('/api/test-transaction?middleware-error=1'); + + const errorEvent = await errorEventPromise; + expect(errorEvent.exception?.values?.some(v => v.value === 'Middleware error')).toBe(true); + + const transactionEvent = await transactionEventPromise; + + // The transaction span should have error status + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts new file mode 100644 index 000000000000..090f8af36fb2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Span nesting: all spans share the same trace_id', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + const traceId = event.contexts?.trace?.trace_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + + // Every child span must belong to the same trace + for (const span of event.spans ?? []) { + expect(span.trace_id).toBe(traceId); + } +}); + +test('Span nesting: h3 middleware spans are children of the srvx request span', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + + // All h3 middleware spans should be children of the srvx span + const h3Spans = event.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); + expect(h3Spans?.length).toBeGreaterThanOrEqual(1); + + for (const span of h3Spans ?? []) { + expect(span.parent_span_id).toBe(srvxSpan!.span_id); + } +}); + +test('Span nesting: manual startSpan calls inside route handler are children of the srvx request span', async ({ + request, +}) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span — this is the parent of all h3 and manual spans + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + const srvxSpanId = srvxSpan!.span_id; + + // Find the manually created db spans + const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); + const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); + expect(dbSelectSpan).toBeDefined(); + expect(dbInsertSpan).toBeDefined(); + + // FIXME: Once nitro's h3 tracing plugin emits a separate span for route handlers (type: "route"), + // the db spans should be children of the h3 route handler span, not the srvx span directly. + // Currently nitro bypasses h3's ~routes for file-based routing, so h3 only emits middleware spans. + // Both db spans should be children of the srvx request span + expect(dbSelectSpan!.parent_span_id).toBe(srvxSpanId); + expect(dbInsertSpan!.parent_span_id).toBe(srvxSpanId); + + // Both db spans should be siblings (same parent) + expect(dbSelectSpan!.parent_span_id).toBe(dbInsertSpan!.parent_span_id); + + // The serialize span should be nested inside the db.insert span + const serializeSpan = event.spans?.find(span => span.op === 'serialize' && span.description === 'db.serialize'); + expect(serializeSpan).toBeDefined(); + expect(serializeSpan!.parent_span_id).toBe(dbInsertSpan!.span_id); +}); + +// FIXME: Nitro's file-based routing bypasses h3's ~routes, so h3's tracing plugin never wraps +// route handlers with type: "route". Once this is fixed upstream or we add our own wrapping, +// uncomment these tests to verify the h3 route handler span exists and is the parent of manual spans. +// +// test('Span nesting: h3 route handler span is a child of the srvx request span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); +// expect(srvxSpan).toBeDefined(); +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// expect(h3HandlerSpan!.parent_span_id).toBe(srvxSpan!.span_id); +// }); +// +// test('Span nesting: manual startSpan calls are children of the h3 route handler span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// +// const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); +// const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); +// expect(dbSelectSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// expect(dbInsertSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// }); + +test('Span nesting: middleware spans start before manual spans in the span tree', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Middleware spans should start before the manual db spans + const middlewareSpans = event.spans?.filter(span => span.op === 'middleware.nitro') ?? []; + const dbSpans = event.spans?.filter(span => span.op === 'db') ?? []; + + expect(middlewareSpans.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.length).toBeGreaterThanOrEqual(1); + + const earliestMiddlewareStart = Math.min(...middlewareSpans.map(s => s.start_timestamp)); + const earliestDbStart = Math.min(...dbSpans.map(s => s.start_timestamp)); + + // Middleware should start before the db spans + expect(earliestMiddlewareStart).toBeLessThanOrEqual(earliestDbStart); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts new file mode 100644 index 000000000000..705521ad759d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Propagates server trace to client pageload via Server-Timing headers', async ({ page }) => { + const clientTxnPromise = waitForTransaction('nitro-3', event => { + return event?.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const clientTxn = await clientTxnPromise; + + expect(clientTxn.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxn.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(clientTxn.contexts?.trace?.op).toBe('pageload'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts new file mode 100644 index 000000000000..48de9c4349df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction event for a successful route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-transaction'; + }); + + await request.get('/api/test-transaction'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /api/test-transaction', + type: 'transaction', + }), + ); + + // srvx.request creates a span for the request + const srvxSpans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.srvx'); + expect(srvxSpans?.length).toBeGreaterThanOrEqual(1); + + // h3 creates a child span for the route handler + const h3Spans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); + expect(h3Spans?.length).toBeGreaterThanOrEqual(1); +}); + +test('Sets correct HTTP status code on transaction', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-transaction'; + }); + + await request.get('/api/test-transaction'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.response.status_code': 200, + }), + ); + + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Uses parameterized route for transaction name', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test-param/:id'; + }); + + await request.get('/api/test-param/123'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /api/test-param/:id', + transaction_info: expect.objectContaining({ source: 'route' }), + type: 'transaction', + }), + ); + + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.route': '/api/test-param/:id', + }), + ); +}); + +test('Sets Server-Timing response headers for trace propagation', async ({ request }) => { + const response = await request.get('/api/test-transaction'); + const headers = response.headers(); + + expect(headers['server-timing']).toBeDefined(); + expect(headers['server-timing']).toContain('sentry-trace;desc="'); + expect(headers['server-timing']).toContain('baggage;desc="'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json new file mode 100644 index 000000000000..b9a951fbebb1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "~/*": ["./*"] + } + }, + "include": ["src/**/*.ts", "routes/**/*.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts new file mode 100644 index 000000000000..d488f8298777 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts @@ -0,0 +1,15 @@ +import { withSentryConfig } from '@sentry/nitro'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + nitro( + // FIXME: Nitro plugin has a type issue + // @ts-expect-error + withSentryConfig({ + serverDir: './server', + }), + ), + ], +}); diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml new file mode 100644 index 000000000000..8878490df729 --- /dev/null +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -0,0 +1,294 @@ +# Taken from https://github.com/babel/babel/blob/624c78d99e8f42b2543b8943ab1b62bd71cf12d8/scripts/integration-tests/verdaccio-config.yml + +# +# This is the default config file. It allows all users to do anything, +# so don't use it on production systems. +# +# Look here for more config file examples: +# https://github.com/verdaccio/verdaccio/tree/master/conf +# + +# Repo-local storage (relative to this file). Absolute /verdaccio/... matches Docker-only templates and is not writable on typical dev machines. +storage: ./storage/data + +# https://verdaccio.org/docs/configuration#authentication +auth: + htpasswd: + file: ./storage/htpasswd + +# https://verdaccio.org/docs/configuration#uplinks +# a list of other known repositories we can talk to +uplinks: + npmjs: + url: https://registry.npmjs.org/ + +# Learn how to protect your packages +# https://verdaccio.org/docs/protect-your-dependencies/ +# https://verdaccio.org/docs/configuration#packages +packages: + # To not use a proxy (e.g. npm) but instead use verdaccio for package hosting we need to define rules here without the + # `proxy` field. Sadly we can't use a wildcard like "@sentry/*" because we have some dependencies (@sentry/cli, + # @sentry/webpack-plugin) that fall under that wildcard but don't live in this repository. If we were to use that + # wildcard, we would get a 404 when attempting to install them, since they weren't uploaded to verdaccio, and also + # don't have a proxy configuration. + + '@sentry/angular': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/astro': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/browser': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/bun': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/core': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/cloudflare': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/deno': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/effect': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/elysia': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/ember': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/gatsby': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/hono': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nestjs': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nextjs': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node-core': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/node-native': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/opentelemetry': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/profiling-node': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/react': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/react-router': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/remix': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/aws-serverless': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/google-cloud-serverless': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/solid': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/solidstart': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/svelte': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/sveltekit': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/tanstackstart': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/tanstackstart-react': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/types': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/vercel-edge': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/vue': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nuxt': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/wasm': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry/nitro': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@sentry-internal/*': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + + '@*/*': + # scoped packages + access: $all + publish: $all + unpublish: $all + proxy: npmjs + + '**': + # allow all users (including non-authenticated users) to read and + # publish all packages + # + # you can specify usernames/groupnames (depending on your auth plugin) + # and three keywords: "$all", "$anonymous", "$authenticated" + access: $all + + # allow all known users to publish/publish packages + # (anyone can register by default, remember?) + publish: $all + unpublish: $all + proxy: npmjs + +# https://verdaccio.org/docs/configuration#server +# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections. +# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout. +# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. +server: + keepAliveTimeout: 60 + +middlewares: + audit: + enabled: false + +# https://verdaccio.org/docs/logger +# log settings +log: { type: stdout, format: pretty, level: http } +#experiments: +# # support for npm token command +# token: false diff --git a/package.json b/package.json index f455f42e3bbf..b7479a41a2f2 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "packages/integration-shims", "packages/nestjs", "packages/nextjs", + "packages/nitro", "packages/node", "packages/node-core", "packages/node-native", diff --git a/packages/nitro/.oxlintrc.json b/packages/nitro/.oxlintrc.json new file mode 100644 index 000000000000..f079a7bc588c --- /dev/null +++ b/packages/nitro/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], + "env": { + "node": true + } +} diff --git a/packages/nitro/LICENSE b/packages/nitro/LICENSE new file mode 100644 index 000000000000..0ecae617386e --- /dev/null +++ b/packages/nitro/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/nitro/README.md b/packages/nitro/README.md new file mode 100644 index 000000000000..459d8087ce0a --- /dev/null +++ b/packages/nitro/README.md @@ -0,0 +1,129 @@ +

+ + Sentry + +

+ +> NOTICE: This package is in beta state and may be subject to breaking changes. + +# Official Sentry SDK for Nitro + +[![npm version](https://img.shields.io/npm/v/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) +[![npm dm](https://img.shields.io/npm/dm/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) +[![npm dt](https://img.shields.io/npm/dt/@sentry/nitro.svg)](https://www.npmjs.com/package/@sentry/nitro) + +## Links + +- [Official Nitro SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) +- [Example Nitro app](https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/nitro-3) + +## Compatibility + +The minimum supported version of Nitro is `3.0.260415-beta`. + +## General + +This package is a wrapper around `@sentry/node` with added instrumentation for Nitro's features like: + +- HTTP handlers and error capturing. +- [Middleware instrumentation](https://nitro.build/guide/routing#middleware). + + + +## Manual Setup + +### 1. Prerequisites & Installation + +1. Install the Sentry Nitro SDK: + + ```bash + # Using npm + npm install @sentry/nitro + + # Using yarn + yarn add @sentry/nitro + + # Using pnpm + pnpm add @sentry/nitro + ``` + +### 2. Build-Time Nitro Config Setup + +1. Import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +#### In `nitro.config.ts` + +If you are using a dedicated `nitro.config.ts` file, you can import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +```javascript +import { defineNitroConfig } from 'nitro/config'; +import { withSentryConfig } from '@sentry/nitro'; + +const config = defineNitroConfig({ + // ... +}); + +export default withSentryConfig(config, { + // Sentry Build Options +}); +``` + +#### In `vite.config.ts` + +If you are using Nitro as a Vite plugin, you can import `withSentryConfig` from `@sentry/nitro` and call it with your Nitro config. + +```ts +import { defineConfig } from 'vite'; +import { nitro } from 'nitro/vite'; +import { withSentryConfig } from '@sentry/nitro'; + +export default defineConfig({ + plugins: [nitro()], + nitro: withSentryConfig( + { + // Nitro options + }, + { + // Sentry Build Options + }, + ), +}); +``` + +### 3. Sentry Config Setup + +Create an `instrument.mjs` file in your project root to initialize the Sentry SDK: + +```javascript +import * as Sentry from '@sentry/nitro'; + +Sentry.init({ + dsn: '__YOUR_DSN__', + tracesSampleRate: 1.0, +}); +``` + +Then use `--import` in `NODE_OPTIONS` to load the instrumentation before your app code: + +```bash +NODE_OPTIONS='--import ./instrument.mjs' npx nitro dev +``` + +This works with any Nitro command (`nitro dev`, `nitro preview`, or a production start script). + +## Uploading Source Maps + +The `withSentryConfig` function automatically configures source map uploading when the `authToken`, `org`, and `project` +options are provided: + +```javascript +export default withSentryConfig(config, { + org: 'your-sentry-org', + project: 'your-sentry-project', + authToken: process.env.SENTRY_AUTH_TOKEN, +}); +``` + +## Troubleshoot + +If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nitro SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nitro/). If the documentation does not provide the necessary information, consider opening an issue on GitHub. diff --git a/packages/nitro/package.json b/packages/nitro/package.json new file mode 100644 index 000000000000..e85f71c7fe2b --- /dev/null +++ b/packages/nitro/package.json @@ -0,0 +1,84 @@ +{ + "name": "@sentry/nitro", + "version": "10.50.0", + "description": "Official Sentry SDK for Nitro", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "nitro", + "sentry", + "apm", + "tracing", + "error-tracking" + ], + "engines": { + "node": ">=18.19.1" + }, + "files": [ + "/build" + ], + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "nitro": ">=3.0.0-0 <4.0.0 || 3.0.260311-beta || 3.0.260415-beta" + }, + "dependencies": { + "@sentry/bundler-plugin-core": "^5.2.0", + "@sentry/core": "10.50.0", + "@sentry/node": "10.50.0", + "@sentry/opentelemetry": "10.50.0" + }, + "devDependencies": { + "nitro": "^3.0.260415-beta", + "h3": "^2.0.1-rc.13" + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "run-s build:types:core", + "build:types:core": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-nitro-*.tgz", + "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", + "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware", + "lint:es-compatibility": "es-check es2022 ./build/esm/*.js --module", + "test": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "dependsOn": [ + "^build:transpile", + "^build:types", + "build:types" + ], + "outputs": [ + "{projectRoot}/build/esm" + ] + } + } + } +} diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs new file mode 100644 index 000000000000..140655a7eca8 --- /dev/null +++ b/packages/nitro/rollup.npm.config.mjs @@ -0,0 +1,13 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default [ + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], + packageSpecificConfig: { + external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'], + }, + }), + { emitCjs: false }, + ), +]; diff --git a/packages/nitro/src/common/debug-build.ts b/packages/nitro/src/common/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/nitro/src/common/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts new file mode 100644 index 000000000000..cdc0f2b00dfb --- /dev/null +++ b/packages/nitro/src/config.ts @@ -0,0 +1,33 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { NitroConfig } from 'nitro/types'; +import { createNitroModule } from './module'; +import { configureSourcemapSettings } from './sourceMaps'; + +export type SentryNitroOptions = BuildTimeOptionsBase; + +/** + * Modifies the passed in Nitro configuration with automatic build-time instrumentation. + */ +export function withSentryConfig(config: NitroConfig, sentryOptions?: SentryNitroOptions): NitroConfig { + return setupSentryNitroModule(config, sentryOptions); +} + +/** + * Sets up the Sentry Nitro module, useful for meta framework integrations. + */ +export function setupSentryNitroModule( + config: NitroConfig, + moduleOptions?: SentryNitroOptions, + _serverConfigFile?: string, +): NitroConfig { + if (!config.tracingChannel) { + config.tracingChannel = true; + } + + const { sentryEnabledSourcemaps } = configureSourcemapSettings(config, moduleOptions); + + config.modules = config.modules || []; + config.modules.push(createNitroModule(moduleOptions, sentryEnabledSourcemaps)); + + return config; +} diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts new file mode 100644 index 000000000000..51f10f7ba5b5 --- /dev/null +++ b/packages/nitro/src/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/export */ +export * from './config'; +export * from '@sentry/node'; +export { init } from './sdk'; diff --git a/packages/nitro/src/instruments/instrumentServer.ts b/packages/nitro/src/instruments/instrumentServer.ts new file mode 100644 index 000000000000..ec891055558b --- /dev/null +++ b/packages/nitro/src/instruments/instrumentServer.ts @@ -0,0 +1,12 @@ +import type { Nitro } from 'nitro/types'; +import { addPlugin } from '../utils/plugin'; +import { createResolver } from '../utils/resolver'; + +/** + * Sets up the Nitro server instrumentation plugin + * @param nitro - The Nitro instance. + */ +export function instrumentServer(nitro: Nitro): void { + const moduleResolver = createResolver(import.meta.url); + addPlugin(nitro, moduleResolver.resolve('../runtime/plugins/server')); +} diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts new file mode 100644 index 000000000000..1a4e5b0478d1 --- /dev/null +++ b/packages/nitro/src/module.ts @@ -0,0 +1,17 @@ +import type { NitroModule } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; +import { instrumentServer } from './instruments/instrumentServer'; +import { setupSourceMaps } from './sourceMaps'; + +/** + * Creates a Nitro module to setup the Sentry SDK. + */ +export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): NitroModule { + return { + name: 'sentry', + setup: nitro => { + instrumentServer(nitro); + setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); + }, + }; +} diff --git a/packages/nitro/src/runtime/README.md b/packages/nitro/src/runtime/README.md new file mode 100644 index 000000000000..43c190e6d015 --- /dev/null +++ b/packages/nitro/src/runtime/README.md @@ -0,0 +1,5 @@ +# Nitro Runtime + +This directory contains the runtime code for Nitro, this includes plugins or any runtime code they may use. + +Do not mix runtime code with other code, this directory will be packaged with the SDK and shipped as-is. diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts new file mode 100644 index 000000000000..fab0c5eff05a --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts @@ -0,0 +1,76 @@ +import { + captureException, + flushIfServerless, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + parseUrl, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { HTTPError } from 'h3'; +import type { CapturedErrorContext } from 'nitro/types'; + +/** + * Extracts the relevant context information from the error context (HTTPEvent in Nitro Error) + * and creates a structured context object. + */ +function extractErrorContext(errorContext: CapturedErrorContext | undefined): Record { + const ctx: Record = {}; + + if (!errorContext) { + return ctx; + } + + if (errorContext.event) { + ctx.method = errorContext.event.req.method; + ctx.path = parseUrl(errorContext.event.req.url).path; + } + + if (Array.isArray(errorContext.tags)) { + ctx.tags = errorContext.tags; + } + + return ctx; +} + +/** + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + */ +export async function captureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise { + const sentryClient = getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + + // Do not report HTTPErrors with 3xx or 4xx status codes + if (HTTPError.isError(error) && error.status >= 300 && error.status < 500) { + return; + } + + const method = errorContext.event?.req.method ?? ''; + const path = errorContext.event?.req.url ? parseUrl(errorContext.event.req.url).path : null; + + if (path) { + getCurrentScope().setTransactionName(`${method} ${path}`); + const activeSpan = getActiveSpan(); + const activeRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + activeRootSpan?.updateName(`${method} ${path}`); + activeRootSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + + const structuredContext = extractErrorContext(errorContext); + + captureException(error, { + captureContext: { contexts: { nitro: structuredContext } }, + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }); + + await flushIfServerless(); +} diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts new file mode 100644 index 000000000000..bf70536b7800 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -0,0 +1,280 @@ +import { + captureException, + getActiveSpan, + getClient, + getHttpSpanDetailsFromUrlObject, + getRootSpan, + GLOBAL_OBJ, + httpHeadersToSpanAttributes, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setHttpStatus, + type Span, + SPAN_STATUS_ERROR, + startSpanManual, + updateSpanName, +} from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracing-channel'; +import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; +import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; +import { setServerTimingHeaders } from './setServerTimingHeaders'; + +/** + * Global object with the trace channels + */ +const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean; +}; + +/** + * Captures tracing events emitted by Nitro tracing channels. + */ +export function captureTracingEvents(): void { + if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) { + return; + } + + setupH3TracingChannels(); + setupSrvxTracingChannels(); + globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; +} + +/** + * No-op function to satisfy the tracing channel subscribe callbacks + */ +const NOOP = (): void => {}; + +/** + * Extracts the HTTP status code from a tracing channel result. + * The result is the return value of the traced handler, which is a Response for srvx + * and may or may not be a Response for h3. + */ +function getResponseStatusCode(result: unknown): number | undefined { + if (result && typeof result === 'object' && 'status' in result && typeof result.status === 'number') { + return result.status; + } + return undefined; +} + +function onTraceEnd(data: TracingChannelContextWithSpan<{ result?: unknown }>): void { + const statusCode = getResponseStatusCode(data.result); + if (data._sentrySpan && statusCode !== undefined) { + setHttpStatus(data._sentrySpan, statusCode); + } + + data._sentrySpan?.end(); +} + +function onTraceError(data: TracingChannelContextWithSpan<{ error: unknown }>): void { + captureException(data.error, { mechanism: { type: 'auto.http.nitro.onTraceError', handled: false } }); + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data._sentrySpan?.end(); +} + +/** + * Extracts the parameterized route pattern from the h3 event context. + */ +function getParameterizedRoute(event: H3TracingRequestEvent['event']): string | undefined { + const matchedRoute = event.context?.matchedRoute; + if (!matchedRoute) { + return undefined; + } + + const routePath = matchedRoute.route; + + // Skip catch-all routes as they're not useful for transaction grouping + if (!routePath || routePath === '/**') { + return undefined; + } + + return routePath; +} + +function setupH3TracingChannels(): void { + const h3Channel = tracingChannel('h3.request', data => { + const parsedUrl = parseStringToURLObject(data.event.url.href); + const routePattern = getParameterizedRoute(data.event); + + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject( + parsedUrl, + 'server', + 'auto.http.nitro.h3', + { method: data.event.req.method }, + routePattern, + ); + + return startSpanManual( + { + name: spanName, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.h3', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', + }, + }, + span => { + setParameterizedRouteAttributes(span, data.event); + + return span; + }, + ); + }); + + h3Channel.subscribe({ + start: (data: H3TracingRequestEvent) => { + setServerTimingHeaders(data.event); + }, + asyncStart: NOOP, + end: NOOP, + asyncEnd: (data: TracingChannelContextWithSpan) => { + onTraceEnd(data); + + if (!data._sentrySpan) { + return; + } + + // Update the root span (srvx transaction) with the parameterized route name. + // The srvx span is created before h3 resolves the route, so it initially has the raw URL. + // Note: data.type is always 'middleware' in asyncEnd regardless of handler type, + // so we rely on getParameterizedRoute() to filter out catch-all routes instead. + const rootSpan = getRootSpan(data._sentrySpan); + if (rootSpan && rootSpan !== data._sentrySpan) { + const routePattern = getParameterizedRoute(data.event); + if (routePattern) { + const method = data.event.req.method || 'GET'; + updateSpanName(rootSpan, `${method} ${routePattern}`); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': routePattern, + }); + } + } + }, + error: onTraceError, + }); +} + +function setupSrvxTracingChannels(): void { + // Store the parent span per-request so middleware and fetch share the same parent. + // WeakMap ensures per-request isolation in concurrent environments and automatic cleanup. + const requestParentSpans = new WeakMap(); + + const fetchChannel = tracingChannel('srvx.request', data => { + const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', { + method: data.request.method, + }); + + const sendDefaultPii = getClient()?.getOptions().sendDefaultPii ?? false; + const headerAttributes = httpHeadersToSpanAttributes( + Object.fromEntries(data.request.headers.entries()), + sendDefaultPii, + ); + + return startSpanManual( + { + name: spanName, + attributes: { + ...urlAttributes, + ...headerAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data.middleware ? 'middleware.nitro' : 'http.server', + 'server.port': data.server.options.port, + }, + // Use the same parent span as middleware to make them siblings + parentSpan: requestParentSpans.get(data.request) || undefined, + }, + span => span, + ); + }); + + // Subscribe to events (span already created in bindStore) + fetchChannel.subscribe({ + start: () => {}, + asyncStart: () => {}, + end: () => {}, + asyncEnd: data => { + onTraceEnd(data); + + // Clean up parent span reference after the fetch handler completes. + requestParentSpans.delete(data.request); + }, + error: data => { + onTraceError(data); + // Clean up parent span reference on error too + requestParentSpans.delete(data.request); + }, + }); + + const middlewareChannel = tracingChannel('srvx.middleware', data => { + // For the first middleware, capture the current parent span per-request + if (data.middleware?.index === 0) { + const activeSpan = getActiveSpan(); + if (activeSpan) { + requestParentSpans.set(data.request, activeSpan); + } + } + + const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; + const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', { + method: data.request.method, + }); + + // Create span as a child of the original parent, not the previous middleware + return startSpanManual( + { + name: `${data.middleware?.handler.name ?? 'unknown'} - ${data.request.method} ${data.request._url?.pathname}`, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nitro', + }, + parentSpan: requestParentSpans.get(data.request) || undefined, + }, + span => span, + ); + }); + + // Subscribe to events (span already created in bindStore) + middlewareChannel.subscribe({ + start: () => {}, + asyncStart: () => {}, + end: () => {}, + asyncEnd: onTraceEnd, + error: onTraceError, + }); +} + +/** + * Sets the parameterized route attributes on the span. + */ +function setParameterizedRouteAttributes(span: Span, event: H3TracingRequestEvent['event']): void { + const rootSpan = getRootSpan(span); + if (!rootSpan) { + return; + } + + const matchedRoutePath = getParameterizedRoute(event); + if (!matchedRoutePath) { + return; + } + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': matchedRoutePath, + }); + + const params = event.context?.params; + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + // Based on this convention: https://getsentry.github.io/sentry-conventions/generated/attributes/url.html#urlpathparameterkey + rootSpan.setAttributes({ + [`url.path.parameter.${key}`]: String(value), + [`params.${key}`]: String(value), + }); + }); + } +} diff --git a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts new file mode 100644 index 000000000000..4573f8171c19 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts @@ -0,0 +1,27 @@ +import { getTraceData } from '@sentry/core'; +import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; + +/** + * Sets Server-Timing response headers for trace propagation to the client. + * The browser SDK reads these via the Performance API to connect pageload traces. + */ +export function setServerTimingHeaders(event: H3TracingRequestEvent['event']): void { + if (event.context._sentryServerTimingSet) { + return; + } + + const headers = event.res?.headers; + if (!headers) { + return; + } + + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); + } + + event.context._sentryServerTimingSet = true; +} diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts new file mode 100644 index 000000000000..2feee84bcc55 --- /dev/null +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -0,0 +1,9 @@ +import { definePlugin } from 'nitro'; +import { captureErrorHook } from '../hooks/captureErrorHook'; +import { captureTracingEvents } from '../hooks/captureTracingEvents'; + +export default definePlugin(nitroApp => { + nitroApp.hooks.hook('error', captureErrorHook); + + captureTracingEvents(); +}); diff --git a/packages/nitro/src/sdk.ts b/packages/nitro/src/sdk.ts new file mode 100644 index 000000000000..d67c0594aa2b --- /dev/null +++ b/packages/nitro/src/sdk.ts @@ -0,0 +1,32 @@ +import type { Integration } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node'; + +/** + * Initializes the Nitro SDK + */ +export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + const opts: NodeOptions = { + ...options, + }; + + if (opts.defaultIntegrations === undefined) { + opts.defaultIntegrations = getDefaultIntegrations(opts); + } + + applySdkMetadata(opts, 'nitro', ['nitro', 'node']); + + const client = nodeInit(opts); + + return client; +} + +/** + * Get the default integrations for the Nitro SDK. + * + * @returns The default integrations for the Nitro SDK. + */ +export function getDefaultIntegrations(options: NodeOptions): Integration[] | undefined { + return [...getDefaultNodeIntegrations(options)]; +} diff --git a/packages/nitro/src/sourceMaps.ts b/packages/nitro/src/sourceMaps.ts new file mode 100644 index 000000000000..9aa470a88d90 --- /dev/null +++ b/packages/nitro/src/sourceMaps.ts @@ -0,0 +1,192 @@ +import type { Options as BundlerPluginOptions } from '@sentry/bundler-plugin-core'; +import { createSentryBuildPluginManager } from '@sentry/bundler-plugin-core'; +import type { Nitro, NitroConfig } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; + +/** + * Registers a `compiled` hook to upload source maps after the build completes. + */ +export function setupSourceMaps(nitro: Nitro, options?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): void { + // The `compiled` hook fires on EVERY rebuild during `nitro dev` watch mode. + // nitro.options.dev is reliably set by the time module setup runs. + if (shouldSkipSourcemapUpload(nitro, options)) { + return; + } + + nitro.hooks.hook('compiled', async (_nitro: Nitro) => { + await handleSourceMapUpload(_nitro, options, sentryEnabledSourcemaps); + }); +} + +/** + * Determines if sourcemap uploads should be skipped. + */ +function shouldSkipSourcemapUpload(nitro: Nitro, options?: SentryNitroOptions): boolean { + return !!( + nitro.options.dev || + nitro.options.preset === 'nitro-prerender' || + nitro.options.sourcemap === false || + (nitro.options.sourcemap as unknown) === 'inline' || + options?.sourcemaps?.disable === true + ); +} + +/** + * Handles the actual source map upload after the build completes. + */ +async function handleSourceMapUpload( + nitro: Nitro, + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, +): Promise { + const outputDir = nitro.options.output.serverDir; + const pluginOptions = getPluginOptions(options, sentryEnabledSourcemaps, outputDir); + + const sentryBuildPluginManager = createSentryBuildPluginManager(pluginOptions, { + buildTool: 'nitro', + loggerPrefix: '[@sentry/nitro]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + + await sentryBuildPluginManager.injectDebugIds([outputDir]); + + if (options?.sourcemaps?.disable !== 'disable-upload') { + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't prepare the artifacts because we injected debug IDs manually before + prepareArtifacts: false, + }); + await sentryBuildPluginManager.deleteArtifacts(); + } +} + +/** + * Normalizes the beginning of a path from e.g. ../../../ to ./ + */ +function normalizePath(path: string): string { + return path.replace(/^(\.\.\/)+/, './'); +} + +/** + * Removes a trailing slash from a path so glob patterns can be appended cleanly. + */ +function removeTrailingSlash(path: string): string { + return path.replace(/\/$/, ''); +} + +/** + * Builds the plugin options for `createSentryBuildPluginManager` from the Sentry Nitro options. + * + * Only exported for testing purposes. + */ +// oxlint-disable-next-line complexity +export function getPluginOptions( + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, + outputDir?: string, +): BundlerPluginOptions { + const defaultFilesToDelete = + sentryEnabledSourcemaps && outputDir ? [`${removeTrailingSlash(outputDir)}/**/*.map`] : undefined; + + if (options?.debug && defaultFilesToDelete && options?.sourcemaps?.filesToDeleteAfterUpload === undefined) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/nitro] Setting \`sourcemaps.filesToDeleteAfterUpload: ["${defaultFilesToDelete[0]}"]\` to delete generated source maps after they were uploaded to Sentry.`, + ); + } + + return { + org: options?.org ?? process.env.SENTRY_ORG, + project: options?.project ?? process.env.SENTRY_PROJECT, + authToken: options?.authToken ?? process.env.SENTRY_AUTH_TOKEN, + url: options?.sentryUrl ?? process.env.SENTRY_URL, + headers: options?.headers, + telemetry: options?.telemetry ?? true, + debug: options?.debug ?? false, + silent: options?.silent ?? false, + errorHandler: options?.errorHandler, + sourcemaps: { + disable: options?.sourcemaps?.disable, + assets: options?.sourcemaps?.assets, + ignore: options?.sourcemaps?.ignore, + filesToDeleteAfterUpload: options?.sourcemaps?.filesToDeleteAfterUpload ?? defaultFilesToDelete, + rewriteSources: options?.sourcemaps?.rewriteSources ?? ((source: string) => normalizePath(source)), + }, + release: options?.release, + bundleSizeOptimizations: options?.bundleSizeOptimizations, + _metaOptions: { + telemetry: { + metaFramework: 'nitro', + }, + }, + }; +} + +/* Source map configuration rules: + 1. User explicitly disabled source maps (sourcemap: false) + - Keep their setting, emit a warning that errors won't be unminified in Sentry + - We will not upload anything + 2. User enabled source map generation (true) + - Keep their setting (don't modify besides uploading) + 3. User did not set source maps (undefined) + - We enable source maps for Sentry + - Configure `filesToDeleteAfterUpload` to clean up .map files after upload +*/ +export function configureSourcemapSettings( + config: NitroConfig, + moduleOptions?: SentryNitroOptions, +): { sentryEnabledSourcemaps: boolean } { + const sourcemapUploadDisabled = moduleOptions?.sourcemaps?.disable === true; + if (sourcemapUploadDisabled) { + return { sentryEnabledSourcemaps: false }; + } + + // Nitro types `sourcemap` as `boolean`, but it forwards the value to Vite which also accepts `'hidden'` and `'inline'`. + const userSourcemap = (config as { sourcemap?: boolean | 'hidden' | 'inline' }).sourcemap; + + if (userSourcemap === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nitro] You have explicitly disabled source maps (`sourcemap: false`). Sentry will not upload source maps, and errors will not be unminified. To let Sentry handle source maps, remove the `sourcemap` option from your Nitro config, or use `sourcemaps: { disable: true }` in your Sentry options to silence this warning.', + ); + return { sentryEnabledSourcemaps: false }; + } + + if (userSourcemap === 'inline') { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nitro] You have set `sourcemap: "inline"`. Inline source maps are embedded in the output bundle, so there are no `.map` files to upload. Sentry will not upload source maps. Set `sourcemap: "hidden"` (or leave it unset) to let Sentry upload source maps and un-minify errors.', + ); + return { sentryEnabledSourcemaps: false }; + } + + let sentryEnabledSourcemaps = false; + if (userSourcemap === true || userSourcemap === 'hidden') { + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log( + `[@sentry/nitro] Source maps are already enabled (\`sourcemap: ${JSON.stringify(userSourcemap)}\`). Sentry will upload them for error unminification.`, + ); + } + } else { + // User did not explicitly set sourcemap, enable hidden source maps for Sentry. + // `'hidden'` emits .map files without adding a `//# sourceMappingURL=` comment to the output, avoiding public exposure. + (config as { sourcemap?: unknown }).sourcemap = 'hidden'; + sentryEnabledSourcemaps = true; + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log( + '[@sentry/nitro] Enabled hidden source map generation for Sentry. Source map files will be deleted after upload.', + ); + } + } + + // Nitro v3 has a `sourcemapMinify` plugin that destructively deletes `sourcesContent`, + // `x_google_ignoreList`, and clears `mappings` for any chunk containing `node_modules`. + // This makes sourcemaps unusable for Sentry. + config.experimental = config.experimental || {}; + config.experimental.sourcemapMinify = false; + + return { sentryEnabledSourcemaps }; +} diff --git a/packages/nitro/src/utils/plugin.ts b/packages/nitro/src/utils/plugin.ts new file mode 100644 index 000000000000..443e3f430ba1 --- /dev/null +++ b/packages/nitro/src/utils/plugin.ts @@ -0,0 +1,9 @@ +import type { Nitro } from 'nitro/types'; + +/** + * Adds a Nitro plugin + */ +export function addPlugin(nitro: Nitro, plugin: string): void { + nitro.options.plugins = nitro.options.plugins || []; + nitro.options.plugins.push(plugin); +} diff --git a/packages/nitro/src/utils/resolver.ts b/packages/nitro/src/utils/resolver.ts new file mode 100644 index 000000000000..f0bde304d929 --- /dev/null +++ b/packages/nitro/src/utils/resolver.ts @@ -0,0 +1,25 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface Resolver { + resolve(...path: string[]): string; +} + +/** + * Creates a resolver for the given base path. + * @example + * ```ts + * const resolver = createResolver(import.meta.url); + * resolver.resolve('foo/bar.js'); + * ``` + */ +export function createResolver(base: string): Resolver { + let resolvedBase = base; + if (base.startsWith('file://')) { + resolvedBase = dirname(fileURLToPath(base)); + } + + return { + resolve: (...path) => resolve(resolvedBase, ...path), + }; +} diff --git a/packages/nitro/test/index.test.ts b/packages/nitro/test/index.test.ts new file mode 100644 index 000000000000..bc9db1cddfbb --- /dev/null +++ b/packages/nitro/test/index.test.ts @@ -0,0 +1,11 @@ +// Dummy test to satisfy the test runner +import { describe, expect, test } from 'vitest'; +import * as NitroServer from '../src'; + +describe('Nitro SDK', () => { + // This is a place holder test at best to satisfy the test runner + test('exports client and server SDKs', () => { + expect(NitroServer).toBeDefined(); + expect(NitroServer.init).toBeDefined(); + }); +}); diff --git a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts new file mode 100644 index 000000000000..804ef569a619 --- /dev/null +++ b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts @@ -0,0 +1,168 @@ +import * as SentryCore from '@sentry/core'; +import { HTTPError } from 'h3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { captureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; + +vi.mock('@sentry/core', async importOriginal => { + const mod = await importOriginal(); + return { + ...(mod as any), + captureException: vi.fn(), + flushIfServerless: vi.fn(), + getClient: vi.fn(), + getCurrentScope: vi.fn(() => ({ + setTransactionName: vi.fn(), + })), + }; +}); + +describe('captureErrorHook', () => { + const mockErrorContext = { + event: { + req: { method: 'GET', url: 'http://localhost/test-path' }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({}), + }); + (SentryCore.flushIfServerless as any).mockResolvedValue(undefined); + }); + + it('should capture regular errors', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }), + ); + }); + + it('should include structured context with method and path', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: { method: 'GET', path: '/test-path' }, + }, + }, + }), + ); + }); + + it('should set transaction name from method and path', async () => { + const mockSetTransactionName = vi.fn(); + (SentryCore.getCurrentScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); + + it('should skip HTTPError with 4xx status codes', async () => { + const error = new HTTPError({ status: 404, message: 'Not found' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should skip HTTPError with 3xx status codes', async () => { + const error = new HTTPError({ status: 302, message: 'Redirect' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture HTTPError with 5xx status codes', async () => { + const error = new HTTPError({ status: 500, message: 'Server error' }); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, + }), + ); + }); + + it('should skip when enableNitroErrorHandler is false', async () => { + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({ enableNitroErrorHandler: false }), + }); + + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should call flushIfServerless after capturing', async () => { + const error = new Error('Test error'); + + await captureErrorHook(error, mockErrorContext); + + expect(SentryCore.flushIfServerless).toHaveBeenCalled(); + }); + + it('should handle missing event in error context', async () => { + const error = new Error('Test error'); + const contextWithoutEvent = { + event: undefined, + }; + + await captureErrorHook(error, contextWithoutEvent); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: {}, + }, + }, + }), + ); + }); + + it('should include tags in structured context when available', async () => { + const error = new Error('Test error'); + const contextWithTags = { + event: { + req: { method: 'POST', url: 'http://localhost/api/test' }, + } as any, + tags: ['tag1', 'tag2'], + }; + + await captureErrorHook(error, contextWithTags); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + nitro: { method: 'POST', path: '/api/test', tags: ['tag1', 'tag2'] }, + }, + }, + }), + ); + }); +}); diff --git a/packages/nitro/test/sourceMaps.test.ts b/packages/nitro/test/sourceMaps.test.ts new file mode 100644 index 000000000000..421994c4e2f1 --- /dev/null +++ b/packages/nitro/test/sourceMaps.test.ts @@ -0,0 +1,367 @@ +import type { NitroConfig } from 'nitro/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNitroOptions } from '../src/config'; +import { setupSentryNitroModule } from '../src/config'; +import { configureSourcemapSettings, getPluginOptions, setupSourceMaps } from '../src/sourceMaps'; + +vi.mock('../src/instruments/instrumentServer', () => ({ + instrumentServer: vi.fn(), +})); + +describe('getPluginOptions', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns default options when no options are provided', () => { + const options = getPluginOptions(undefined, true, '/project/.output/server'); + + expect(options).toEqual( + expect.objectContaining({ + telemetry: true, + debug: false, + silent: false, + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: ['/project/.output/server/**/*.map'], + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nitro', + }), + }), + }), + ); + expect(options.org).toBeUndefined(); + expect(options.project).toBeUndefined(); + expect(options.authToken).toBeUndefined(); + expect(options.url).toBeUndefined(); + }); + + it('does not default filesToDeleteAfterUpload when user enabled sourcemaps themselves', () => { + const options = getPluginOptions(undefined, false); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + + it('respects user-provided filesToDeleteAfterUpload even when Sentry enabled sourcemaps', () => { + const options = getPluginOptions( + { sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + true, + '/project/.output/server', + ); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('logs the default filesToDeleteAfterUpload glob in debug mode', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + getPluginOptions({ debug: true }, true, '/project/.output/server'); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/project/.output/server/**/*.map')); + logSpy.mockRestore(); + }); + + it('does not log the default glob when user provides filesToDeleteAfterUpload', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + getPluginOptions( + { debug: true, sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + true, + '/project/.output/server', + ); + + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('filesToDeleteAfterUpload')); + logSpy.mockRestore(); + }); + + it('uses environment variables as fallback', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_PROJECT = 'env-project'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://custom.sentry.io'; + + const options = getPluginOptions(); + + expect(options.org).toBe('env-org'); + expect(options.project).toBe('env-project'); + expect(options.authToken).toBe('env-token'); + expect(options.url).toBe('https://custom.sentry.io'); // sentryUrl maps to url + }); + + it('prefers direct options over environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://env.sentry.io'; + + const options = getPluginOptions({ + org: 'direct-org', + authToken: 'direct-token', + sentryUrl: 'https://direct.sentry.io', + }); + + expect(options.org).toBe('direct-org'); + expect(options.authToken).toBe('direct-token'); + expect(options.url).toBe('https://direct.sentry.io'); + }); + + it('passes through all user options', () => { + const sentryOptions: SentryNitroOptions = { + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://my-sentry.io', + headers: { 'X-Custom': 'header' }, + debug: true, + silent: true, + telemetry: false, + errorHandler: () => {}, + release: { name: 'v1.0.0' }, + sourcemaps: { + assets: ['dist/**'], + ignore: ['dist/test/**'], + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + }; + + const options = getPluginOptions(sentryOptions); + + expect(options.org).toBe('my-org'); + expect(options.project).toBe('my-project'); + expect(options.authToken).toBe('my-token'); + expect(options.url).toBe('https://my-sentry.io'); + expect(options.headers).toEqual({ 'X-Custom': 'header' }); + expect(options.debug).toBe(true); + expect(options.silent).toBe(true); + expect(options.telemetry).toBe(false); + expect(options.errorHandler).toBeDefined(); + expect(options.release).toEqual({ name: 'v1.0.0' }); + expect(options.sourcemaps?.assets).toEqual(['dist/**']); + expect(options.sourcemaps?.ignore).toEqual(['dist/test/**']); + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('normalizes source paths via rewriteSources', () => { + const options = getPluginOptions(); + const rewriteSources = options.sourcemaps?.rewriteSources; + + expect(rewriteSources?.('../../../src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('../../lib/utils.ts', undefined)).toBe('./lib/utils.ts'); + expect(rewriteSources?.('./src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('src/index.ts', undefined)).toBe('src/index.ts'); + }); + + it('uses user-provided rewriteSources when given', () => { + const customRewrite = (source: string) => `/custom/${source}`; + const options = getPluginOptions({ sourcemaps: { rewriteSources: customRewrite } }); + + expect(options.sourcemaps?.rewriteSources?.('../../../src/index.ts', undefined)).toBe( + '/custom/../../../src/index.ts', + ); + }); + + it('always sets metaFramework to nitro', () => { + const options = getPluginOptions(); + + expect(options._metaOptions?.telemetry?.metaFramework).toBe('nitro'); + }); + + it('passes through sourcemaps.disable', () => { + const options = getPluginOptions({ sourcemaps: { disable: 'disable-upload' } }); + + expect(options.sourcemaps?.disable).toBe('disable-upload'); + }); +}); + +describe('configureSourcemapSettings', () => { + it('enables hidden sourcemap generation on the config', () => { + const config: NitroConfig = {}; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe('hidden'); + expect(result.sentryEnabledSourcemaps).toBe(true); + }); + + it('respects user explicitly disabling sourcemaps and warns', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(false); + expect(result.sentryEnabledSourcemaps).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('explicitly disabled source maps')); + warnSpy.mockRestore(); + }); + + it('does not modify experimental config when user disabled sourcemaps', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config); + + expect(config.experimental).toBeUndefined(); + vi.restoreAllMocks(); + }); + + it('keeps sourcemap true when user already set it', () => { + const config: NitroConfig = { sourcemap: true }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + expect(result.sentryEnabledSourcemaps).toBe(false); + }); + + it('keeps sourcemap "hidden" when user already set it and does not enable deletion', () => { + const config = { sourcemap: 'hidden' } as unknown as NitroConfig; + const result = configureSourcemapSettings(config); + + expect((config as { sourcemap?: unknown }).sourcemap).toBe('hidden'); + expect(result.sentryEnabledSourcemaps).toBe(false); + }); + + it('keeps sourcemap "inline", warns, and does not enable uploads', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { sourcemap: 'inline' } as unknown as NitroConfig; + const result = configureSourcemapSettings(config); + + expect((config as { sourcemap?: unknown }).sourcemap).toBe('inline'); + expect(result.sentryEnabledSourcemaps).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('`sourcemap: "inline"`')); + warnSpy.mockRestore(); + }); + + it('disables experimental sourcemapMinify', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('preserves existing experimental config', () => { + const config: NitroConfig = { + experimental: { + sourcemapMinify: undefined, + }, + }; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('skips sourcemap config when sourcemaps.disable is true', () => { + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config, { sourcemaps: { disable: true } }); + + expect(config.sourcemap).toBe(false); + }); + + it('still configures sourcemaps when sourcemaps.disable is disable-upload', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config, { sourcemaps: { disable: 'disable-upload' } }); + + expect(config.sourcemap).toBe('hidden'); + }); +}); + +describe('setupSentryNitroModule', () => { + it('enables tracing', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.tracingChannel).toBe(true); + }); + + it('adds the sentry module', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); + + it('still adds module when sourcemaps are disabled', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config, { sourcemaps: { disable: true } }); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); +}); + +describe('setupSourceMaps', () => { + it('does not register hook in dev mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: true, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook when sourcemaps.disable is true', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { sourcemaps: { disable: true } }); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook when nitro sourcemap is disabled', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, sourcemap: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook in nitro-prerender preset', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, preset: 'nitro-prerender', output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('registers compiled hook in production mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); + + it('registers compiled hook with custom options', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { org: 'my-org', project: 'my-project' }); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); +}); diff --git a/packages/nitro/test/tsconfig.json b/packages/nitro/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/packages/nitro/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/packages/nitro/tsconfig.json b/packages/nitro/tsconfig.json new file mode 100644 index 000000000000..202590772b10 --- /dev/null +++ b/packages/nitro/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + // package-specific options + "module": "esnext", + "moduleResolution": "bundler" + } +} diff --git a/packages/nitro/tsconfig.test.json b/packages/nitro/tsconfig.test.json new file mode 100644 index 000000000000..c41efeacd92f --- /dev/null +++ b/packages/nitro/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + } +} diff --git a/packages/nitro/tsconfig.types.json b/packages/nitro/tsconfig.types.json new file mode 100644 index 000000000000..b1a51db073c2 --- /dev/null +++ b/packages/nitro/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/nitro/vite.config.ts b/packages/nitro/vite.config.ts new file mode 100644 index 000000000000..4c0db8cdc068 --- /dev/null +++ b/packages/nitro/vite.config.ts @@ -0,0 +1,11 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + }, + }, +}; diff --git a/packages/opentelemetry/src/tracingChannel.ts b/packages/opentelemetry/src/tracingChannel.ts index 984986b7cdcb..5548201c5f4c 100644 --- a/packages/opentelemetry/src/tracingChannel.ts +++ b/packages/opentelemetry/src/tracingChannel.ts @@ -18,7 +18,7 @@ import { DEBUG_BUILD } from './debug-build'; */ export type OtelTracingChannelTransform = (data: TData) => Span; -type WithSpan = TData & { _sentrySpan?: Span }; +export type TracingChannelContextWithSpan = TContext & { _sentrySpan?: Span }; /** * A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber @@ -26,7 +26,7 @@ type WithSpan = TData & { _sentrySpan?: Span }; */ export interface OtelTracingChannel< TData extends object = object, - TDataWithSpan extends object = WithSpan, + TDataWithSpan extends object = TracingChannelContextWithSpan, > extends Omit, 'subscribe' | 'unsubscribe'> { subscribe(subscribers: Partial>): void; unsubscribe(subscribers: Partial>): void; @@ -52,10 +52,10 @@ interface ContextApi { export function tracingChannel( channelNameOrInstance: string, transformStart: OtelTracingChannelTransform, -): OtelTracingChannel> { - const channel = nativeTracingChannel, WithSpan>( +): OtelTracingChannel> { + const channel = nativeTracingChannel, TracingChannelContextWithSpan>( channelNameOrInstance, - ) as unknown as OtelTracingChannel>; + ) as unknown as OtelTracingChannel>; let lookup: AsyncLocalStorageLookup | undefined; try { @@ -78,7 +78,7 @@ export function tracingChannel( // Bind the start channel so that each trace invocation runs the transform // and stores the resulting context (with span) in AsyncLocalStorage. // @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type - channel.start.bindStore(otelStorage, (data: WithSpan) => { + channel.start.bindStore(otelStorage, (data: TracingChannelContextWithSpan) => { const span = transformStart(data); // Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it. diff --git a/yarn.lock b/yarn.lock index d95a0b67c008..bda61b7744ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3158,12 +3158,12 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz#2cbcf822bf3764c9658c4d2e568bd0c0cb748016" integrity sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw== -"@dabh/diagnostics@^2.0.2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" - integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== +"@dabh/diagnostics@^2.0.2", "@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== dependencies: - colorspace "1.1.x" + "@so-ric/colorspace" "^1.1.6" enabled "2.0.x" kuler "^2.0.0" @@ -3305,25 +3305,25 @@ lodash "^4.17.21" resolve "^1.20.0" -"@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" - integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== +"@emnapi/core@1.9.2", "@emnapi/core@^1.1.0", "@emnapi/core@^1.4.3": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== dependencies: - "@emnapi/wasi-threads" "1.2.0" + "@emnapi/wasi-threads" "1.2.1" tslib "^2.4.0" -"@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.7.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" - integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== +"@emnapi/runtime@1.9.2", "@emnapi/runtime@^1.1.0", "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== dependencies: tslib "^2.4.0" -"@emnapi/wasi-threads@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz#a19d9772cc3d195370bf6e2a805eec40aa75e18e" - integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== dependencies: tslib "^2.4.0" @@ -5338,13 +5338,11 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@napi-rs/wasm-runtime@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2" - integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== +"@napi-rs/wasm-runtime@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz#1eeb8699770481306e5fcd84471f20fcb6177336" + integrity sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ== dependencies: - "@emnapi/core" "^1.7.1" - "@emnapi/runtime" "^1.7.1" "@tybys/wasm-util" "^0.10.1" "@nestjs/common@^10.0.0": @@ -6468,10 +6466,10 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.76.0.tgz#3dbef82283f871c9cb59325c9daf4f740d11a6e9" integrity sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA== -"@oxc-project/types@=0.120.0": - version "0.120.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.120.0.tgz#af521b0e689dd0eaa04fe4feef9b68d98b74783d" - integrity sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg== +"@oxc-project/types@=0.124.0": + version "0.124.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.124.0.tgz#1dfd7b3fbb98febc2f91b505f48c940db73c8701" + integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg== "@oxc-project/types@^0.76.0": version "0.76.0" @@ -6823,9 +6821,9 @@ integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== "@poppinss/colors@^4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@poppinss/colors/-/colors-4.1.5.tgz#09273b845a4816f5fd9c53c78a3bc656650fe18f" - integrity sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw== + version "4.1.6" + resolved "https://registry.yarnpkg.com/@poppinss/colors/-/colors-4.1.6.tgz#bf8546e30cfc5ee8dfe68988ce58eb0ad9d7c21b" + integrity sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg== dependencies: kleur "^4.1.5" @@ -7183,87 +7181,94 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/binding-android-arm64@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz#0bbd3380f49a6d0dc96c9b32fb7dad26ae0dfaa7" - integrity sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg== - -"@rolldown/binding-darwin-arm64@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz#a30b051784fbb13635e652ba4041c6ce7a4ce7ab" - integrity sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w== - -"@rolldown/binding-darwin-x64@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz#2d9dea982d5be90b95b6d8836ff26a4b0959d94b" - integrity sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A== - -"@rolldown/binding-freebsd-x64@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz#4efc3aca43ae4dfb90729eeca6e84ef6e6b38c4a" - integrity sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w== - -"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz#4a19a5d24537e925b25e9583b6cd575b2ad9fa27" - integrity sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA== - -"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz#01a41e5e905838353ae9a3da10dc8242dcd61453" - integrity sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg== - -"@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz#bd059e5f83471de29ce35b0ba254995d8091ca40" - integrity sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g== - -"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz#fe726a540631015f269a989c0cfb299283190390" - integrity sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w== - -"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz#825ced028bad3f1fa9ce83b1f3dac76e0424367f" - integrity sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg== - -"@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz#b700dae69274aa3d54a16ca5e00e30f47a089119" - integrity sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw== - -"@rolldown/binding-linux-x64-musl@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz#eb875660ad68a2348acab36a7005699e87f6e9dd" - integrity sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA== - -"@rolldown/binding-openharmony-arm64@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz#72aa24b412f83025087bcf83ce09634b2bd93c5c" - integrity sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q== - -"@rolldown/binding-wasm32-wasi@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz#7f3303a96c5dc01d1f4c539b1dcbc16392c6f17d" - integrity sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA== - dependencies: - "@napi-rs/wasm-runtime" "^1.1.1" - -"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz#3419144a04ad12c69c48536b01fc21ac9d87ecf4" - integrity sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ== - -"@rolldown/binding-win32-x64-msvc@1.0.0-rc.10": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz#09bee46e6a32c6086beeabc3da12e67be714f882" - integrity sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w== - -"@rolldown/pluginutils@1.0.0-rc.10", "@rolldown/pluginutils@^1.0.0-beta.9": - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz#eed997f37f928a3300bbe2161f42687d8a3ae759" - integrity sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg== +"@rolldown/binding-android-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz#ca20574c469ade7b941f90c9af5e83e7c67f06b7" + integrity sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz#ce2c5c7fc4958dfc94783dc09b3d09f3c2e1d072" + integrity sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg== + +"@rolldown/binding-darwin-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz#251ecdf1fdb751031cb6486907c105daaf9dab21" + integrity sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz#dbcfe95f409bf671a77bd83bff0fdc877d217728" + integrity sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz#ea002b45445be6f9ed1883a834b335bc2ccd510f" + integrity sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz#12b96e7e7821a9dc2cd5c670ad56882987ed5c62" + integrity sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz#738b0f62f0b65bf676dfe48595017f1883859d1f" + integrity sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz#3088b9fbc2783033985b558316f87f39281bc533" + integrity sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz#ac0aa6f1b72e3151d56c43145a71c745cf862a9a" + integrity sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz#b8cf27aa5be6da641c22dad5665d0240551d2dec" + integrity sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz#4531f9eca77963935026634ba9b61c2535340534" + integrity sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz#66ff691a65f9325171bced98e353b4cc4b0095c3" + integrity sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz#7db6c90aa510eef65d7d0f14e8ca23775e8e5eee" + integrity sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.3" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz#81f9097abbd4493cc13373b26f5a3da8461dbb47" + integrity sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz#cef11bc89149f3a77771727be75490fbb13ae193" + integrity sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g== + +"@rolldown/pluginutils@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz#e75d7731593e195d23710f9ff49bf5c745c96682" + integrity sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g== + +"@rolldown/pluginutils@^1.0.0-beta.9": + version "1.0.0-rc.16" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz#bc27c8f906309b57c6c10eddb21043fd8e86b87e" + integrity sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA== "@rollup/plugin-alias@^5.0.0": version "5.1.1" @@ -8377,6 +8382,14 @@ dependencies: tslib "^2.6.2" +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + "@socket.io/component-emitter@~3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" @@ -13423,7 +13436,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0, color-convert@^1.9.3: +color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -13437,6 +13450,13 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" +color-convert@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.3.tgz#db6627b97181cb8facdfce755ae26f97ab0711f1" + integrity sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg== + dependencies: + color-name "^2.0.0" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -13447,7 +13467,12 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.6.0, color-string@^1.9.0: +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -13455,19 +13480,18 @@ color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== + dependencies: + color-name "^2.0.0" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -color@^3.1.3: - version "3.2.1" - resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" - integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== - dependencies: - color-convert "^1.9.3" - color-string "^1.6.0" - color@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" @@ -13476,6 +13500,14 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" +color@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.3.tgz#f79390b1b778e222ffbb54304d3dbeaef633f97f" + integrity sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA== + dependencies: + color-convert "^3.1.3" + color-string "^2.1.3" + colord@^2.9.3: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" @@ -13501,14 +13533,6 @@ colors@^1.4.0: resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== -colorspace@1.1.x: - version "1.1.4" - resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" - integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== - dependencies: - color "^3.1.3" - text-hex "1.0.x" - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -13983,10 +14007,10 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: dependencies: uncrypto "^0.1.3" -crossws@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.4.4.tgz#d62574bcc6de75f0e45fe08b5133d9ba8436a30c" - integrity sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg== +crossws@^0.4.4, crossws@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.4.5.tgz#e300fec909cd93fe377a1cee84f6813c9c786edf" + integrity sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA== crypto-random-string@^2.0.0: version "2.0.0" @@ -15651,14 +15675,15 @@ env-paths@^2.2.0: resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== -env-runner@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/env-runner/-/env-runner-0.1.6.tgz#b2acc95c00bc9a00457d7ad5220f10bd75595b2d" - integrity sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA== +env-runner@^0.1.6, env-runner@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/env-runner/-/env-runner-0.1.7.tgz#ab26aa711cf195c9d8e158b6e864291fbd8d202e" + integrity sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA== dependencies: crossws "^0.4.4" - httpxy "^0.3.1" - srvx "^0.11.9" + exsolve "^1.0.8" + httpxy "^0.5.0" + srvx "^0.11.13" err-code@^2.0.2: version "2.0.3" @@ -18363,13 +18388,13 @@ h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5: ufo "^1.6.3" uncrypto "^0.1.3" -h3@^2.0.1-rc.16: - version "2.0.1-rc.17" - resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.17.tgz#86fb5a5261a38f59e0fb3384581e345285be3b61" - integrity sha512-9rPJs68qMj7HJH78z7uSIAw6rl3EElLdVSirTeAf6B5ogwiFVIr9AKMMS4u00Gp8DYIPnnjtw3ZWN7EkYcPBrQ== +h3@^2.0.1-rc.13, h3@^2.0.1-rc.16, h3@^2.0.1-rc.20: + version "2.0.1-rc.20" + resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.20.tgz#51050db30afb0b6e69718d88cccc23666fbe8039" + integrity sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg== dependencies: rou3 "^0.8.1" - srvx "^0.11.12" + srvx "^0.11.13" handle-thing@^2.0.0: version "2.0.1" @@ -18728,10 +18753,10 @@ hookable@^5.5.3: resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== -hookable@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/hookable/-/hookable-6.0.1.tgz#be950f1b8ef38af24d4354657e9e3590d2a5b5e6" - integrity sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw== +hookable@^6.0.1, hookable@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-6.1.1.tgz#825f966b4b426db2e622d94d7a31a70f196f9d2f" + integrity sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ== hosted-git-info@^5.0.0: version "5.1.0" @@ -18956,10 +18981,10 @@ httpxy@^0.1.7: resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.1.7.tgz#02d02e57eda10e8b5c0e3f9f10860e3d7a5991a4" integrity sha512-pXNx8gnANKAndgga5ahefxc++tJvNL87CXoRwxn1cJE2ZkWEojF3tNfQIEhZX/vfpt+wzeAzpUI4qkediX1MLQ== -httpxy@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.3.1.tgz#da1bb1a4a26cb44d7835a9297c845a0e06372083" - integrity sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw== +httpxy@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/httpxy/-/httpxy-0.5.0.tgz#a9c53543760dee498611827a464e56e14639c0d0" + integrity sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg== human-signals@^1.1.1: version "1.1.1" @@ -22606,10 +22631,10 @@ next@14.2.35: "@next/swc-win32-ia32-msvc" "14.2.33" "@next/swc-win32-x64-msvc" "14.2.33" -nf3@^0.3.11: - version "0.3.13" - resolved "https://registry.yarnpkg.com/nf3/-/nf3-0.3.13.tgz#9dfbc08158c9f12583ebf82bd89c97dc362b7df1" - integrity sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw== +nf3@^0.3.11, nf3@^0.3.16: + version "0.3.16" + resolved "https://registry.yarnpkg.com/nf3/-/nf3-0.3.16.tgz#36e3d1bb36d98ee78b47627b7967864c2ea01720" + integrity sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw== ng-packagr@^14.2.2: version "14.3.0" @@ -22676,6 +22701,26 @@ nitro@^3.0.260311-beta: unenv "^2.0.0-rc.24" unstorage "^2.0.0-alpha.6" +nitro@^3.0.260415-beta: + version "3.0.260415-beta" + resolved "https://registry.yarnpkg.com/nitro/-/nitro-3.0.260415-beta.tgz#2a40c38c9a2d6ae14b259ebe78e5ce1142d0c5e5" + integrity sha512-J0ntJERWtIdvweZdmkCiF8eOFvP9fIAJR2gpeIDrHbAlYavK41WQfADo/YoZ/LF7RMTZBiPaH/pt2s/nPru9Iw== + dependencies: + consola "^3.4.2" + crossws "^0.4.5" + db0 "^0.3.4" + env-runner "^0.1.7" + h3 "^2.0.1-rc.20" + hookable "^6.1.1" + nf3 "^0.3.16" + ocache "^0.1.4" + ofetch "^2.0.0-alpha.3" + ohash "^2.0.11" + rolldown "^1.0.0-rc.15" + srvx "^0.11.15" + unenv "^2.0.0-rc.24" + unstorage "^2.0.0-alpha.7" + nitropack@^2.11.10, nitropack@^2.11.13, nitropack@^2.13.1: version "2.13.1" resolved "https://registry.yarnpkg.com/nitropack/-/nitropack-2.13.1.tgz#70be1b14eb0d2fed9c670fe7cfff3741c384ecf2" @@ -23416,7 +23461,7 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -ocache@^0.1.2: +ocache@^0.1.2, ocache@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/ocache/-/ocache-0.1.4.tgz#d4a71be84ceaeb5685cc0128c197d44713dda9a7" integrity sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ== @@ -26541,29 +26586,29 @@ roarr@^7.0.4: safe-stable-stringify "^2.4.1" semver-compare "^1.0.0" -rolldown@^1.0.0-rc.8: - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.10.tgz#41c55e52d833c52c90131973047250548e35f2bf" - integrity sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA== +rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8: + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.15.tgz#ea3526443b2dbe834e9f8f6c1fde6232ec687170" + integrity sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g== dependencies: - "@oxc-project/types" "=0.120.0" - "@rolldown/pluginutils" "1.0.0-rc.10" + "@oxc-project/types" "=0.124.0" + "@rolldown/pluginutils" "1.0.0-rc.15" optionalDependencies: - "@rolldown/binding-android-arm64" "1.0.0-rc.10" - "@rolldown/binding-darwin-arm64" "1.0.0-rc.10" - "@rolldown/binding-darwin-x64" "1.0.0-rc.10" - "@rolldown/binding-freebsd-x64" "1.0.0-rc.10" - "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.10" - "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.10" - "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.10" - "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.10" - "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.10" - "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.10" - "@rolldown/binding-linux-x64-musl" "1.0.0-rc.10" - "@rolldown/binding-openharmony-arm64" "1.0.0-rc.10" - "@rolldown/binding-wasm32-wasi" "1.0.0-rc.10" - "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.10" - "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.10" + "@rolldown/binding-android-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-x64" "1.0.0-rc.15" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.15" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.15" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.15" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.15" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.15" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.15" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.15" rollup-plugin-cleanup@^3.2.1: version "3.2.1" @@ -27887,10 +27932,10 @@ sqlstring@2.3.1: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.1.tgz#475393ff9e91479aea62dcaf0ca3d14983a7fb40" integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= -srvx@^0.11.12, srvx@^0.11.2, srvx@^0.11.9: - version "0.11.13" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.13.tgz#cc77a98cb9a459c34f75ee4345bd0eef9f613a54" - integrity sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw== +srvx@^0.11.13, srvx@^0.11.15, srvx@^0.11.2, srvx@^0.11.9: + version "0.11.15" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.15.tgz#51c08f993bb116f5821ec929a466a29e8d5c7b61" + integrity sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg== ssri@^9.0.0: version "9.0.1" @@ -29817,7 +29862,7 @@ unstorage@^1.16.0, unstorage@^1.17.4: ofetch "^1.5.1" ufo "^1.6.3" -unstorage@^2.0.0-alpha.6: +unstorage@^2.0.0-alpha.6, unstorage@^2.0.0-alpha.7: version "2.0.0-alpha.7" resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-2.0.0-alpha.7.tgz#803ea90176683bf2175bb01065cb07df6d65280a" integrity sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog== @@ -30876,12 +30921,12 @@ winston@3.13.0: winston-transport "^4.7.0" winston@^3.17.0: - version "3.17.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" - integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== + version "3.19.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.19.0.tgz#cc1d1262f5f45946904085cfffe73efb4b7a581d" + integrity sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA== dependencies: "@colors/colors" "^1.6.0" - "@dabh/diagnostics" "^2.0.2" + "@dabh/diagnostics" "^2.0.8" async "^3.2.3" is-stream "^2.0.0" logform "^2.7.0" From da03651aed9eac3bc8ef90e17e73a250df0c107f Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 23 Apr 2026 14:32:47 -0400 Subject: [PATCH 19/77] test(tanstackstart-react): add e2e variant for object-form tunnelRoute Covers the `{ path, allowedDsns }` shape of `tunnelRoute` end-to-end alongside the existing dynamic/static/custom variants, exercising the build-time serialization of `allowedDsns` into the generated virtual route module. Also refactors the `tunnelRoute` resolution in `vite.config.ts` from a nested ternary to a switch for readability. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tanstackstart-react/package.json | 11 +++++++++-- .../tanstackstart-react/tests/tunnel.test.ts | 8 +++++++- .../tanstackstart-react/vite.config.ts | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index ff18677ee970..eeb7c389e8af 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -12,12 +12,14 @@ "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", + "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test", - "test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom", + "test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom && E2E_TEST_TUNNEL_ROUTE_MODE=object pnpm build && pnpm test:assert:tunnel-object", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", - "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test" + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" }, "dependencies": { "@sentry/tanstackstart-react": "latest || *", @@ -59,6 +61,11 @@ "label": "tunnel-custom", "build-command": "pnpm test:build:tunnel-custom", "assert-command": "pnpm test:assert:tunnel-custom" + }, + { + "label": "tunnel-object", + "build-command": "pnpm test:build:tunnel-object", + "assert-command": "pnpm test:assert:tunnel-object" } ] } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts index 110d2df31d60..27f200b8ef62 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/tunnel.test.ts @@ -4,7 +4,13 @@ import { waitForError } from '@sentry-internal/test-utils'; const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); const expectedTunnelPathMatcher = - tunnelRouteMode === 'static' ? '/monitor' : tunnelRouteMode === 'custom' ? '/custom-monitor' : /^\/[a-z0-9]{8}$/; + tunnelRouteMode === 'static' + ? '/monitor' + : tunnelRouteMode === 'custom' + ? '/custom-monitor' + : tunnelRouteMode === 'object' + ? '/object-monitor' + : /^\/[a-z0-9]{8}$/; test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index ca46cd58d53e..2385d8aa5e93 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -20,7 +20,20 @@ const appTunnel = useManagedTunnelRoute ? '/custom-monitor' : 'http://localhost:3031/'; -const tunnelRoute = tunnelRouteMode === 'dynamic' ? true : tunnelRouteMode === 'static' ? '/monitor' : undefined; +function resolveTunnelRouteOption() { + switch (tunnelRouteMode) { + case 'dynamic': + return true; + case 'static': + return '/monitor'; + case 'object': + return { path: '/object-monitor', allowedDsns: [appDsn] }; + default: + return undefined; + } +} + +const tunnelRoute = resolveTunnelRouteOption(); export default defineConfig({ server: { From af60d117ccdc707882a37e2fa087abf84e7cad7f Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Thu, 23 Apr 2026 14:54:11 -0400 Subject: [PATCH 20/77] test(tanstackstart-react): drop tunnel chain from default test:assert The tunnel variants run independently through sentryTest.variants in CI, so the chained `test:assert` was redundant and slow to run locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test-applications/tanstackstart-react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index b5cc42172234..f689aba2d7e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -15,7 +15,7 @@ "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test", - "test:assert": "pnpm test:assert:proxy && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic pnpm build && pnpm test:assert:tunnel-generated && E2E_TEST_TUNNEL_ROUTE_MODE=static pnpm build && pnpm test:assert:tunnel-static && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build && pnpm test:assert:tunnel-custom && E2E_TEST_TUNNEL_ROUTE_MODE=object pnpm build && pnpm test:assert:tunnel-object", + "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", From 358a49b5bd5e2f35e7d5a734da7beb0b2a690e87 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 02:19:30 -0400 Subject: [PATCH 21/77] fix(tests): Remove nitro canary test job (#20473) There is no stable stream of Nitro 3 canary builds yet, so no point in having this. I will add it back once we have a stable stream of builds, likely after the full v3 release. I probably had it at one point then didn't remove it properly before merging https://github.com/getsentry/sentry-javascript/pull/19224 Closes #20472 --- .github/workflows/canary.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 5b16ebe112e2..e28d6988d9a1 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -120,9 +120,6 @@ jobs: - test-application: 'nestjs-microservices' build-command: 'test:build-latest' label: 'nestjs-microservices (latest)' - - test-application: 'nitro-3' - build-command: 'test:build-canary' - label: 'nitro-3 (canary)' steps: - name: Check out current commit From ec2c3d722a263a82d1ca4942adfef1ef798e6ff2 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 08:50:43 +0200 Subject: [PATCH 22/77] chore(ci): Bump pnpm/action-setup to v5 and pin to commit SHA (#20462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump `pnpm/action-setup` from v4 to v5.0.0 - Pin to exact commit SHA ([`fc06bc1257f339d1d5d8b3a19a8cae5388b55320`](https://github.com/pnpm/action-setup/commit/fc06bc1257f339d1d5d8b3a19a8cae5388b55320)) instead of a mutable version tag for supply chain security Updated in `build.yml` (2 occurrences) and `canary.yml` (1 occurrence). ### Changelog (v4 → v5) **v5.0.0** — Updated the action to use Node.js 24 (resolves GHA deprecation warning for Node.js 20 actions). **v4.4.0** — Updated the action to use Node.js 24. **v4.3.0** — Store caching support, docs fixes, dependency cleanup. **v4.2.0** — Respects `.npmrc` registry configuration when fetching pnpm. **v4.1.0** — Added support for `package.yaml`. Full changelog: https://github.com/pnpm/action-setup/compare/v4.0.0...v5.0.0 ## Test plan - [ ] CI passes — E2E tests use pnpm for package installation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 4 ++-- .github/workflows/canary.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index add193a29d3b..c556cc3fe357 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -922,7 +922,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 9.15.9 - name: Set up Node @@ -1054,7 +1054,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 9.15.9 - name: Set up Node diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index e28d6988d9a1..1d9b11f6515e 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -126,7 +126,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: version: 9.15.9 - name: Set up Node From 39ad06b86783ffe2085cf740e69ed10dc4a9b624 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:25:54 +0200 Subject: [PATCH 23/77] test(hono): Add E2E tests for middleware spans (#20465) Patches `app.route` to also support middleware defined in Hono route groups. The Node instrumentation still produces too many spans, this will be fixed in another PR - one test is skipped for now. Closes https://github.com/getsentry/sentry-javascript/issues/20449 --- .../hono-4/playwright.config.ts | 5 +- .../src/route-groups/test-middleware.ts | 32 +- .../test-applications/hono-4/src/routes.ts | 12 +- .../hono-4/tests/constants.ts | 6 + .../hono-4/tests/errors.test.ts | 3 +- .../hono-4/tests/middleware.test.ts | 348 +++++++++++------- .../hono-4/tests/tracing.test.ts | 3 +- packages/hono/src/bun/middleware.ts | 4 +- packages/hono/src/cloudflare/middleware.ts | 4 +- packages/hono/src/node/middleware.ts | 4 +- packages/hono/src/shared/applyPatches.ts | 14 + packages/hono/src/shared/patchAppUse.ts | 54 +-- packages/hono/src/shared/patchRoute.ts | 47 +++ .../hono/src/shared/wrapMiddlewareSpan.ts | 60 +++ packages/hono/test/shared/patchAppUse.test.ts | 224 ++++++++++- 15 files changed, 600 insertions(+), 220 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts create mode 100644 packages/hono/src/shared/applyPatches.ts create mode 100644 packages/hono/src/shared/patchRoute.ts create mode 100644 packages/hono/src/shared/wrapMiddlewareSpan.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 74a21e10a349..d478c0734cb3 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -1,8 +1,5 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -type Runtime = 'cloudflare' | 'node' | 'bun'; - -const RUNTIME = (process.env.RUNTIME || 'cloudflare') as Runtime; +import { RUNTIME, type Runtime } from './tests/constants'; const testEnv = process.env.TEST_ENV; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts index 656fea319579..23004976ef08 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -1,10 +1,30 @@ import { Hono } from 'hono'; +import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; -const testMiddleware = new Hono(); +const middlewareRoutes = new Hono(); -testMiddleware.get('/named', c => c.json({ middleware: 'named' })); -testMiddleware.get('/anonymous', c => c.json({ middleware: 'anonymous' })); -testMiddleware.get('/multi', c => c.json({ middleware: 'multi' })); -testMiddleware.get('/error', c => c.text('should not reach')); +middlewareRoutes.get('/named', c => c.json({ middleware: 'named' })); +middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); +middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); +middlewareRoutes.get('/error', c => c.text('should not reach')); -export { testMiddleware }; +// Self-contained sub-app registering its own middleware +const subAppWithMiddleware = new Hono(); + +subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); +}); +subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); +subAppWithMiddleware.use('/error/*', failingMiddleware); + +// .all() produces the same method:'ALL' as .use() in Hono's route record. +// Wrapping it is harmless (onlyIfParent:true) — this route exists to prove that. +subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { + return c.json({ handler: 'all' }); +}); + +subAppWithMiddleware.route('/', middlewareRoutes); + +export { middlewareRoutes, subAppWithMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index 65d30787de64..0ff66a589b72 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import { testMiddleware } from './route-groups/test-middleware'; -import { middlewareA, middlewareB, failingMiddleware } from './middleware'; +import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { middlewareRoutes, subAppWithMiddleware } from './route-groups/test-middleware'; export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): void { app.get('/', c => { @@ -24,9 +24,7 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v throw new HTTPException(code, { message: `HTTPException ${code}` }); }); - // === Middleware === - // Middleware is registered on the main app (the patched instance) via `app.use()` - // TODO: In the future, we may want to support middleware registration on sub-apps (route groups) + // Root-app middleware: registered on the patched main app instance app.use('/test-middleware/named/*', middlewareA); app.use('/test-middleware/anonymous/*', async (c, next) => { c.header('X-Custom', 'anonymous'); @@ -34,6 +32,8 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v }); app.use('/test-middleware/multi/*', middlewareA, middlewareB); app.use('/test-middleware/error/*', failingMiddleware); + app.route('/test-middleware', middlewareRoutes); - app.route('/test-middleware', testMiddleware); + // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching + app.route('/test-subapp-middleware', subAppWithMiddleware); } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts new file mode 100644 index 000000000000..74905baee532 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts @@ -0,0 +1,6 @@ +export type Runtime = 'cloudflare' | 'node' | 'bun'; + +export const RUNTIME = (process.env.RUNTIME || 'cloudflare') as Runtime; +export const isNode = RUNTIME === 'node'; + +export const APP_NAME = 'hono-4'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index e85958e8328b..832204237946 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; - -const APP_NAME = 'hono-4'; +import { APP_NAME } from './constants'; test('captures error thrown in route handler', async ({ baseURL }) => { const errorWaiter = waitForError(APP_NAME, event => { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index a03398798756..967d639baa35 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -1,143 +1,233 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { type SpanJSON } from '@sentry/core'; - -const APP_NAME = 'hono-4'; - -test('creates a span for named middleware', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/named'; +import { APP_NAME, isNode } from './constants'; + +// In Node, @sentry/node/preload eagerly activates the OTel HonoInstrumentation, +// which wraps all Hono instance methods at construction time via WrappedHono. +const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; +const OTEL_ORIGIN = 'auto.http.otel.hono'; + +const SCENARIOS = [ + { + name: 'root app middleware', + prefix: '/test-middleware', + origin: MIDDLEWARE_ORIGIN, + }, + { + name: 'sub-app middleware (route group)', + prefix: '/test-subapp-middleware', + origin: isNode ? OTEL_ORIGIN : MIDDLEWARE_ORIGIN, + }, +] as const; + +for (const { name, prefix, origin } of SCENARIOS) { + test.describe(name, () => { + test('creates a span for named middleware', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/named`; + }); + + const response = await fetch(`${baseURL}${prefix}/named`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + const middlewareSpan = spans.find( + (span: { description?: string; op?: string }) => + span.op === 'middleware.hono' && span.description === 'middlewareA', + ); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + description: 'middlewareA', + op: 'middleware.hono', + origin, + status: 'ok', + }), + ); + + // @ts-expect-error timestamp is defined + const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; + expect(durationMs).toBeGreaterThanOrEqual(49); + }); + + test('creates a span for anonymous middleware', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/anonymous`; + }); + + const response = await fetch(`${baseURL}${prefix}/anonymous`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: '', + op: 'middleware.hono', + origin: MIDDLEWARE_ORIGIN, + status: 'ok', + }), + ); + }); + + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { + test.skip( + isNode, + 'Node double-instruments middleware (too many spans) - TODO: fix this in the SDK and re-enable the test', + ); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/multi`; + }); + + const response = await fetch(`${baseURL}${prefix}/multi`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + // Sort spans because they are in a different order in Node/Bun (OTel-based) + const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + + expect(middlewareSpans).toHaveLength(2); + expect(middlewareSpans[0]?.description).toBe('middlewareA'); + expect(middlewareSpans[1]?.description).toBe('middlewareB'); + + expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + + // middlewareA has a 50ms delay, middlewareB has a 60ms delay + // @ts-expect-error timestamp is defined + const aDurationMs = (middlewareSpans[0]?.timestamp - middlewareSpans[0]?.start_timestamp) * 1000; + // @ts-expect-error timestamp is defined + const bDurationMs = (middlewareSpans[1]?.timestamp - middlewareSpans[1]?.start_timestamp) * 1000; + expect(aDurationMs).toBeGreaterThanOrEqual(49); + expect(bDurationMs).toBeGreaterThanOrEqual(59); + }); + + test('captures error thrown in middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + event.exception?.values?.[0]?.value === 'Middleware error' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.hono' + ); + }); + + const response = await fetch(`${baseURL}${prefix}/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.middleware.hono', + }), + ); + }); + + test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/error/*`; + }); + + await fetch(`${baseURL}${prefix}/error`); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + // On the /error path only one middleware (failingMiddleware) is registered, + // so we can find the error span by status alone. On Node for sub-apps, the + // OTel layer wraps before patchRoute, so the function name may be lost in + // the patchRoute span — but the error status is always set. + const failingSpan = spans.find( + (span: SpanJSON) => span.op === 'middleware.hono' && span.status === 'internal_error', + ); + + expect(failingSpan).toBeDefined(); + expect(failingSpan?.status).toBe('internal_error'); + }); + + test('includes request data on error events from middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + }); + + await fetch(`${baseURL}${prefix}/error`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${prefix}/error`), + }), + ); + }); }); +} - const response = await fetch(`${baseURL}/test-middleware/named`); - expect(response.status).toBe(200); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - const middlewareSpan = spans.find( - (span: { description?: string; op?: string }) => - span.op === 'middleware.hono' && span.description === 'middlewareA', - ); - - expect(middlewareSpan).toEqual( - expect.objectContaining({ - description: 'middlewareA', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); - - // The middleware has a 50ms delay, so the span duration should be at least 50ms (0.05s) - // @ts-expect-error timestamp is defined - const durationMs = (middlewareSpan?.timestamp - middlewareSpan?.start_timestamp) * 1000; - expect(durationMs).toBeGreaterThanOrEqual(49); -}); +test.describe('.all() handler on sub-app (method ALL edge case)', () => { + test('Node: OTel wraps .all() and produces a hono span', async ({ baseURL }) => { + test.skip(!isNode, 'Node-specific: OTel wraps .all() at construction time'); -test('creates a span for anonymous middleware', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/anonymous'; - }); + const transactionPromise = waitForTransaction(APP_NAME, event => { + return ( + event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' + ); + }); - const response = await fetch(`${baseURL}/test-middleware/anonymous`); - expect(response.status).toBe(200); + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); - const transaction = await transactionPromise; - const spans = transaction.spans || []; + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); - expect(spans).toContainEqual( - expect.objectContaining({ - description: '', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); -}); + const transaction = await transactionPromise; + const spans = transaction.spans || []; -test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/multi'; + // On Node, OTel wraps .all() at construction time. Since the handler + // returns a Response, OTel classifies it as 'request_handler' (not + // middleware). patchRoute also wraps it but sees the anonymous OTel wrapper. + // Either way, the handler IS instrumented — verify any hono span exists. + const honoSpan = spans.find((span: SpanJSON) => span.op?.endsWith('.hono')); + expect(honoSpan).toBeDefined(); }); - const response = await fetch(`${baseURL}/test-middleware/multi`); - expect(response.status).toBe(200); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - // Sort spans because they are in a different order in Node/Bun (OTel-based) - const middlewareSpans = spans - .filter((span: SpanJSON) => span.op === 'middleware.hono' && span.origin === 'auto.middleware.hono') - .sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); - - expect(middlewareSpans).toHaveLength(2); - expect(middlewareSpans[0]?.description).toBe('middlewareA'); - expect(middlewareSpans[1]?.description).toBe('middlewareB'); - - // Both middleware spans share the same parent (siblings, not nested) - expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); - - // middlewareA has a 50ms delay, middlewareB has a 60ms delay - // @ts-expect-error timestamp is defined - const middlewareADuration = (middlewareSpans[0]?.timestamp - middlewareSpans[0]?.start_timestamp) * 1000; - // @ts-expect-error timestamp is defined - const middlewareBDuration = (middlewareSpans[1]?.timestamp - middlewareSpans[1]?.start_timestamp) * 1000; - expect(middlewareADuration).toBeGreaterThanOrEqual(49); - expect(middlewareBDuration).toBeGreaterThanOrEqual(59); -}); - -test('captures error thrown in middleware', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; + test('Bun/Cloudflare: patchRoute wraps .all() as middleware span', async ({ baseURL }) => { + test.skip(isNode, 'Bun/Cloudflare-specific: patchRoute is the sole wrapper'); + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return ( + event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' + ); + }); + + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); + + const transaction = await transactionPromise; + const spans = transaction.spans || []; + + // On Bun/Cloudflare, patchRoute is the sole wrapper and sees the original + // function name. It wraps .all() handlers identically to .use() middleware + // because both produce method:'ALL' in Hono's route record. + const allHandlerSpan = spans.find( + (span: SpanJSON) => span.op === 'middleware.hono' && span.description === 'allCatchAll', + ); + + expect(allHandlerSpan).toEqual( + expect.objectContaining({ + description: 'allCatchAll', + op: 'middleware.hono', + origin: MIDDLEWARE_ORIGIN, + status: 'ok', + }), + ); }); - - const response = await fetch(`${baseURL}/test-middleware/error`); - expect(response.status).toBe(500); - - const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( - expect.objectContaining({ - handled: false, - type: 'auto.middleware.hono', - }), - ); -}); - -test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { - const transactionPromise = waitForTransaction(APP_NAME, event => { - return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-middleware/error/*'; - }); - - await fetch(`${baseURL}/test-middleware/error`); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - const failingSpan = spans.find( - (span: { description?: string; op?: string }) => - span.op === 'middleware.hono' && span.description === 'failingMiddleware', - ); - - expect(failingSpan).toBeDefined(); - expect(failingSpan?.status).toBe('internal_error'); - expect(failingSpan?.origin).toBe('auto.middleware.hono'); -}); - -test('includes request data on error events from middleware', async ({ baseURL }) => { - const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; - }); - - await fetch(`${baseURL}/test-middleware/error`); - - const errorEvent = await errorPromise; - expect(errorEvent.request).toEqual( - expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/test-middleware/error'), - }), - ); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 58c73c6a8369..1c33943f38f8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; - -const APP_NAME = 'hono-4'; +import { APP_NAME } from './constants'; test('sends a transaction for the index route', async ({ baseURL }) => { const transactionWaiter = waitForTransaction(APP_NAME, event => { diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index fbcbffb15019..651cb4649378 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,8 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import type { Hono, MiddlewareHandler } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoBunOptions extends Options {} @@ -16,7 +16,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): MiddlewareHandler => init(options); - patchAppUse(app); + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 66151af2f87f..7a4f8a4d4139 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -3,7 +3,7 @@ import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { patchAppUse } from '../shared/patchAppUse'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoCloudflareOptions extends Options {} @@ -33,7 +33,7 @@ export function sentry( app as unknown as ExportedHandler, ); - patchAppUse(app); + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 2a85575db0d8..07d3c4ed2fa8 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,8 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import type { Hono, MiddlewareHandler } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; +import { applyPatches } from '../shared/applyPatches'; export interface HonoNodeOptions extends Options {} @@ -16,7 +16,7 @@ export const sentry = (app: Hono, options: HonoNodeOptions): MiddlewareHandler = init(options); - patchAppUse(app); + applyPatches(app); return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/hono/src/shared/applyPatches.ts new file mode 100644 index 000000000000..1b694ca7cfa5 --- /dev/null +++ b/packages/hono/src/shared/applyPatches.ts @@ -0,0 +1,14 @@ +import type { Env, Hono } from 'hono'; +import { patchAppUse } from '../shared/patchAppUse'; +import { patchRoute } from '../shared/patchRoute'; + +/** + * Applies necessary patches to the Hono app to ensure that Sentry can properly trace middleware and route handlers. + */ +export function applyPatches(app: Hono): void { + // `app.use` (instance own property) — wraps middleware at registration time on this instance. + patchAppUse(app); + + //`HonoBase.prototype.route` — wraps sub-app middleware at mount time so that route groups (`app.route('/prefix', subApp)`) are also instrumented. + patchRoute(app); +} diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts index f4bb9205c0f6..c0d620692278 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/hono/src/shared/patchAppUse.ts @@ -1,20 +1,8 @@ -import { - captureException, - getActiveSpan, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - startInactiveSpan, -} from '@sentry/core'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; - /** - * Patches `app.use` so that every middleware registered through it is automatically - * wrapped in a Sentry span. Supports both forms: `app.use(...handlers)` and `app.use(path, ...handlers)`. + * Patches the Hono app so that middleware is automatically traced as Sentry spans. */ export function patchAppUse(app: Hono): void { app.use = new Proxy(app.use, { @@ -31,41 +19,3 @@ export function patchAppUse(app: Hono): void { }, }); } - -/** - * Wraps a Hono middleware handler so that its execution is traced as a Sentry span. - * Explicitly parents each span under the root (transaction) span so that all middleware - * spans are siblings — even when OTel instrumentation introduces nested active contexts - * (onion order: A → B → handler → B → A would otherwise nest B under A). - */ -function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { - return async function sentryTracedMiddleware(context, next) { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - - const span = startInactiveSpan({ - name: handler.name || '', - op: 'middleware.hono', - onlyIfParent: true, - parentSpan: rootSpan, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN, - }, - }); - - try { - const result = await handler(context, next); - span.setStatus({ code: SPAN_STATUS_OK }); - return result; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, - }); - throw error; - } finally { - span.end(); - } - }; -} diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts new file mode 100644 index 000000000000..6e821d2af64a --- /dev/null +++ b/packages/hono/src/shared/patchRoute.ts @@ -0,0 +1,47 @@ +import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; +import type { WrappedFunction } from '@sentry/core'; +import type { Env, Hono } from 'hono'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; + +interface HonoBaseProto { + // oxlint-disable-next-line typescript/no-explicit-any + route: (path: string, app: Hono) => Hono; +} + +/** + * Patches `HonoBase.prototype.route` so that when a sub-app is mounted via `app.route('/prefix', subApp)`, its middleware handlers + * are retroactively wrapped in Sentry spans before the parent copies them. + * + * `route` lives on the prototype (unlike `use` which is a class field) + */ +export function patchRoute(app: Hono): void { + const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(app)) as HonoBaseProto; + if (!honoBaseProto || typeof honoBaseProto?.route !== 'function') { + return; + } + + if (getOriginalFunction(honoBaseProto.route as WrappedFunction)) { + return; + } + + const originalRoute = honoBaseProto.route; + + // oxlint-disable-next-line typescript/no-explicit-any + const patchedRoute = function (this: Hono, path: string, subApp: Hono): Hono { + if (subApp && Array.isArray(subApp.routes)) { + for (const route of subApp.routes) { + /* Internally, `app.use()` always registers with `method: 'ALL'` (via the constant `METHOD_NAME_ALL`), + * while `app.get()` / `.post()` / etc. use their respective uppercase method name. + * https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + */ + if (route.method === 'ALL' && typeof route.handler === 'function') { + route.handler = wrapMiddlewareWithSpan(route.handler); + } + } + } + return originalRoute.call(this, path, subApp); + }; + + markFunctionWrapped(patchedRoute as unknown as WrappedFunction, originalRoute as unknown as WrappedFunction); + honoBaseProto.route = patchedRoute; +} diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/hono/src/shared/wrapMiddlewareSpan.ts new file mode 100644 index 000000000000..b93e5de0bded --- /dev/null +++ b/packages/hono/src/shared/wrapMiddlewareSpan.ts @@ -0,0 +1,60 @@ +import { + captureException, + getActiveSpan, + getRootSpan, + getOriginalFunction, + markFunctionWrapped, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startInactiveSpan, + type WrappedFunction, +} from '@sentry/core'; +import { type MiddlewareHandler } from 'hono'; + +const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; + +/** + * Wraps a Hono middleware handler so that its execution is traced as a Sentry span. + * Explicitly parents each span under the root (transaction) span so that all middleware + * spans are siblings — even when OTel instrumentation introduces nested active contexts + * (onion order: A → B → handler → B → A would otherwise nest B under A). + */ +export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + if (getOriginalFunction(handler as unknown as WrappedFunction)) { + return handler; + } + + const wrapped: MiddlewareHandler = async function sentryTracedMiddleware(context, next) { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const span = startInactiveSpan({ + name: handler.name || '', + op: 'middleware.hono', + onlyIfParent: true, + parentSpan: rootSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN, + }, + }); + + try { + const result = await handler(context, next); + span.setStatus({ code: SPAN_STATUS_OK }); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, + }); + throw error; + } finally { + span.end(); + } + }; + + markFunctionWrapped(wrapped as unknown as WrappedFunction, handler as unknown as WrappedFunction); + return wrapped; +} diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index 0482d3569c84..84dd510113e1 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,7 +1,8 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { patchAppUse } from '../../src/shared/patchAppUse'; +import { patchRoute } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -18,11 +19,18 @@ vi.mock('@sentry/core', async () => { const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + describe('patchAppUse (middleware spans)', () => { beforeEach(() => { vi.clearAllMocks(); }); + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); patchAppUse(app); @@ -156,22 +164,212 @@ describe('patchAppUse (middleware spans)', () => { expect(fakeApp._capturedThis).toBe(fakeApp); }); - // todo: support sub-app (Hono route groups) patching in the future - it('does not wrap middleware on sub-apps (instance-level patching limitation)', async () => { - const app = new Hono(); - patchAppUse(app); + describe('route() patching (sub-app / route group support)', () => { + beforeEach(() => { + honoBaseProto.route = originalRoute; + }); - // Route Grouping: https://hono.dev/docs/api/routing#grouping - const subApp = new Hono(); - subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { - await next(); + it('wraps middleware on sub-apps mounted via route()', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); }); - subApp.get('/', () => new Response('sub')); - app.route('/sub', subApp); + it('does not wrap route handlers (only method ALL from use())', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + app.get('/', () => new Response('ok')); + + // Mount the main app as a sub-app of another app (contrived but tests the guard) + const parent = new Hono(); + parent.route('/', app); + + await parent.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); + }); + + it('does not patch route() twice when patchRoute is called multiple times', () => { + const app1 = new Hono(); + patchRoute(app1); + + const patchedRoute = honoBaseProto.route; + + const app2 = new Hono(); + patchRoute(app2); + + expect(honoBaseProto.route).toBe(patchedRoute); + }); + + it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { + const app = new Hono(); + patchRoute(app); + + // oxlint-disable-next-line typescript/no-explicit-any + const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; + expect(sentryOriginal).toBe(originalRoute); + }); + + it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/admin/dashboard', () => new Response('dashboard')); - await app.fetch(new Request('http://localhost/sub')); + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/admin/dashboard')); - expect(startInactiveSpanMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); + }); + + it('also wraps .all() handlers on sub-apps (same method: ALL in route record)', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.all('/catch-all', async function allHandler() { + return new Response('catch-all'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/catch-all')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'allHandler' })); + }); + + it('wraps mixed .use() and .all() handlers on the same sub-app', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use(async function mw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.all('/wildcard', async function allRoute() { + return new Response('wildcard'); + }); + subApp.get('/specific', () => new Response('specific')); + + app.route('/mixed', subApp); + await app.fetch(new Request('http://localhost/mixed/wildcard')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('mw'); + expect(spanNames).toContain('allRoute'); + }); + + it('does not wrap .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const innerSub = new Hono(); + innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + innerSub.get('/', () => new Response('inner')); + + const outerSub = new Hono(); + outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + outerSub.route('/inner', innerSub); + + app.route('/outer', outerSub); + await app.fetch(new Request('http://localhost/outer/inner')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('outerMiddleware'); + expect(spanNames).toContain('innerMiddleware'); + }); + + it('handles sub-app with multiple path-targeted middleware for different paths', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/a/test', () => new Response('a')); + subApp.get('/b/test', () => new Response('b')); + + app.route('/sub', subApp); + + // Hit path /a — only mwForA should fire + await app.fetch(new Request('http://localhost/sub/a/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); + + startInactiveSpanMock.mockClear(); + + // Hit path /b — only mwForB should fire + await app.fetch(new Request('http://localhost/sub/b/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); + }); }); }); From 64fc5b999c8a5cbb21a3a49bd8c5b5903fa2f826 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 04:00:23 -0400 Subject: [PATCH 24/77] fix(e2e/tanstackstart-react): pin @tanstack/start-plugin-core to unblock CI (#20482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@tanstack/start-plugin-core@1.168.0` was published at `2026-04-24T01:28Z` and broke the `tanstackstart-react` E2E build on every branch 😡 I had a clanker pin it temporarily, to unblock the current branches. Co-authored-by: Claude Opus 4.7 (1M context) --- .../test-applications/tanstackstart-react/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index f689aba2d7e2..7e78493e4afc 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -45,6 +45,11 @@ "volta": { "extends": "../../package.json" }, + "pnpm": { + "overrides": { + "@tanstack/start-plugin-core": "1.167.35" + } + }, "sentryTest": { "variants": [ { From 57c02878ca46efc71219bcc356203926ce140f23 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 24 Apr 2026 10:27:45 +0200 Subject: [PATCH 25/77] feat(browser): Add support for streamed spans in `httpContextIntegration` (#20464) This PR adds span processing support for the `httpContextIntegration `: - register a `processSegmentSpan` callback on `httpContextIntegration ` that adds the attributes. All attributes are already registered in sentry conventions: https://getsentry.github.io/sentry-conventions/attributes/url/#url-full and https://getsentry.github.io/sentry-conventions/attributes/http/#http-request-header-key - adds a span streaming-specific integration test in addition to the already existing error/event-based integration test Closes https://github.com/getsentry/sentry-javascript/issues/20378 --- .size-limit.js | 18 ++++++------- .../integrations/httpContext-streamed/init.js | 9 +++++++ .../integrations/httpContext-streamed/test.ts | 27 +++++++++++++++++++ .../public-api/startSpan/streamed/test.ts | 8 ++++++ .../interactions-streamed/test.ts | 8 ++++++ .../navigation-streamed/test.ts | 8 ++++++ .../pageload-streamed/test.ts | 8 ++++++ .../browser/src/integrations/httpcontext.ts | 18 ++++++++++++- packages/core/src/index.ts | 1 + 9 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts diff --git a/.size-limit.js b/.size-limit.js index cad516a0a49a..e9e760d91526 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -59,7 +59,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '83 KB', + limit: '84 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -96,7 +96,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '100 KB', + limit: '101 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -138,7 +138,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '28 KB', + limit: '29 KB', }, // React SDK (ESM) { @@ -197,7 +197,7 @@ module.exports = [ name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '30 KB', + limit: '31 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', @@ -209,7 +209,7 @@ module.exports = [ name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '69 KB', + limit: '70 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -255,7 +255,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '88 KB', + limit: '89 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -283,21 +283,21 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '258.5 KB', + limit: '259 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '268 KB', + limit: '269 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '271.5 KB', + limit: '272 KB', }, // Next.js SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js new file mode 100644 index 000000000000..c69a872adc77 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration(), Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts new file mode 100644 index 000000000000..cb1e88072a9f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext-streamed/test.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('httpContextIntegration captures url, user-agent, and referer', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'pageload')); + + await page.goto(url, { referer: 'https://sentry.io/' }); + + const spans = await spansPromise; + + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + expect(pageloadSpan!.attributes?.['url.full']).toEqual({ type: 'string', value: expect.any(String) }); + expect(pageloadSpan!.attributes?.['http.request.header.user_agent']).toEqual({ + type: 'string', + value: expect.any(String), + }); + expect(pageloadSpan!.attributes?.['http.request.header.referer']).toEqual({ + type: 'string', + value: 'https://sentry.io/', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 7a70c832558f..ba4e492c9f26 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -179,6 +179,14 @@ sentryTest( type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts index f1b0882d2325..383ecade3530 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts @@ -52,6 +52,14 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'idleTimeout', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index 520a3d330bb9..c2dcad317a3f 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -81,6 +81,14 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, 'device.processor_count': { type: expect.stringMatching(/^(integer)|(double)$/), value: expect.any(Number), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index 6b09fcd0097d..2344e28c67d4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -74,6 +74,14 @@ sentryTest( type: 'string', value: expect.any(String), }, + 'http.request.header.user_agent': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.any(String), + }, // formerly known as 'hardwareConcurrency' 'device.processor_count': { type: expect.stringMatching(/^(integer)|(double)$/), diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 9517b2364e83..bc331ed1e0f8 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,4 +1,4 @@ -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; /** @@ -26,5 +26,21 @@ export const httpContextIntegration = defineIntegration(() => { headers, }; }, + processSegmentSpan(span) { + // if none of the information we want exists, don't bother + if (!WINDOW.navigator && !WINDOW.location && !WINDOW.document) { + return; + } + + const reqData = getHttpRequestData(); + + safeSetSpanJSONAttributes(span, { + // Coerce empty string to undefined so the helper's nullish check drops it, + // rather than writing an empty `url.full` attribute onto the span. + 'url.full': reqData.url || undefined, + 'http.request.header.user_agent': reqData.headers['User-Agent'], + 'http.request.header.referer': reqData.headers['Referer'], + }); + }, }; }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3f8c454e997..7f112bd4c95b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,7 @@ export { createCheckInEnvelope } from './checkin'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { withStreamedSpan } from './tracing/spans/beforeSendSpan'; export { isStreamedBeforeSendSpanCallback } from './tracing/spans/beforeSendSpan'; +export { safeSetSpanJSONAttributes } from './tracing/spans/captureSpan'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; From a5480131ebc9e727993fa3208e90d36a83e94591 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 24 Apr 2026 10:28:21 +0200 Subject: [PATCH 26/77] feat(react-router): Clean up bogus `*` http.route attribute on segment spans (#20471) In span-streaming mode, the existing `processEvent` cleanup of bogus `*` `http.route` attributes doesn't run, so segment spans end up with the garbage route. Ports the same logic to the `processSegmentSpan` hook. Added unit tests for this (also for the event path), since the alternative would have been to add a separate streaming e2e test for react-router, which seems overkill to me. Closes #20361 --- .../server/integration/reactRouterServer.ts | 20 ++- .../integration/reactRouterServer.test.ts | 131 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts index e067ba06c830..6682c5b3516d 100644 --- a/packages/react-router/src/server/integration/reactRouterServer.ts +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -1,5 +1,5 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { generateInstrumentOnce, NODE_VERSION } from '@sentry/node'; import { ReactRouterInstrumentation } from '../instrumentation/reactRouter'; import { registerServerBuildGlobal } from '../serverBuild'; @@ -60,5 +60,23 @@ export const reactRouterServerIntegration = defineIntegration(() => { return event; }, + processSegmentSpan(span) { + // Express generates bogus `*` routes for data loaders, which we want to remove here + // we cannot do this earlier because some OTEL instrumentation adds this at some unexpected point + const attributes = span.attributes; + if (attributes?.[ATTR_HTTP_ROUTE] !== '*') { + return; + } + + const origin = attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; + const isInstrumentationApiOrigin = typeof origin === 'string' && origin.includes('instrumentation_api'); + + // For instrumentation_api, always clean up bogus `*` route since we set better names + // For legacy, only clean up if the name has been adjusted (not METHOD *) + if (isInstrumentationApiOrigin || !span.name?.endsWith(' *')) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[ATTR_HTTP_ROUTE]; + } + }, }; }); diff --git a/packages/react-router/test/server/integration/reactRouterServer.test.ts b/packages/react-router/test/server/integration/reactRouterServer.test.ts index 096095984eec..b97d6403bd18 100644 --- a/packages/react-router/test/server/integration/reactRouterServer.test.ts +++ b/packages/react-router/test/server/integration/reactRouterServer.test.ts @@ -1,3 +1,5 @@ +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type { Client, Event, EventType, StreamedSpanJSON } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ReactRouterInstrumentation } from '../../../src/server/instrumentation/reactRouter'; import { reactRouterServerIntegration } from '../../../src/server/integration/reactRouterServer'; @@ -98,4 +100,133 @@ describe('reactRouterServerIntegration', () => { expect(ReactRouterInstrumentation).toHaveBeenCalledTimes(1); expect(registerServerBuildGlobalSpy).toHaveBeenCalledTimes(1); }); + + describe('processEvent', () => { + const client = {} as Client; + const hint = {}; + + it('preserves http.route when it is not "*"', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET /users/:id', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '/users/:id' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBe('/users/:id'); + }); + + it('deletes bogus "*" route when origin is instrumentation_api', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET *', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.instrumentation_api', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('deletes bogus "*" route when legacy origin and transaction name was renamed', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET /api/users', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('keeps "*" when legacy origin and transaction name still ends with " *"', () => { + const integration = reactRouterServerIntegration(); + const event = { + type: 'transaction' as EventType, + transaction: 'GET *', + contexts: { + trace: { + data: { [ATTR_HTTP_ROUTE]: '*' }, + origin: 'auto.http.otel.http', + }, + }, + } as unknown as Event; + + integration.processEvent!(event, hint, client); + + expect(event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE]).toBe('*'); + }); + }); + + describe('processSegmentSpan', () => { + const client = {} as Client; + + it('preserves http.route when it is not "*"', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET /users/:id', + attributes: { [ATTR_HTTP_ROUTE]: '/users/:id', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBe('/users/:id'); + }); + + it('deletes bogus "*" route when origin is instrumentation_api', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET *', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.instrumentation_api' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('deletes bogus "*" route when legacy origin and span name was renamed', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET /api/users', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBeUndefined(); + }); + + it('keeps "*" when legacy origin and span name still ends with " *"', () => { + const integration = reactRouterServerIntegration(); + const span = { + name: 'GET *', + attributes: { [ATTR_HTTP_ROUTE]: '*', 'sentry.origin': 'auto.http.otel.http' }, + } as unknown as StreamedSpanJSON; + + integration.processSegmentSpan!(span, client); + + expect(span.attributes?.[ATTR_HTTP_ROUTE]).toBe('*'); + }); + }); }); From e2d35a20e4a1e1b72b246904e5373f68c09e4837 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 10:46:13 +0200 Subject: [PATCH 27/77] fix(core): Ensure ip address headers are stripped when lower case (#20484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was flagged by a claude security review and makes sense IMHO, we should make sure to also strip IP headers when they are lower case. While looking at that I noticed we have no tests at all for this rather critical thing 😬 so I added some here. --- packages/core/src/integrations/requestdata.ts | 14 +- .../test/lib/integrations/requestdata.test.ts | 604 ++++++++++++++++++ 2 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/lib/integrations/requestdata.test.ts diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index a72fbed70d7e..9ff6033ed7a2 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -106,12 +106,16 @@ function extractNormalizedRequestData( delete (headers as { cookie?: string }).cookie; } - // Remove IP headers in case IP data should not be included in the event + // Remove IP headers in case IP data should not be included in the event. + // Match case-insensitively — same as getClientIPAddress — so lowercase keys are stripped too. if (!include.ip) { - ipHeaderNames.forEach(ipHeaderName => { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (headers as Record)[ipHeaderName]; - }); + const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ipHeaderNamesLower.has(key.toLowerCase())) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (headers as Record)[key]; + } + } } } diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts new file mode 100644 index 000000000000..df8e8d4d8766 --- /dev/null +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, it } from 'vitest'; +import type { Client } from '../../../src/client'; +import { requestDataIntegration } from '../../../src/integrations/requestdata'; +import type { Event } from '../../../src/types-hoist/event'; +import { ipHeaderNames } from '../../../src/vendor/getIpAddress'; + +function mockClient(sendDefaultPii: boolean | undefined): Client { + return { + getOptions: () => ({ sendDefaultPii: sendDefaultPii as boolean | undefined }), + } as unknown as Client; +} + +function baseEvent(overrides: Partial = {}): Event { + return { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + 'CF-Connecting-IP': '10.0.0.2', + }, + }, + }, + ...overrides, + }; +} + +/** Rich normalized request (Cookie header only — tests `parseCookie` path). */ +function richNormalizedRequest() { + return { + method: 'POST', + url: 'https://example.com/items?q=1', + query_string: 'q=1', + data: { body: 'payload' }, + headers: { + Host: 'example.com', + cookie: 'session=from-header', + 'X-Forwarded-For': '192.168.1.1', + 'X-Custom': 'keep', + }, + }; +} + +describe('requestDataIntegration', () => { + describe('IP-related headers on event.request', () => { + it('removes known IP headers from event.request.headers when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + }); + }); + + it('removes every ipHeaderNames entry when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const headers: Record = { Host: 'example.com', 'X-Other': 'keep-me' }; + for (const name of ipHeaderNames) { + headers[name] = '203.0.113.1'; + } + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Other': 'keep-me', + }); + }); + + it('keeps IP headers on event.request.headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + 'CF-Connecting-IP': '10.0.0.2', + }); + }); + + it('keeps IP headers when include.ip is true even if sendDefaultPii is false', () => { + const integration = requestDataIntegration({ include: { ip: true } }); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers?.['X-Forwarded-For']).toBe('192.168.1.1'); + }); + + it('strips IP headers when include.ip is false even if sendDefaultPii is true', () => { + const integration = requestDataIntegration({ include: { ip: false } }); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ Host: 'example.com' }); + }); + + it('removes every ipHeaderNames entry when keys use lowercase spelling and sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const headers: Record = { host: 'example.com', 'x-other': 'keep-me' }; + for (const name of ipHeaderNames) { + headers[name.toLowerCase()] = '203.0.113.1'; + } + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + host: 'example.com', + 'x-other': 'keep-me', + }); + }); + + it('keeps lowercase IP headers on event.request.headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + host: 'example.com', + 'x-forwarded-for': '192.168.1.1', + 'cf-connecting-ip': '10.0.0.2', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + host: 'example.com', + 'x-forwarded-for': '192.168.1.1', + 'cf-connecting-ip': '10.0.0.2', + }); + }); + }); + + describe('user.ip_address', () => { + it('does not set user.ip_address when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.user?.ip_address).toBeUndefined(); + }); + + it('sets user.ip_address from request headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event = baseEvent(); + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('192.168.1.1'); + }); + + it('sets user.ip_address from lowercase IP headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/path', + headers: { + host: 'example.com', + 'x-forwarded-for': '192.168.1.9', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('192.168.1.9'); + }); + + it('sets user.ip_address from sdkProcessingMetadata.ipAddress when headers yield no IP', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + ipAddress: '198.51.100.7', + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.user?.ip_address).toBe('198.51.100.7'); + }); + + it('does not set user.ip_address from sdkProcessingMetadata when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + ipAddress: '198.51.100.7', + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.user?.ip_address).toBeUndefined(); + }); + }); + + describe('include.headers', () => { + it('omits event.request.headers when include.headers is false', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.request?.method).toBe('POST'); + expect(event.request?.url).toBe('https://example.com/items?q=1'); + }); + + it('with include.headers false and include.cookies true, parses cookies from the cookie header without exposing headers', () => { + const integration = requestDataIntegration({ + include: { headers: false, cookies: true }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'id=42' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.request?.cookies).toEqual({ id: '42' }); + }); + + it('with include.headers false, still sets user.ip_address from original headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { 'X-Forwarded-For': '192.0.2.1' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toBeUndefined(); + expect(event.user?.ip_address).toBe('192.0.2.1'); + }); + }); + + describe('include.cookies', () => { + it('removes the cookie header from event.request.headers when include.cookies is false', () => { + const integration = requestDataIntegration({ + include: { cookies: false }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { + Host: 'example.com', + cookie: 'secret=value', + 'X-Custom': 'ok', + }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + 'X-Custom': 'ok', + }); + }); + + it('omits event.request.cookies when include.cookies is false', () => { + const integration = requestDataIntegration({ + include: { cookies: false }, + }); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'a=b' }, + cookies: { sid: '1' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toBeUndefined(); + }); + + it('uses normalizedRequest.cookies when set', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + cookies: { session_id: 'abc' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ session_id: 'abc' }); + }); + + it('prefers normalizedRequest.cookies over the Cookie header when both are present', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'from=header' }, + cookies: { from: 'object' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ from: 'object' }); + }); + + it('parses the Cookie header when normalizedRequest.cookies is absent', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { cookie: 'a=1; b=two' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({ a: '1', b: 'two' }); + }); + + it('sets event.request.cookies to an empty object when include.cookies is true but no cookies are present', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.cookies).toEqual({}); + }); + }); + + describe('include.url', () => { + it('omits event.request.url when include.url is false', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.url).toBeUndefined(); + expect(event.request?.method).toBe('POST'); + }); + }); + + describe('include.query_string', () => { + it('omits event.request.query_string when include.query_string is false', () => { + const integration = requestDataIntegration({ include: { query_string: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.query_string).toBeUndefined(); + expect(event.request?.url).toBe('https://example.com/items?q=1'); + }); + }); + + describe('include.data', () => { + it('omits event.request.data when include.data is false', () => { + const integration = requestDataIntegration({ include: { data: false } }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.data).toBeUndefined(); + }); + }); + + describe('defaults and combined include options', () => { + it('with default include and sendDefaultPii true, copies method, url, query_string, data, headers, cookies, and user IP', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toEqual({ + method: 'POST', + url: 'https://example.com/items?q=1', + query_string: 'q=1', + data: { body: 'payload' }, + headers: { + Host: 'example.com', + cookie: 'session=from-header', + 'X-Forwarded-For': '192.168.1.1', + 'X-Custom': 'keep', + }, + cookies: { session: 'from-header' }, + }); + expect(event.user?.ip_address).toBe('192.168.1.1'); + }); + + it('with default include and sendDefaultPii false, keeps non-IP fields and strips IP from headers and user', () => { + const integration = requestDataIntegration(); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.headers).toEqual({ + Host: 'example.com', + cookie: 'session=from-header', + 'X-Custom': 'keep', + }); + expect(event.request?.cookies).toEqual({ session: 'from-header' }); + expect(event.user?.ip_address).toBeUndefined(); + }); + + it('can disable multiple include flags at once', () => { + const integration = requestDataIntegration({ + include: { + url: false, + query_string: false, + data: false, + cookies: false, + }, + }); + const event: Event = { + sdkProcessingMetadata: { normalizedRequest: richNormalizedRequest() }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.method).toBe('POST'); + expect(event.request?.headers?.Host).toBe('example.com'); + expect(event.request?.url).toBeUndefined(); + expect(event.request?.query_string).toBeUndefined(); + expect(event.request?.data).toBeUndefined(); + expect(event.request?.cookies).toBeUndefined(); + expect(event.request?.headers?.cookie).toBeUndefined(); + }); + }); + + describe('normalizedRequest absent', () => { + it('does not add event.request when it was undefined and there is no normalizedRequest', () => { + const integration = requestDataIntegration(); + const event: Event = { sdkProcessingMetadata: {} }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toBeUndefined(); + }); + + it('preserves existing event.request when there is no normalizedRequest', () => { + const integration = requestDataIntegration(); + const event: Event = { + request: { url: 'https://unchanged/' }, + sdkProcessingMetadata: {}, + }; + + integration.processEvent?.(event, {}, mockClient(true)); + + expect(event.request).toEqual({ url: 'https://unchanged/' }); + }); + }); + + describe('merging with existing event.request', () => { + it('merges new request fields into an existing event.request', () => { + const integration = requestDataIntegration(); + const event: Event = { + request: { env: { INTEGRATION: 'test' } }, + sdkProcessingMetadata: { + normalizedRequest: { + method: 'PUT', + url: 'https://example.com/r', + headers: { Host: 'example.com' }, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.env).toEqual({ INTEGRATION: 'test' }); + expect(event.request?.method).toBe('PUT'); + expect(event.request?.url).toBe('https://example.com/r'); + }); + + it('does not clear an existing event.request.url when include.url is false (object spread merge)', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const event: Event = { + request: { url: 'https://preserved/' }, + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/new', + headers: {}, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(event.request?.url).toBe('https://preserved/'); + expect(event.request?.method).toBe('GET'); + }); + }); + + it('does not mutate normalizedRequest.headers on the event (copy is used)', () => { + const integration = requestDataIntegration(); + const normalizedHeaders = { + Host: 'example.com', + 'X-Forwarded-For': '192.168.1.1', + }; + const event: Event = { + sdkProcessingMetadata: { + normalizedRequest: { + method: 'GET', + url: 'https://example.com/', + headers: normalizedHeaders, + }, + }, + }; + + integration.processEvent?.(event, {}, mockClient(false)); + + expect(normalizedHeaders['X-Forwarded-For']).toBe('192.168.1.1'); + expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); + }); +}); From fdeba1106d62b306446f1e4ab1cd07ecac00a82d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 24 Apr 2026 10:58:09 +0200 Subject: [PATCH 28/77] ref(browser): Use `safeSetSpanJSONAttributes` in cultureContext integration (#20481) Applies the same `safeSetSpanJSONAttributes` refactor from #20464 to the `cultureContext` integration's `processSegmentSpan` hook. This gives us in-place mutation instead of spread-and-reassign and nullish values are skipped (these get dropped during serialization anyways but doesn't hurt to just not assign them in the first place). Co-authored-by: Claude Opus 4.7 (1M context) --- packages/browser/src/integrations/culturecontext.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/culturecontext.ts b/packages/browser/src/integrations/culturecontext.ts index f2b705e3e9a9..cb4a1c975937 100644 --- a/packages/browser/src/integrations/culturecontext.ts +++ b/packages/browser/src/integrations/culturecontext.ts @@ -1,5 +1,5 @@ import type { CultureContext, IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; import { WINDOW } from '../helpers'; const INTEGRATION_NAME = 'CultureContext'; @@ -21,12 +21,11 @@ const _cultureContextIntegration = (() => { const culture = getCultureContext(); if (culture) { - span.attributes = { + safeSetSpanJSONAttributes(span, { 'culture.locale': culture.locale, 'culture.timezone': culture.timezone, 'culture.calendar': culture.calendar, - ...span.attributes, - }; + }); } }, }; From 5439cc5896dc15217dee9a68f9641fff02a20463 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 11:12:51 +0200 Subject: [PATCH 29/77] chore(build): Opt-out of nx analytics (#20487) This keeps being asked, we can opt-out of this for now. --- nx.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nx.json b/nx.json index 7cd807e089fb..48b0f8500b36 100644 --- a/nx.json +++ b/nx.json @@ -65,5 +65,6 @@ "tui": { "autoExit": true }, - "parallel": 5 + "parallel": 5, + "analytics": false } From 3bb172200776cc170cbb4be0056071c3ca9fcfeb Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 11:19:05 +0200 Subject: [PATCH 30/77] chore(ci): Streamline CI setup to split bundle, layer, tarball generation (#20396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Restructures the CI Build & Test pipeline to split the monolithic build step into parallel jobs and eliminate redundant work, reducing the critical path for test execution. ### Key changes **Split build into parallel jobs:** - The main `Build` job now only runs `build:transpile`, `build:types`, and `build:extension` (via `yarn build:ci`) - **Bundle builds** (`job_build_bundles`) run in a separate parallel job after `Build` — only when browser integration tests are affected - **Lambda layer build** (`job_build_layer`) runs in its own job — only when `@sentry/aws-serverless` or related E2E tests are affected - **Tarball packaging** (`job_build_tarballs`) is a dedicated job that replaces the old `job_e2e_prepare` step **Smarter build artifact handling:** - Replaced the hand-maintained `CACHED_BUILD_PATHS` list with a dynamic Nx-derived artifact path computation (`scripts/ci-print-build-artifact-paths.mjs`) that reads the Nx project graph to determine exactly which output directories to upload - Removed the tarball cache (`BUILD_CACHE_TARBALL_KEY`) — tarballs are now built once and passed as artifacts, not cached across runs - Each build stage uploads its own artifact (`build-output`, `build-bundle-output`, `build-layer-output`), so downstream jobs only download what they need **E2E test matrix generation moved into Build:** - The E2E test matrix (`ci:build-matrix`) is now computed inside the `Build` job instead of a separate `job_e2e_prepare` job - This removes `job_e2e_prepare` as a separate step and shaves off the sequential dependency **NX cache improvements:** - Moved NX cache directory from `.nxcache` to `.nx/cache` (includes `workspace-data` for better cache hits) - Changed from `actions/cache` (save+restore) to `actions/cache/save` in Build + `actions/cache/restore` in downstream jobs - Cache is now scoped per-PR (`${{ github.head_ref }}-${{ github.run_id }}`) for more predictable behavior **Other cleanups:** - `getTestMatrix.ts` → `getTestMatrix.mjs` — removed the `ts-node`, `glob`, and `yaml` dependencies by rewriting as plain ESM with `node:fs` and `node:util` - Fixed 1aws-serverless1 to include the lambda extension in the transpile step, to ensure it is properly cached and restored, and as we depend on this for the tarball as well. - Fixed `angular` dependency resolution for CI builds - Fixes `nestjs` types output for proper caching ### Before → After pipeline structure ``` BEFORE: Metadata → Build (transpile+types+bundles+layer+tarballs) → All tests AFTER: Metadata → Build (transpile+types only) ├── Build bundles ──→ Browser Playwright tests ├── Build layer ───→ (only when needed) ├── Build tarballs → E2E tests └── Unit/Integration/Remix tests (no extra build needed) ``` Somehow, cache restoration with nx seems hit or miss, it works sometimes but not other times. I opened an issue here: https://github.com/nrwl/nx/issues/35403 - fixing this would improve this by a couple minutes, so would be nice to get there... --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 327 ++++++++++++------ .github/workflows/flaky-test-detector.yml | 13 +- dev-packages/e2e-tests/README.md | 2 +- .../{getTestMatrix.ts => getTestMatrix.mjs} | 85 ++--- dev-packages/e2e-tests/package.json | 4 +- nx.json | 2 +- package.json | 5 +- packages/angular/package.json | 5 +- packages/aws-serverless/package.json | 23 +- packages/gatsby/package.json | 19 + packages/nestjs/package.json | 19 + scripts/ci-print-build-artifact-paths.mjs | 111 ++++++ 12 files changed, 418 insertions(+), 197 deletions(-) rename dev-packages/e2e-tests/lib/{getTestMatrix.ts => getTestMatrix.mjs} (73%) create mode 100644 scripts/ci-print-build-artifact-paths.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c556cc3fe357..f014114e7584 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,6 @@ concurrency: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - # WARNING: this disables cross os caching as ~ and - # github.workspace evaluate to differents paths CACHED_DEPENDENCY_PATHS: | ${{ github.workspace }}/node_modules ${{ github.workspace }}/packages/*/node_modules @@ -38,25 +36,23 @@ env: # DEPENDENCY_CACHE_KEY: can't be set here because we don't have access to yarn.lock - # WARNING: this disables cross os caching as ~ and - # github.workspace evaluate to differents paths - # packages/utils/cjs and packages/utils/esm: Symlinks to the folders inside of `build`, needed for tests - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dev-packages/*/build - ${{ github.workspace }}/packages/*/build - ${{ github.workspace }}/packages/*/lib - ${{ github.workspace }}/packages/ember/*.d.ts - ${{ github.workspace }}/packages/gatsby/*.d.ts - - BUILD_CACHE_TARBALL_KEY: tarball-${{ github.event.inputs.commit || github.sha }} - - # GH will use the first restore-key it finds that matches - # So it will start by looking for one from the same branch, else take the newest one it can find elsewhere - # We want to prefer the cache from the current develop branch, if we don't find any on the current branch - NX_CACHE_RESTORE_KEYS: | - nx-Linux-${{ github.ref }}-${{ github.event.inputs.commit || github.sha }} - nx-Linux-${{ github.ref }} - nx-Linux + # build-output artifact paths are computed in job_build (step output from + # yarn ci:print-build-artifact-paths — Nx merged outputs for build:transpile, and build:types). + + # upload-artifact globs drop the path through the first `*` (see upload-artifact + # README). Tarballs therefore land in the artifact as /*.tgz; download + # build-tarball-output into packages/ so they resolve to packages//*.tgz. + TARBALL_ARTIFACT_GLOB: packages/*/*.tgz + TARBALL_ARTIFACT_DOWNLOAD_PATH: ${{ github.workspace }}/packages + + BUILD_LAYER_PATH: ${{ github.workspace }}/packages/aws-serverless/build/aws/dist-serverless + + # Same glob / download split as TARBALL_ARTIFACT_*: upload-artifact strips the path through + # the first `*`, so bundle trees are stored as /build/bundles/...; download into packages/. + BUNDLE_ARTIFACT_GLOB: packages/*/build/bundles + BUNDLE_ARTIFACT_DOWNLOAD_PATH: ${{ github.workspace }}/packages + + NX_CACHE_KEY: nx-Linux-${{ github.head_ref || github.ref }}-${{ github.run_id }} # https://bsky.app/profile/joyeecheung.bsky.social/post/3lhy6o54fo22h # Apparently some of our CI failures are attributable to a corrupt v8 cache, causing v8 failures with: "Check failed: current == end_slot_index.". @@ -66,6 +62,7 @@ env: jobs: job_get_metadata: uses: ./.github/workflows/ci-metadata.yml + name: Get CI Metadata with: head_commit: ${{ github.event.inputs.commit || github.sha }} permissions: @@ -110,34 +107,51 @@ jobs: base: ${{ github.event.pull_request.base.sha }} head: ${{ env.HEAD_COMMIT }} - - name: NX cache - uses: actions/cache@v5 - # Disable cache when: - # - on release branches - # - when PR has `ci-skip-cache` label or on nightly builds - if: | - needs.job_get_metadata.outputs.is_release == 'false' && - needs.job_get_metadata.outputs.force_skip_cache == 'false' - with: - path: .nxcache - key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT || github.sha }} - # On develop branch, we want to _store_ the cache (so it can be used by other branches), but never _restore_ from it - restore-keys: - ${{needs.job_get_metadata.outputs.is_base_branch == 'false' && env.NX_CACHE_RESTORE_KEYS || - 'nx-never-restore'}} - - name: Build packages - run: yarn build + run: yarn build:ci + + - name: Compute build artifact paths from Nx + id: nx_build_paths + run: | + { + echo 'paths<> "$GITHUB_OUTPUT" + + - name: Store NX cache + uses: actions/cache/save@v5 + # Only cache this per-PR to speed up CI. + if: github.event_name == 'pull_request' + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: build-output - path: ${{ env.CACHED_BUILD_PATHS }} + path: ${{ steps.nx_build_paths.outputs.paths }} retention-days: 4 compression-level: 6 overwrite: true + - name: Determine which test applications should be run + id: matrix + run: + yarn --silent ci:build-matrix --base=${{ (github.event_name == 'pull_request' && + github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT + working-directory: dev-packages/e2e-tests + + - name: Determine which optional E2E test applications should be run + id: matrix-optional + run: + yarn --silent ci:build-matrix-optional --base=${{ (github.event_name == 'pull_request' && + github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT + working-directory: dev-packages/e2e-tests + outputs: dependency_cache_key: ${{ steps.install_dependencies.outputs.cache_key }} changed_node_integration: @@ -164,6 +178,92 @@ jobs: changed_browser_integration: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry-internal/browser-integration-tests') }} + changed_aws_serverless: + ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, + '@sentry/aws-serverless') }} + e2e-matrix: ${{ steps.matrix.outputs.matrix }} + e2e-matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} + + job_build_layer: + name: Build Lambda layer + needs: [job_get_metadata, job_build] + if: + needs.job_build.outputs.changed_aws_serverless == 'true' || contains(needs.job_build.outputs.e2e-matrix, + 'aws-serverless') || github.event_name != 'pull_request' + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v6 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Build Lambda layer + run: yarn build:layer + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} + retention-days: 4 + compression-level: 6 + overwrite: true + + job_build_bundles: + name: Build bundles + needs: [job_get_metadata, job_build] + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v6 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Build bundles + run: yarn build:bundle + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_GLOB }} + retention-days: 4 + compression-level: 6 + overwrite: true job_check_branches: name: Check PR branches @@ -182,7 +282,7 @@ jobs: job_size_check: name: Size Check - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] timeout-minutes: 15 runs-on: ubuntu-24.04 if: @@ -201,6 +301,11 @@ jobs: uses: ./.github/actions/restore-cache with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} - name: Check bundle sizes uses: ./dev-packages/size-limit-gh-action with: @@ -298,7 +403,7 @@ jobs: job_artifacts: name: Upload Artifacts - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_layer, job_build_bundles, job_build_tarballs] runs-on: ubuntu-24.04 # Build artifacts are only needed for releasing workflow. if: needs.job_get_metadata.outputs.is_release == 'true' @@ -316,8 +421,23 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Pack tarballs - run: yarn build:tarball + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 + with: + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} + + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + + - name: Restore build layer artifacts + uses: actions/download-artifact@v7 + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} - name: Archive artifacts uses: actions/upload-artifact@v7 @@ -478,7 +598,7 @@ jobs: name: Playwright ${{ matrix.bundle }}${{ matrix.project && matrix.project != 'chromium' && format(' {0}', matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04-large-js timeout-minutes: 25 @@ -543,6 +663,12 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + - name: Install Playwright uses: ./.github/actions/install-playwright with: @@ -581,7 +707,7 @@ jobs: job_browser_loader_tests: name: PW ${{ matrix.bundle }} Tests - needs: [job_get_metadata, job_build] + needs: [job_get_metadata, job_build, job_build_bundles] if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -611,6 +737,12 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Restore build bundle artifacts + uses: actions/download-artifact@v7 + with: + name: build-bundle-output + path: ${{ env.BUNDLE_ARTIFACT_DOWNLOAD_PATH }} + - name: Install Playwright uses: ./.github/actions/install-playwright with: @@ -662,7 +794,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Check for dts files that reference stuff in the temporary build folder run: | - if grep -r --include "*.d.ts" --exclude-dir ".nxcache" 'import("@sentry(-internal)?/[^/]*/build' .; then + if grep -r --include "*.d.ts" --exclude-dir ".nx" 'import("@sentry(-internal)?/[^/]*/build' .; then echo "Found illegal TypeScript import statement." exit 1 fi @@ -834,25 +966,17 @@ jobs: cd packages/remix yarn test:integration:ci - job_e2e_prepare: - name: Prepare E2E tests + job_build_tarballs: + name: Build tarballs # We want to run this if: # - The build job was successful, not skipped if: | always() && needs.job_build.result == 'success' needs: [job_get_metadata, job_build] - runs-on: ubuntu-24.04-large-js + runs-on: ubuntu-24.04 timeout-minutes: 15 - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} steps: - - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v6 - if: github.event_name == 'pull_request' - with: - ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v6 with: @@ -861,48 +985,39 @@ jobs: uses: actions/setup-node@v6 with: node-version-file: 'package.json' + - name: Restore NX cache + uses: actions/cache/restore@v5 + with: + path: | + .nx/cache + .nx/workspace-data + key: ${{ env.NX_CACHE_KEY }} + - name: Restore caches uses: ./.github/actions/restore-cache with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: NX cache - uses: actions/cache/restore@v5 - with: - path: .nxcache - key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} - # On develop branch, we want to _store_ the cache (so it can be used by other branches), but never _restore_ from it - restore-keys: ${{ env.NX_CACHE_RESTORE_KEYS }} - name: Build tarballs run: yarn build:tarball - - name: Stores tarballs in cache - uses: actions/cache/save@v5 + - name: Upload tarball artifacts + uses: actions/upload-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} - - - name: Determine which E2E test applications should be run - id: matrix - run: - yarn --silent ci:build-matrix --base=${{ (github.event_name == 'pull_request' && - github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT - working-directory: dev-packages/e2e-tests - - - name: Determine which optional E2E test applications should be run - id: matrix-optional - run: - yarn --silent ci:build-matrix-optional --base=${{ (github.event_name == 'pull_request' && - github.event.pull_request.base.sha) || '' }} >> $GITHUB_OUTPUT - working-directory: dev-packages/e2e-tests + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_GLOB }} + if-no-files-found: error + retention-days: 4 + compression-level: 6 + overwrite: true job_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test # We need to add the `always()` check here because the previous step has this as well :( # See: https://github.com/actions/runner/issues/2205 if: - always() && needs.job_e2e_prepare.result == 'success' && needs.job_e2e_prepare.outputs.matrix != '{"include":[]}' - needs: [job_get_metadata, job_build, job_e2e_prepare] + always() && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix !='{"include":[]}' + needs: [job_get_metadata, job_build, job_build_layer, job_build_tarballs] runs-on: ubuntu-24.04 timeout-minutes: 15 env: @@ -916,7 +1031,7 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' strategy: fail-fast: false - matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix) }} + matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v6 @@ -950,16 +1065,18 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Restore tarball cache - uses: actions/cache/restore@v5 - id: restore-tarball-cache + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} - - name: Build tarballs if not cached - if: steps.restore-tarball-cache.outputs.cache-hit != 'true' - run: yarn build:tarball + - name: Restore build layer artifacts + uses: actions/download-artifact@v7 + if: matrix.test-application == 'aws-serverless' || matrix.test-application == 'aws-serverless-layer' + with: + name: build-layer-output + path: ${{ env.BUILD_LAYER_PATH }} - name: Prepare e2e tests run: yarn test:prepare @@ -1030,10 +1147,10 @@ jobs: # We need to add the `always()` check here because the previous step has this as well :( # See: https://github.com/actions/runner/issues/2205 if: - always() && needs.job_get_metadata.outputs.is_release != 'true' && needs.job_e2e_prepare.result == 'success' && - needs.job_e2e_prepare.outputs.matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || + always() && needs.job_get_metadata.outputs.is_release != 'true' && needs.job_build_tarballs.result == 'success' && + needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' - needs: [job_get_metadata, job_build, job_e2e_prepare] + needs: [job_get_metadata, job_build, job_build_tarballs] runs-on: ubuntu-24.04 timeout-minutes: 15 env: @@ -1047,7 +1164,7 @@ jobs: E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' strategy: fail-fast: false - matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix-optional) }} + matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) @@ -1066,16 +1183,11 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Restore tarball cache - uses: actions/cache/restore@v5 - id: restore-tarball-cache + - name: Restore tarball artifacts + uses: actions/download-artifact@v7 with: - path: ${{ github.workspace }}/packages/*/*.tgz - key: ${{ env.BUILD_CACHE_TARBALL_KEY }} - - - name: Build tarballs if not cached - if: steps.restore-tarball-cache.outputs.cache-hit != 'true' - run: yarn build:tarball + name: build-tarball-output + path: ${{ env.TARBALL_ARTIFACT_DOWNLOAD_PATH }} - name: Prepare E2E tests run: yarn test:prepare @@ -1131,6 +1243,9 @@ jobs: needs: [ job_build, + job_build_bundles, + job_build_layer, + job_build_tarballs, job_browser_unit_tests, job_bun_unit_tests, job_deno_unit_tests, diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index c0a8f1f720b1..d74a82a667e5 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -10,11 +10,6 @@ on: env: HEAD_COMMIT: ${{ github.event.inputs.commit || github.sha }} - NX_CACHE_RESTORE_KEYS: | - nx-Linux-${{ github.ref }}-${{ github.event.inputs.commit || github.sha }} - nx-Linux-${{ github.ref }} - nx-Linux - # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value concurrency: @@ -36,16 +31,10 @@ jobs: with: node-version-file: 'package.json' cache: 'yarn' + - name: Install dependencies run: yarn install --ignore-engines --frozen-lockfile - - name: NX cache - uses: actions/cache/restore@v5 - with: - path: .nxcache - key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} - restore-keys: ${{ env.NX_CACHE_RESTORE_KEYS }} - - name: Build packages run: yarn build diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 15de0fd49ee0..2ff4b0665cd3 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -198,7 +198,7 @@ try { ``` Test apps in the folder `test-applications` will be automatically picked up by CI in the job `job_e2e_tests` (in `.github/workflows/build.yml`). -The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.ts`. +The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.mjs`. For each test app, CI checks its dependencies (and devDependencies) to see if any of them have changed in the current PR (based on nx affected projects). For example, if something is changed in the browser package, only E2E test apps that depend on browser will run, while others will be skipped. diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.mjs similarity index 73% rename from dev-packages/e2e-tests/lib/getTestMatrix.ts rename to dev-packages/e2e-tests/lib/getTestMatrix.mjs index 86a4bda3e701..b8be7af5f528 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -1,42 +1,21 @@ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import { sync as globSync } from 'glob'; -import * as path from 'path'; -import { dirname } from 'path'; -import { parseArgs } from 'util'; - -interface MatrixInclude { - /** The test application (directory) name. */ - 'test-application': string; - /** Optional override for the build command to run. */ - 'build-command'?: string; - /** Optional override for the assert command to run. */ - 'assert-command'?: string; - /** Optional label for the test run. If not set, defaults to value of `test-application`. */ - label?: string; -} +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; -interface PackageJsonSentryTestConfig { - /** If this is true, the test app is optional. */ - optional?: boolean; - /** Variant configs that should be run in non-optional test runs. */ - variants?: Partial[]; - /** Variant configs that should be run in optional test runs. */ - optionalVariants?: Partial[]; - /** Skip this test app for matrix generation. */ - skip?: boolean; -} +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** - * This methods generates a matrix for the GitHub Actions workflow to run the E2E tests. - * It checks which test applications are affected by the current changes in the PR and then generates a matrix + * Generates a matrix for the GitHub Actions workflow to run the E2E tests. + * Checks which test applications are affected by the current changes in the PR and then generates a matrix * including all test apps that have at least one dependency that was changed in the PR. * If no `--base=xxx` is provided, it will output all test applications. * * If `--optional=true` is set, it will generate a matrix of optional test applications only. * Otherwise, these will be skipped. */ -function run(): void { +function run() { const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -52,9 +31,7 @@ function run(): void { // eslint-disable-next-line no-console console.error(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`); - const testApplications = globSync('*/package.json', { - cwd: `${__dirname}/../test-applications`, - }).map(filePath => dirname(filePath)); + const testApplications = discoverTestApplicationDirs(); // For GitHub Action debugging (using stderr the 'matrix=...' output is not polluted) // eslint-disable-next-line no-console @@ -67,7 +44,7 @@ function run(): void { : testApplications; const optionalMode = optional === 'true'; - const includes: MatrixInclude[] = []; + const includes = []; includedTestApplications.forEach(testApp => { addIncludesForTestApp(testApp, includes, { optionalMode }); @@ -78,11 +55,18 @@ function run(): void { console.log(`matrix=${JSON.stringify({ include: includes })}`); } -function addIncludesForTestApp( - testApp: string, - includes: MatrixInclude[], - { optionalMode }: { optionalMode: boolean }, -): void { +/** Direct children of `test-applications/` that contain a `package.json` (replaces glob one-segment + package.json). */ +function discoverTestApplicationDirs() { + const appsRoot = path.join(__dirname, '..', 'test-applications'); + return fs + .readdirSync(appsRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .filter(name => fs.existsSync(path.join(appsRoot, name, 'package.json'))) + .sort(); +} + +function addIncludesForTestApp(testApp, includes, { optionalMode }) { const packageJson = getPackageJson(testApp); const shouldSkip = packageJson.sentryTest?.skip || false; @@ -108,7 +92,7 @@ function addIncludesForTestApp( }); } -function getSentryDependencies(appName: string): string[] { +function getSentryDependencies(appName) { const packageJson = getPackageJson(appName); const dependencies = { @@ -119,11 +103,7 @@ function getSentryDependencies(appName: string): string[] { return Object.keys(dependencies).filter(key => key.startsWith('@sentry')); } -function getPackageJson(appName: string): { - dependencies?: { [key: string]: string }; - devDependencies?: { [key: string]: string }; - sentryTest?: PackageJsonSentryTestConfig; -} { +function getPackageJson(appName) { const fullPath = path.resolve(__dirname, '..', 'test-applications', appName, 'package.json'); if (!fs.existsSync(fullPath)) { @@ -133,19 +113,14 @@ function getPackageJson(appName: string): { return JSON.parse(fs.readFileSync(fullPath, 'utf8')); } -run(); - -function getAffectedTestApplications( - testApplications: string[], - { base = 'develop', head }: { base?: string; head?: string }, -): string[] { +function getAffectedTestApplications(testApplications, { base = 'develop', head }) { const additionalArgs = [`--base=${base}`]; if (head) { additionalArgs.push(`--head=${head}`); } - let affectedProjects: string[] = []; + let affectedProjects = []; try { affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) .toString() @@ -201,7 +176,7 @@ function getAffectedTestApplications( return Array.from(testAppsToRun); } -function getChangedTestApps(base: string, head?: string): false | Set { +function getChangedTestApps(base, head) { const changedFiles = execSync(`git diff --name-only ${base}${head ? `..${head}` : ''} -- .`, { encoding: 'utf-8', }) @@ -214,7 +189,7 @@ function getChangedTestApps(base: string, head?: string): false | Set { // eslint-disable-next-line no-console console.error(`Changed files since ${base}${head ? `..${head}` : ''}: ${JSON.stringify(changedFiles)}`); - const changedTestApps: Set = new Set(); + const changedTestApps = new Set(); const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; for (const file of changedFiles) { @@ -233,3 +208,5 @@ function getChangedTestApps(base: string, head?: string): false | Set { return changedTestApps; } + +run(); diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 9e5cc91c58ea..b142f8c8ccd7 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -12,8 +12,8 @@ "test:prepare": "ts-node prepare.ts", "test:validate": "ts-node validate-packed-tarball-setup.ts", "clean": "rimraf tmp node_modules packed && yarn clean:test-applications && yarn clean:pnpm", - "ci:build-matrix": "ts-node ./lib/getTestMatrix.ts", - "ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true", + "ci:build-matrix": "node ./lib/getTestMatrix.mjs", + "ci:build-matrix-optional": "node ./lib/getTestMatrix.mjs --optional=true", "ci:copy-to-temp": "ts-node ./ciCopyToTemp.ts", "ci:pnpm-overrides": "ts-node ./ciPnpmOverrides.ts", "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,.astro,.output,pnpm-lock.yaml,.last-run.json,test-results,.angular,event-dumps}", diff --git a/nx.json b/nx.json index 48b0f8500b36..656f381d9b43 100644 --- a/nx.json +++ b/nx.json @@ -61,7 +61,7 @@ } }, "$schema": "./node_modules/nx/schemas/nx-schema.json", - "cacheDirectory": ".nxcache", + "cacheDirectory": ".nx/cache", "tui": { "autoExit": true }, diff --git a/package.json b/package.json index b7479a41a2f2..ce569de85547 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "private": true, "scripts": { - "build": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types build:bundle build:layer", + "build": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types build:bundle", + "build:ci": "node ./scripts/verify-packages-versions.js && nx run-many -t build:transpile build:types", "build:bundle": "nx run-many -t build:bundle", + "build:layer": "nx run-many -t build:layer", "build:dev": "nx run-many -t build:types build:transpile", "build:dev:filter": "nx run-many -t build:dev -p", "build:transpile": "nx run-many -t build:transpile", @@ -11,6 +13,7 @@ "build:dev:watch": "nx run-many -t build:dev:watch", "build:tarball": "run-s clean:tarballs build:tarballs", "build:tarballs": "nx run-many -t build:tarball", + "ci:print-build-artifact-paths": "node ./scripts/ci-print-build-artifact-paths.mjs", "changelog": "ts-node ./scripts/get-commit-list.ts", "generate-changelog": "ts-node ./scripts/generate-changelog.ts", "circularDepCheck": "nx run-many -t circularDepCheck", diff --git a/packages/angular/package.json b/packages/angular/package.json index 62ec94958ceb..9494c96264fc 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -71,10 +71,7 @@ "^build:types" ], "outputs": [ - "{projectRoot}/build/esm2015", - "{projectRoot}/build/fesm2015", - "{projectRoot}/build/fesm2020", - "{projectRoot}/build/*.d.ts" + "{projectRoot}/build" ] } } diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 99d97d5ecb33..0bc39e22634d 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -80,11 +80,12 @@ "@vercel/nft": "^1.3.0" }, "scripts": { - "build": "run-p build:transpile build:types build:extension && run-s build:layer", + "build": "run-p build:transpile build:types", "build:extension": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaExtension.ts", "build:layer": "rimraf build/aws && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", - "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:transpile": "run-s build:transpile:npm build:extension", + "build:transpile:npm": "rollup -c rollup.npm.config.mjs", "build:types": "run-s build:types:core build:types:downlevel", "build:types:core": "tsc -p tsconfig.types.json", "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", @@ -117,20 +118,10 @@ ], "outputs": [ "{projectRoot}/build/npm/esm", - "{projectRoot}/build/npm/cjs" - ] - }, - "build:extension": { - "inputs": [ - "production", - "^production" - ], - "dependsOn": [ - "^build:transpile" - ], - "outputs": [ + "{projectRoot}/build/npm/cjs", "{projectRoot}/build/lambda-extension" - ] + ], + "cache": true }, "build:layer": { "inputs": [ @@ -139,7 +130,7 @@ ], "dependsOn": [ "build:transpile", - "build:extension" + "build:types" ], "outputs": [ "{projectRoot}/build/aws" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index d57aabb667df..f21151738f7d 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -84,5 +84,24 @@ "volta": { "extends": "../../package.json" }, + "nx": { + "targets": { + "build:transpile": { + "inputs": [ + "production", + "^production" + ], + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/*.d.ts" + ], + "dependsOn": [ + "^build:transpile" + ], + "cache": true + } + } + }, "sideEffects": false } diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 95a47170c52b..803ffac74c92 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -85,5 +85,24 @@ "volta": { "extends": "../../package.json" }, + "nx": { + "targets": { + "build:types": { + "inputs": [ + "production", + "^production" + ], + "dependsOn": [ + "^build:types" + ], + "outputs": [ + "{projectRoot}/build/types", + "{projectRoot}/build/types-ts3.8", + "{projectRoot}/*.d.ts" + ], + "cache": true + } + } + }, "sideEffects": false } diff --git a/scripts/ci-print-build-artifact-paths.mjs b/scripts/ci-print-build-artifact-paths.mjs new file mode 100644 index 000000000000..690b995d78b2 --- /dev/null +++ b/scripts/ci-print-build-artifact-paths.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Prints multiline paths for actions/upload-artifact `path` (often wired via a prior step's + * `GITHUB_OUTPUT` `paths< yarn ci:print-build-artifact-paths + * (defaults to cwd when GITHUB_WORKSPACE is unset) + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = path.resolve(__dirname, '..'); +const graphPath = path.join(workspaceRoot, '.nx', 'ci-print-build-artifact-paths-graph.json'); + +const TARGETS = ['build:transpile', 'build:types']; + +fs.mkdirSync(path.dirname(graphPath), { recursive: true }); +execSync(`yarn nx graph --file="${graphPath}"`, { + cwd: workspaceRoot, + stdio: ['ignore', 'pipe', 'inherit'], +}); + +const { graph } = JSON.parse(fs.readFileSync(graphPath, 'utf8')); +try { + fs.unlinkSync(graphPath); +} catch { + // ignore +} + +/** @type {Map>} key = `${kind}\0${suffix}` */ +const groups = new Map(); + +for (const node of Object.values(graph.nodes)) { + const root = node.data?.root; + if (!root || (!root.startsWith('packages/') && !root.startsWith('dev-packages/'))) { + continue; + } + + const [kind, pkg] = root.split('/'); + if (!kind || !pkg) { + continue; + } + + const targets = node.data?.targets || {}; + for (const targetName of TARGETS) { + const outputs = targets[targetName]?.outputs; + if (!Array.isArray(outputs)) { + continue; + } + + for (const output of outputs) { + const rel = output.replace(/\{projectRoot\}/g, root).replace(/\\/g, '/'); + const prefix = `${kind}/${pkg}/`; + if (!rel.startsWith(prefix)) { + throw new Error(`Unexpected Nx output (missing project prefix): ${rel}`); + } + const suffix = rel.slice(prefix.length); + const key = `${kind}\0${suffix}`; + if (!groups.has(key)) { + groups.set(key, new Set()); + } + groups.get(key).add(pkg); + } + } +} + +const ws = (process.env.GITHUB_WORKSPACE || workspaceRoot).replace(/\\/g, '/'); +const lines = new Set(); + +// A glob like packages + star + slash + "build" matches every package's build tree, so we +// never emit that when several projects each declare a top-level {projectRoot}/build output. +function isUnsafeSharedTopLevelBuildSuffix(suffix, pkgCount) { + return pkgCount > 1 && !suffix.includes('/') && !/[?*]/.test(suffix) && suffix === 'build'; +} + +for (const [key, pkgSet] of groups) { + const [kind, suffix] = key.split('\0'); + const pkgs = [...pkgSet].sort((a, b) => a.localeCompare(b)); + const n = pkgs.length; + + if (n === 1) { + lines.add(`${ws}/${kind}/${pkgs[0]}/${suffix}`); + continue; + } + + if (isUnsafeSharedTopLevelBuildSuffix(suffix, n)) { + for (const pkg of pkgs) { + lines.add(`${ws}/${kind}/${pkg}/build`); + } + continue; + } + + lines.add(`${ws}/${kind}/*/${suffix}`); +} + +process.stdout.write([...lines].sort((a, b) => a.localeCompare(b)).join('\n')); +if (lines.size) { + process.stdout.write('\n'); +} From edd1867a45a4dd8bdbc2e05eea819df3813793fb Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 11:20:46 +0200 Subject: [PATCH 31/77] fix(nextjs): Ensure we do not match tunnel endpoints too broadly (#20488) This PR ensures we use the same matching logic for nextjs tunnel routes consistently. --- .../utils/dropMiddlewareTunnelRequests.ts | 3 +- .../src/common/utils/tunnelPathnameMatch.ts | 8 +++++ .../src/common/wrapMiddlewareWithSentry.ts | 3 +- packages/nextjs/test/config/wrappers.test.ts | 29 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/nextjs/src/common/utils/tunnelPathnameMatch.ts diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index ce54e8e25f85..fbbe8f704fbe 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -2,6 +2,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import { getClient, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core'; import { isSentryRequestSpan } from '@sentry/opentelemetry'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { isPathnameUnderSentryTunnelRoute } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -59,7 +60,7 @@ function isTunnelRouteSpan(spanAttributes: Record): boolean { // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = httpTarget.split('?')[0] || ''; - return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); + return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); } return false; diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts new file mode 100644 index 000000000000..9f107d33636c --- /dev/null +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -0,0 +1,8 @@ +/** + * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path + * (`tunnelPath` + `/...`). A plain `startsWith(tunnelPath)` is unsafe: e.g. tunnel + * `/api/t` must not match `/api/things`. + */ +export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { + return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); +} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 985354543a0d..d383837cbf17 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,6 +13,7 @@ import { withIsolationScope, } from '@sentry/core'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; +import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathnameMatch'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -36,7 +37,7 @@ export function wrapMiddlewareWithSentry( // Check if the current request matches the tunnel route if (req instanceof Request) { const url = new URL(req.url); - const isTunnelRequest = url.pathname.startsWith(tunnelRoute); + const isTunnelRequest = isPathnameUnderSentryTunnelRoute(url.pathname, tunnelRoute); if (isTunnelRequest) { // Create a simple response that mimics NextResponse.next() so we don't need to import internals here diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index c61c92026f60..7d5f4029bd94 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -193,4 +193,33 @@ describe('wrapMiddlewareWithSentry', () => { expect(origFunction).toHaveBeenCalledWith(mockRequest); expect(result).toBe(mockReturnValue); }); + + test('should not treat paths as tunnel when they only share a prefix with tunnelRoute', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + + const mockReturnValue = { status: 200 }; + const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request('https://example.com/api/things', { method: 'GET' }); + + const result = await wrappedOriginal(mockRequest); + + expect(origFunction).toHaveBeenCalledWith(mockRequest); + expect(result).toBe(mockReturnValue); + }); + + test('should skip processing for tunnel sub-paths under tunnelRoute', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + + const origFunction: EdgeRouteHandler = vi.fn(async () => ({ status: 200 })); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request('https://example.com/api/t/envelope?o=1'); + + const result = await wrappedOriginal(mockRequest); + + expect(origFunction).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + }); }); From dbf1af157b30f09ea56d9c2f77182fc735322d5e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 24 Apr 2026 11:29:57 +0200 Subject: [PATCH 32/77] fix(core): Filter more cookie names for PII (#20485) Raised by claude security review, this PR extends the list of cookie names we filter for PII reasons, covering more ground. This is always best effort so no guarantees, but this should extend the list of things we cover significantly. --- packages/core/src/utils/request.ts | 99 ++++++++++++++++---- packages/core/test/lib/utils/request.test.ts | 29 ++++++ 2 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 6aaceb8fc201..3f7477e6459b 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -149,6 +149,44 @@ const SENSITIVE_HEADER_SNIPPETS = [ 'cookie', ]; +/** + * Extra substrings matched only against individual Cookie / Set-Cookie **names** (not header names), + * so we can cover common session secrets that do not match {@link SENSITIVE_HEADER_SNIPPETS} + * (e.g. `connect.sid` does not contain `session`) without false positives on arbitrary HTTP headers. + * + * Cookie names are checked with the same `includes()` list as headers plus these entries; omit redundant + * cookie-only snippets that are already implied by a header match (e.g. `oauth` → `auth`, `id_token` → `token`, + * `next-auth` → `auth`). + */ +const SENSITIVE_COOKIE_NAME_SNIPPETS = [ + // Express / Connect default session cookie + '.sid', + // Opaque session ids (PHPSESSID, ASPSESSIONID*, BIGipServer*, *sessid*, …) + 'sessid', + // Laravel etc. "remember me" tokens + 'remember', + // OIDC / OAuth auxiliary (`oauth*` covered by header snippet `auth`) + 'oidc', + 'pkce', + 'nonce', + // RFC 6265bis high-security cookie name prefixes + '__secure-', + '__host-', + // Load balancer / CDN sticky-session cookies (opaque routing tokens) + 'awsalb', + 'awselb', + 'akamai', + // BaaS / IdP session cookies (names often omit "session") + '__stripe', + 'cognito', + 'firebase', + 'supabase', + 'sb-', + // Step-up / MFA cookies + 'mfa', + '2fa', +]; + const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user']; /** @@ -196,17 +234,23 @@ export function httpHeadersToSpanAttributes( const lowerCasedCookieKey = cookieKey.toLowerCase(); - addSpanAttribute( + addSpanAttribute({ spanAttributes, - lowerCasedHeaderKey, - lowerCasedCookieKey, - cookieValue, + headerKey: lowerCasedHeaderKey, + cookieKey: lowerCasedCookieKey, + value: cookieValue, sendDefaultPii, lifecycle, - ); + }); } } else { - addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii, lifecycle); + addSpanAttribute({ + spanAttributes, + headerKey: lowerCasedHeaderKey, + value, + sendDefaultPii, + lifecycle, + }); } }); } catch { @@ -220,15 +264,31 @@ function normalizeAttributeKey(key: string): string { return key.replace(/-/g, '_'); } -function addSpanAttribute( - spanAttributes: Record, - headerKey: string, - cookieKey: string, - value: string | string[] | undefined, - sendPii: boolean, - lifecycle: 'request' | 'response', -): void { - const headerValue = handleHttpHeader(cookieKey || headerKey, value, sendPii); +type AddSpanAttributeOptions = { + spanAttributes: Record; + /** Lowercased HTTP header name (e.g. `cookie`, `set-cookie`, `accept`). */ + headerKey: string; + /** + * Lowercased cookie name when this attribute comes from a parsed `Cookie` / `Set-Cookie` value. + * Omit for non-cookie headers; when present and non-empty, cookie-specific sensitivity rules apply. + */ + cookieKey?: string; + value: string | string[] | undefined; + sendDefaultPii: boolean; + lifecycle: 'request' | 'response'; +}; + +function addSpanAttribute({ + spanAttributes, + headerKey, + cookieKey, + value, + sendDefaultPii, + lifecycle, +}: AddSpanAttributeOptions): void { + const isCookieSubKey = Boolean(cookieKey); + const nameForSensitivity = cookieKey || headerKey; + const headerValue = handleHttpHeader(nameForSensitivity, value, sendDefaultPii, isCookieSubKey); if (headerValue == null) { return; } @@ -241,10 +301,15 @@ function handleHttpHeader( lowerCasedKey: string, value: string | string[] | undefined, sendPii: boolean, + isCookieSubKey: boolean = false, ): string | undefined { + const snippetsForSensitivity = isCookieSubKey + ? [...SENSITIVE_HEADER_SNIPPETS, ...SENSITIVE_COOKIE_NAME_SNIPPETS] + : SENSITIVE_HEADER_SNIPPETS; + const isSensitive = sendPii - ? SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet)) - : [...PII_HEADER_SNIPPETS, ...SENSITIVE_HEADER_SNIPPETS].some(snippet => lowerCasedKey.includes(snippet)); + ? snippetsForSensitivity.some(snippet => lowerCasedKey.includes(snippet)) + : [...PII_HEADER_SNIPPETS, ...snippetsForSensitivity].some(snippet => lowerCasedKey.includes(snippet)); if (isSensitive) { return '[Filtered]'; diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 73a19c2bfa45..250fcf8443c8 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -650,6 +650,35 @@ describe('request utils', () => { }); }); + it('filters common framework and provider session-style cookie names', () => { + const headers = { + Cookie: + 'connect.sid=s3cr3t; express.sid=opaque; PHPSESSID=abcd; theme=light; sb-access-token=x; __stripe_mid=y', + }; + + const result = httpHeadersToSpanAttributes(headers); + + expect(result).toEqual({ + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.express.sid': '[Filtered]', + 'http.request.header.cookie.phpsessid': '[Filtered]', + 'http.request.header.cookie.theme': 'light', + 'http.request.header.cookie.sb_access_token': '[Filtered]', + 'http.request.header.cookie.__stripe_mid': '[Filtered]', + }); + }); + + it('still filters session-style cookie names when sendDefaultPii is true', () => { + const headers = { Cookie: 'connect.sid=s3cr3t; analytics=1' }; + + const result = httpHeadersToSpanAttributes(headers, true); + + expect(result).toEqual({ + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.analytics': '1', + }); + }); + it('adds a filtered cookie header when cookie header is present, but has no valid key=value pairs', () => { const headers1 = { Cookie: ['key', 'val'] }; const result1 = httpHeadersToSpanAttributes(headers1); From a501f75251a5966c073b260749f4db4cb7500caf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 09:44:20 -0400 Subject: [PATCH 33/77] feat(feedback): allow error messages to be customized (#20474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds text options to `FeedbackTextConfiguration` to allow customizing the widget's error messages (for i18n and other purposes). `sendFeedback` now accepts optional `errorMessages` overrides via its `hint` argument, defaulting to the original English strings. The widget wraps `sendFeedback` to inject the configured text options before the form displays them. Standalone `sendFeedback` consumers see the same English messages as before — no observable behavior change. Closes #14687 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .size-limit.js | 6 +-- packages/core/src/index.ts | 2 + .../core/src/types-hoist/feedback/config.ts | 25 ++++++++++++ .../core/src/types-hoist/feedback/index.ts | 11 ++++- .../src/types-hoist/feedback/sendFeedback.ts | 11 ++++- packages/feedback/src/constants/index.ts | 8 ++++ packages/feedback/src/core/integration.ts | 30 +++++++++++++- packages/feedback/src/core/sendFeedback.ts | 31 +++++++++----- .../feedback/src/modal/components/Form.tsx | 5 ++- .../feedback/src/util/createFeedbackError.ts | 24 +++++++++++ .../feedback/test/core/sendFeedback.test.ts | 40 +++++++++++++++++++ 11 files changed, 173 insertions(+), 20 deletions(-) create mode 100644 packages/feedback/src/util/createFeedbackError.ts diff --git a/.size-limit.js b/.size-limit.js index e9e760d91526..6075311aaa01 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '43 KB', + limit: '44 KB', }, { name: '@sentry/browser (incl. sendFeedback)', @@ -233,7 +233,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '90 KB', + limit: '91 KB', }, // browser CDN bundles (non-gzipped) { @@ -297,7 +297,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '272 KB', + limit: '273 KB', }, // Next.js SDK (ESM) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f112bd4c95b..72fb619ca4f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -453,6 +453,8 @@ export type { ReplayStopReason, } from './types-hoist/replay'; export type { + FeedbackErrorCode, + FeedbackErrorMessages, FeedbackEvent, FeedbackFormData, FeedbackInternalOptions, diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index f6a90c7c5b73..5d7db7636996 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -191,6 +191,31 @@ export interface FeedbackTextConfiguration { * The label for the button that removed a highlight/hidden section of the screenshot. */ removeHighlightText: string; + + /** + * Error text shown when feedback submission is attempted with an empty message + */ + errorEmptyMessageText: string; + + /** + * Error text shown when the Sentry client is not set up + */ + errorNoClientText: string; + + /** + * Error text shown when the feedback submission times out (after 30s) + */ + errorTimeoutText: string; + + /** + * Error text shown when the feedback submission is blocked because the domain is not allowed (HTTP 403) + */ + errorForbiddenText: string; + + /** + * Error text shown when the feedback submission fails for any other reason (e.g. network error, ad-blocker) + */ + errorGenericText: string; } /** diff --git a/packages/core/src/types-hoist/feedback/index.ts b/packages/core/src/types-hoist/feedback/index.ts index 239a44d82543..fd44615e878c 100644 --- a/packages/core/src/types-hoist/feedback/index.ts +++ b/packages/core/src/types-hoist/feedback/index.ts @@ -6,10 +6,17 @@ import type { FeedbackTextConfiguration, FeedbackThemeConfiguration, } from './config'; -import type { FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback } from './sendFeedback'; +import type { + FeedbackErrorCode, + FeedbackErrorMessages, + FeedbackEvent, + SendFeedback, + SendFeedbackParams, + UserFeedback, +} from './sendFeedback'; export type { FeedbackFormData } from './form'; -export type { FeedbackEvent, UserFeedback, SendFeedback, SendFeedbackParams }; +export type { FeedbackErrorCode, FeedbackErrorMessages, FeedbackEvent, SendFeedback, SendFeedbackParams, UserFeedback }; /** * The integration's internal `options` member where every value should be set diff --git a/packages/core/src/types-hoist/feedback/sendFeedback.ts b/packages/core/src/types-hoist/feedback/sendFeedback.ts index 63d63b402b50..f1d0fd5d0f3e 100644 --- a/packages/core/src/types-hoist/feedback/sendFeedback.ts +++ b/packages/core/src/types-hoist/feedback/sendFeedback.ts @@ -47,7 +47,16 @@ export interface SendFeedbackParams { tags?: { [key: string]: Primitive }; } +export type FeedbackErrorCode = + | 'ERROR_EMPTY_MESSAGE' + | 'ERROR_NO_CLIENT' + | 'ERROR_TIMEOUT' + | 'ERROR_FORBIDDEN' + | 'ERROR_GENERIC'; + +export type FeedbackErrorMessages = Partial>; + export type SendFeedback = ( params: SendFeedbackParams, - hint?: EventHint & { includeReplay?: boolean }, + hint?: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages }, ) => Promise; diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index d18392258417..309bba7490ab 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -26,6 +26,14 @@ export const HIGHLIGHT_TOOL_TEXT = 'Highlight'; export const HIDE_TOOL_TEXT = 'Hide'; export const REMOVE_HIGHLIGHT_TEXT = 'Remove'; +export const ERROR_EMPTY_MESSAGE_TEXT = 'Unable to submit feedback with empty message'; +export const ERROR_NO_CLIENT_TEXT = 'No client setup, cannot send feedback.'; +export const ERROR_TIMEOUT_TEXT = 'Unable to determine if Feedback was correctly sent.'; +export const ERROR_FORBIDDEN_TEXT = + 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.'; +export const ERROR_GENERIC_TEXT = + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.'; + export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 1dc418ed131f..a2f94a078976 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -1,11 +1,14 @@ /* eslint-disable max-lines */ +/* eslint-disable complexity */ import type { + FeedbackErrorMessages, FeedbackInternalOptions, FeedbackModalIntegration, FeedbackScreenshotIntegration, Integration, IntegrationFn, + SendFeedback, } from '@sentry/core'; import { addIntegration, debug, isBrowser } from '@sentry/core'; import { @@ -15,6 +18,11 @@ import { DOCUMENT, EMAIL_LABEL, EMAIL_PLACEHOLDER, + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, FORM_TITLE, HIDE_TOOL_TEXT, HIGHLIGHT_TOOL_TEXT, @@ -119,6 +127,11 @@ export const buildFeedbackIntegration = ({ highlightToolText = HIGHLIGHT_TOOL_TEXT, hideToolText = HIDE_TOOL_TEXT, removeHighlightText = REMOVE_HIGHLIGHT_TEXT, + errorEmptyMessageText = ERROR_EMPTY_MESSAGE_TEXT, + errorNoClientText = ERROR_NO_CLIENT_TEXT, + errorTimeoutText = ERROR_TIMEOUT_TEXT, + errorForbiddenText = ERROR_FORBIDDEN_TEXT, + errorGenericText = ERROR_GENERIC_TEXT, // FeedbackCallbacks onFormOpen, @@ -164,6 +177,11 @@ export const buildFeedbackIntegration = ({ highlightToolText, hideToolText, removeHighlightText, + errorEmptyMessageText, + errorNoClientText, + errorTimeoutText, + errorForbiddenText, + errorGenericText, onFormClose, onFormOpen, @@ -230,6 +248,16 @@ export const buildFeedbackIntegration = ({ debug.error('[Feedback] Missing feedback screenshot integration. Proceeding without screenshots.'); } + const errorMessages: FeedbackErrorMessages = { + ERROR_EMPTY_MESSAGE: options.errorEmptyMessageText, + ERROR_NO_CLIENT: options.errorNoClientText, + ERROR_TIMEOUT: options.errorTimeoutText, + ERROR_FORBIDDEN: options.errorForbiddenText, + ERROR_GENERIC: options.errorGenericText, + }; + const wrappedSendFeedback: SendFeedback = (params, hint) => + sendFeedback(params, { includeReplay: true, ...hint, errorMessages }); + const dialog = modalIntegration.createDialog({ options: { ...options, @@ -243,7 +271,7 @@ export const buildFeedbackIntegration = ({ }, }, screenshotIntegration, - sendFeedback, + sendFeedback: wrappedSendFeedback, shadow: _createShadow(options), }); diff --git a/packages/feedback/src/core/sendFeedback.ts b/packages/feedback/src/core/sendFeedback.ts index 712da5c269bf..204cf1cfa6c7 100644 --- a/packages/feedback/src/core/sendFeedback.ts +++ b/packages/feedback/src/core/sendFeedback.ts @@ -1,23 +1,33 @@ -import type { Event, EventHint, SendFeedback, SendFeedbackParams, TransportMakeRequestResponse } from '@sentry/core'; +import type { + Event, + EventHint, + FeedbackErrorMessages, + SendFeedback, + SendFeedbackParams, + TransportMakeRequestResponse, +} from '@sentry/core'; import { captureFeedback, getClient, getCurrentScope, getLocationHref } from '@sentry/core'; import { FEEDBACK_API_SOURCE } from '../constants'; +import { createFeedbackError, resolveFeedbackErrorMessage } from '../util/createFeedbackError'; /** * Public API to send a Feedback item to Sentry */ export const sendFeedback: SendFeedback = ( params: SendFeedbackParams, - hint: EventHint & { includeReplay?: boolean } = { includeReplay: true }, + hint: EventHint & { includeReplay?: boolean; errorMessages?: FeedbackErrorMessages } = { includeReplay: true }, ): Promise => { + const errorMessages = hint.errorMessages; + if (!params.message) { - throw new Error('Unable to submit feedback with empty message'); + throw createFeedbackError('ERROR_EMPTY_MESSAGE', errorMessages); } // We want to wait for the feedback to be sent (or not) const client = getClient(); if (!client) { - throw new Error('No client setup, cannot send feedback.'); + throw createFeedbackError('ERROR_NO_CLIENT', errorMessages); } if (params.tags && Object.keys(params.tags).length) { @@ -35,7 +45,10 @@ export const sendFeedback: SendFeedback = ( // We want to wait for the feedback to be sent (or not) return new Promise((resolve, reject) => { // After 30s, we want to clear anyhow - const timeout = setTimeout(() => reject('Unable to determine if Feedback was correctly sent.'), 30_000); + const timeout = setTimeout(() => { + cleanup(); + reject(resolveFeedbackErrorMessage('ERROR_TIMEOUT', errorMessages)); + }, 30_000); const cleanup = client.on('afterSendEvent', (event: Event, response: TransportMakeRequestResponse) => { if (event.event_id !== eventId) { @@ -51,14 +64,10 @@ export const sendFeedback: SendFeedback = ( } if (response?.statusCode === 403) { - return reject( - 'Unable to send feedback. This could be because this domain is not in your list of allowed domains.', - ); + return reject(resolveFeedbackErrorMessage('ERROR_FORBIDDEN', errorMessages)); } - return reject( - 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', - ); + return reject(resolveFeedbackErrorMessage('ERROR_GENERIC', errorMessages)); }); }); }; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index 0f09e969fee5..ee4b06b25d88 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -131,8 +131,9 @@ export function Form({ onSubmitSuccess(data, eventId); } catch (error) { DEBUG_BUILD && debug.error(error); - setError(error as string); - onSubmitError(error as Error); + const err = error instanceof Error ? error : new Error(String(error)); + setError(err.message); + onSubmitError(err); } } finally { setIsSubmitting(false); diff --git a/packages/feedback/src/util/createFeedbackError.ts b/packages/feedback/src/util/createFeedbackError.ts new file mode 100644 index 000000000000..d7c2c4100f3e --- /dev/null +++ b/packages/feedback/src/util/createFeedbackError.ts @@ -0,0 +1,24 @@ +import type { FeedbackErrorCode, FeedbackErrorMessages } from '@sentry/core'; +import { + ERROR_EMPTY_MESSAGE_TEXT, + ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC_TEXT, + ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT_TEXT, +} from '../constants'; + +const DEFAULT_MESSAGES: Record = { + ERROR_EMPTY_MESSAGE: ERROR_EMPTY_MESSAGE_TEXT, + ERROR_NO_CLIENT: ERROR_NO_CLIENT_TEXT, + ERROR_TIMEOUT: ERROR_TIMEOUT_TEXT, + ERROR_FORBIDDEN: ERROR_FORBIDDEN_TEXT, + ERROR_GENERIC: ERROR_GENERIC_TEXT, +}; + +export function resolveFeedbackErrorMessage(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): string { + return messages?.[code] ?? DEFAULT_MESSAGES[code]; +} + +export function createFeedbackError(code: FeedbackErrorCode, messages?: FeedbackErrorMessages): Error { + return new Error(resolveFeedbackErrorMessage(code, messages)); +} diff --git a/packages/feedback/test/core/sendFeedback.test.ts b/packages/feedback/test/core/sendFeedback.test.ts index a0cbb084da59..56938d1ddd6a 100644 --- a/packages/feedback/test/core/sendFeedback.test.ts +++ b/packages/feedback/test/core/sendFeedback.test.ts @@ -267,6 +267,46 @@ describe('sendFeedback', () => { ]); }); + it('throws when message is empty', () => { + mockSdk(); + expect(() => sendFeedback({ message: '' })).toThrow('Unable to submit feedback with empty message'); + }); + + it('throws when no client is set up', async () => { + // Isolate in its own scope so the client set up by other tests doesn't bleed in. + // `getClient` reads from the current scope; resetting it here leaves no client. + const { getGlobalScope } = await import('@sentry/core'); + getGlobalScope().setClient(undefined); + getCurrentScope().setClient(undefined); + getIsolationScope().setClient(undefined); + expect(() => sendFeedback({ message: 'mi' })).toThrow('No client setup, cannot send feedback.'); + }); + + it('uses provided errorMessages overrides', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 403 }); + }); + + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch('custom forbidden text'); + }); + + it('falls back to default messages for codes not in errorMessages', async () => { + mockSdk(); + vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { + return Promise.resolve({ statusCode: 400 }); + }); + + // Only override ERROR_FORBIDDEN — a 400 should still use the default generic message. + await expect( + sendFeedback({ message: 'mi' }, { errorMessages: { ERROR_FORBIDDEN: 'custom forbidden text' } }), + ).rejects.toMatch( + 'Unable to send feedback. This could be because of network issues, or because you are using an ad-blocker.', + ); + }); + it('handles 400 transport error', async () => { mockSdk(); vi.spyOn(getClient()!.getTransport()!, 'send').mockImplementation(() => { From b0455414a1627b3b98eecdb467fc6930f45a8a71 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 24 Apr 2026 13:46:52 +0000 Subject: [PATCH 34/77] feat(nextjs): Filter unwanted segments when span streaming is enabled (#20384) closes #20374 In this case we can get rid of the event processors entirely and just rely on `ignoreSpans` for transactions and streamed spans. Updated unit tests to include the span streaming path. We should get E2E coverage for this from our existing E2E tests. --------- Co-authored-by: Nicolas Hrubec --- packages/nextjs/src/client/index.ts | 22 ++++---- packages/nextjs/test/clientSdk.test.ts | 71 ++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index fb8b7acea878..b7f9c482b816 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -74,19 +74,17 @@ export function init(options: BrowserOptions): Client | undefined { applyTunnelRouteOption(opts); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); - const client = reactInit(opts); - - const filterTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === '/404' ? null : event; - filterTransactions.id = 'NextClient404Filter'; - addEventProcessor(filterTransactions); + opts.ignoreSpans = [ + ...(opts.ignoreSpans || []), + // we filter out segment spans for /404 pages + /^\/404$/, + // segment spans where we didn't get a reasonable transaction name + // in this case, constructing a dynamic RegExp is fine because the variable is a constant + // we need to ensure to exact-match, so a string match isn't safe (same for /404 above) + new RegExp(`^${INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME}$`), + ]; - const filterIncompleteNavigationTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME - ? null - : event; - filterIncompleteNavigationTransactions.id = 'IncompleteTransactionFilter'; - addEventProcessor(filterIncompleteNavigationTransactions); + const client = reactInit(opts); const filterNextRedirectError: EventProcessor = (event, hint) => isRedirectNavigationError(hint?.originalException) || event.exception?.values?.[0]?.value === 'NEXT_REDIRECT' diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 873aa5a2511e..090ef61fe5cd 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,10 +1,11 @@ import type { Integration } from '@sentry/core'; -import { debug, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { debug, getGlobalScope, getIsolationScope, SentryNonRecordingSpan } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, getCurrentScope, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import { breadcrumbsIntegration, browserTracingIntegration, init } from '../src/client'; +import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from '../src/client/routing/appRouterRoutingInstrumentation'; const reactInit = vi.spyOn(SentryReact, 'init'); const debugLogSpy = vi.spyOn(debug, 'log'); @@ -83,20 +84,68 @@ describe('Client init()', () => { ); }); - it('adds 404 transaction filter', () => { - init({ - dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', - tracesSampleRate: 1.0, + describe('transaction filtering', () => { + const TEST_DSN_404 = 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012'; + + it('drops /404 transactions', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0 }); + const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); + + // Ensure we have no current span, so our next span is a transaction + SentryReact.withActiveSpan(null, () => { + SentryReact.startInactiveSpan({ name: '/404' })?.end(); + }); + + expect(transportSend).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); }); - const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); - // Ensure we have no current span, so our next span is a transaction - SentryReact.withActiveSpan(null, () => { - SentryReact.startInactiveSpan({ name: '/404' })?.end(); + it('drops incomplete navigation transactions', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0 }); + const transportSend = vi.spyOn(getClient()!.getTransport()!, 'send'); + + // Ensure we have no current span, so our next span is a transaction + SentryReact.withActiveSpan(null, () => { + SentryReact.startInactiveSpan({ name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME })?.end(); + }); + + expect(transportSend).not.toHaveBeenCalled(); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); }); - expect(transportSend).not.toHaveBeenCalled(); - expect(debugLogSpy).toHaveBeenCalledWith('An event processor returned `null`, will not send event.'); + describe('span streaming', () => { + it('drops /404 segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + // Ensure we have no current span, so our next span is a segment span + const span = SentryReact.withActiveSpan(null, () => SentryReact.startInactiveSpan({ name: '/404' })); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); + }); + + it('drops incomplete navigation segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + // Ensure we have no current span, so our next span is a segment span + const span = SentryReact.withActiveSpan(null, () => + SentryReact.startInactiveSpan({ name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME }), + ); + + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('matches `ignoreSpans`')); + }); + + it('drops /404 non-segment spans', () => { + init({ dsn: TEST_DSN_404, tracesSampleRate: 1.0, traceLifecycle: 'stream' }); + + SentryReact.startSpan({ name: 'parent' }, parent => { + expect(parent).not.toBeInstanceOf(SentryNonRecordingSpan); + const child = SentryReact.startInactiveSpan({ name: '/404' }); + expect(child).toBeInstanceOf(SentryNonRecordingSpan); + }); + }); + }); }); describe('integrations', () => { From 37c52cb693493ab91f5f6801c9d0202b58711d4d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:10:10 +0900 Subject: [PATCH 35/77] chore(e2e): Remove @tanstack/start-plugin-core override (#20518) This PR removes the `@tanstack/start-plugin-core` override we added to work around transient breakage in `1.168.0` in #20482. This is now no longer needed, and instead breaks because the subpath has changed. Closes: #20508 --- .../test-applications/tanstackstart-react/package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 7e78493e4afc..f689aba2d7e2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -45,11 +45,6 @@ "volta": { "extends": "../../package.json" }, - "pnpm": { - "overrides": { - "@tanstack/start-plugin-core": "1.167.35" - } - }, "sentryTest": { "variants": [ { From e1c10773fd1699f2a48dd5213498217098cce5e9 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:24:01 +0900 Subject: [PATCH 36/77] chore(e2e): Add vue and vue-router to nuxt-4 canary build step to fix rollup resolution (#20519) This PR adds `vue` and `vue-router` to the `test:build-canary` script to work around a regression in nuxt-nightly's latest build (4.4.3) where rollup fails to resolve vue imports. This is a temporary workaround and should be removed once nuxt fixes this upstream: https://github.com/nuxt/nuxt/issues/34888. Closes: #20515 --- dev-packages/e2e-tests/test-applications/nuxt-4/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 02477111483d..403e9b20a3a0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -14,7 +14,7 @@ "test:prod": "TEST_ENV=production playwright test", "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", - "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm add vue vue-router && pnpm install --force && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { From de706edcb4a0df109d3f209ea20109f5c9a0ca97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 27 Apr 2026 10:26:05 +0200 Subject: [PATCH 37/77] feat(cloudflare): Add trace propagation for RPC method calls (#20343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #19327 closes [JS-1715](https://linear.app/getsentry/issue/JS-1715/sentrycloudflare-durable-object-rpc-spans-are-not-linked-to-worker) closes #16898 closes [JS-680](https://linear.app/getsentry/issue/JS-680/instrument-cloudflare-worker-rpc-methods) closes #16760 closes [JS-622](https://linear.app/getsentry/issue/JS-622/trace-propagation-in-cloudflare-workers-not-working-as-expected) ## Summary > Most of the additions are tests, the main implementation is rather small Adds trace propagation for Cloudflare Workers RPC method calls to Durable Objects. This is admittedly a bit of a hack: [Cap'n Proto](https://capnproto.org/) ([which powers Cloudflare RPC](https://blog.cloudflare.com/capnweb-javascript-rpc-library/)) has no native support for headers or metadata. To work around this, we append our trace data (sentry-trace + baggage) as a trailing argument object `{ __sentry: { trace, baggage } }` to every RPC call. On the receiving DO side, we strip this argument before the user's method is invoked, so it's completely transparent. **Caveat:** If the Durable Object is not instrumented with Sentry, the trailing `__sentry` argument will remain in the args array and be passed to the user's method. I would count this as ok since: - Users opting into RPC instrumentation are expected to instrument both sides - The extra argument is easy to ignore in most cases, unless users use `...args` to retrieve all arguments Otherwise, trace propagation should be seamless across Worker → DO and Worker → Worker → DO call chains. ### How it works As mentioned above a Sentry trace object is appended on each call ```ts const id = env.MY_DURABLE_OBJECT.idFromName('test'); const stub = env.MY_DURABLE_OBJECT.get(id); // User's RPC call const result = await stub.sayHello('World'); // What is actually sent (transparent to user) const result = await stub.sayHello('World', { __sentry: { trace, baggage } }); ``` --- .../suites/tracing/durableobject/index.ts | 37 ++- .../suites/tracing/durableobject/test.ts | 72 +++++ .../worker-do-rpc-disabled/index.ts | 45 ++++ .../worker-do-rpc-disabled/test.ts | 40 +++ .../worker-do-rpc-disabled/wrangler.jsonc | 20 ++ .../propagation/worker-do-rpc/index.ts | 54 ++++ .../tracing/propagation/worker-do-rpc/test.ts | 123 +++++++++ .../propagation/worker-do-rpc/wrangler.jsonc | 20 ++ .../worker-worker-do-rpc/index-sub-worker.ts | 45 ++++ .../propagation/worker-worker-do-rpc/index.ts | 27 ++ .../propagation/worker-worker-do-rpc/test.ts | 103 +++++++ .../wrangler-sub-worker.jsonc | 20 ++ .../worker-worker-do-rpc/wrangler.jsonc | 12 + packages/cloudflare/src/durableobject.ts | 206 +++++--------- .../instrumentDurableObjectNamespace.ts | 17 +- .../instrumentations/worker/instrumentEnv.ts | 13 +- packages/cloudflare/src/utils/rpcMeta.ts | 59 ++++ packages/cloudflare/src/utils/rpcOptions.ts | 36 +-- .../cloudflare/src/wrapMethodWithSentry.ts | 24 +- .../cloudflare/test/durableobject.test.ts | 254 ++++++++++-------- .../instrumentDurableObjectNamespace.test.ts | 79 ++++++ .../instrumentations/instrumentEnv.test.ts | 113 ++++++++ .../cloudflare/test/utils/rpcMeta.test.ts | 157 +++++++++++ 23 files changed, 1287 insertions(+), 289 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc create mode 100644 packages/cloudflare/src/utils/rpcMeta.ts create mode 100644 packages/cloudflare/test/utils/rpcMeta.test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts index 74ce2cbbdac4..659b04a3f488 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -6,14 +6,38 @@ interface Env { TEST_DURABLE_OBJECT: DurableObjectNamespace; } +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// This class mimics a real-world DO with private fields/methods and multiple public methods class TestDurableObjectBase extends DurableObject { + // Private field used by RPC methods - tests that private fields work with instrumentation + #greeting = 'Hello'; + public constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); } - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + // RPC method that uses a private field - this would throw TypeError if the Proxy + // doesn't correctly bind `this` to the original object async sayHello(name: string): Promise { - return `Hello, ${name}`; + return `${this.#greeting}, ${name}`; + } + + // RPC method that modifies a private field + async setGreeting(greeting: string): Promise { + this.#greeting = greeting; + } + + // Other public methods that are not called - should not interfere with RPC + async getStatus(): Promise { + return 'OK'; + } + + async processData(data: Record): Promise> { + return { ...data, processed: true }; + } + + async multiply(a: number, b: number): Promise { + return a * b; } } @@ -21,7 +45,7 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), TestDurableObjectBase, ); @@ -36,6 +60,13 @@ export default { return new Response(greeting); } + // Test endpoint that modifies and reads a private field via RPC + if (request.url.includes('custom-greeting')) { + await stub.setGreeting('Howdy'); + const greeting = await stub.sayHello('partner'); + return new Response(greeting); + } + return new Response('Usual response'); }, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index e86508c0f101..4e9e65f22118 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; it('traces a durable object method', async ({ signal }) => { @@ -25,3 +26,74 @@ it('traces a durable object method', async ({ signal }) => { await runner.makeRequest('get', '/hello'); await runner.completed(); }); + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// The RPC receiver does not implement the method error on consecutive calls +it('handles consecutive RPC calls without throwing "RPC receiver does not implement method" error', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // First request - this always worked + const response1 = await runner.makeRequest('get', '/hello'); + expect(response1).toBe('Hello, world'); + + // Second consecutive request - this used to fail with: + // "The RPC receiver does not implement the method 'sayHello'" + const response2 = await runner.makeRequest('get', '/hello'); + expect(response2).toBe('Hello, world'); + + await runner.completed(); +}); + +// Regression test: RPC methods that access private fields should work correctly. +// When enableRpcTracePropagation wraps the DO in a Proxy, calling methods through +// the Proxy must ensure `this` refers to the original object (not the Proxy), +// otherwise private field access throws: "Cannot read private member from an object +// whose class did not declare it" +it('allows RPC methods to access private class fields', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'setGreeting', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // This calls setGreeting (writes private field) then sayHello (reads private field) + // Would throw TypeError if `this` is the Proxy instead of the original object + const response = await runner.makeRequest('get', '/custom-greeting'); + expect(response).toBe('Howdy, partner'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..eb21c2918155 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } +} + +// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + // enableRpcTracePropagation: false (default) + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..cba40af5a43d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => { + let receivedTransactions: string[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Should only receive the worker HTTP transaction, not the DO RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + receivedTransactions.push(transactionEvent.transaction as string); + }) + .start(signal); + + // The RPC call should still work, just not be instrumented + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Verify we only got the worker transaction, no RPC transaction + expect(receivedTransactions).toEqual(['GET /rpc/hello']); + expect(receivedTransactions).not.toContain('sayHello'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..0711a1d68d37 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts new file mode 100644 index 000000000000..8c6ab60fbdd5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts new file mode 100644 index 000000000000..f86348ab6fbc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object via RPC method call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..3f909c489513 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..f9a6fd2ed8ff --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts new file mode 100644 index 000000000000..3465449ba2fe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts new file mode 100644 index 000000000000..a6f5818b8489 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts @@ -0,0 +1,103 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker to durable object (3 levels deep)', async ({ signal }) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main Worker -> Sub Worker -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..063d8e9224ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-worker-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..ddf9c607d906 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-worker-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-worker-worker-do-rpc-sub", + }, + ], +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index d21ca8d10bf1..95068c7c9697 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -3,26 +3,34 @@ import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument'; +import { ensureInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; -import { getPrototypeMethodFilter } from './utils/rpcOptions'; -import type { UncheckedMethod } from './wrapMethodWithSentry'; -import { wrapMethodWithSentry } from './wrapMethodWithSentry'; +import { getEffectiveRpcPropagation } from './utils/rpcOptions'; +import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; + +const BUILT_IN_DO_METHODS = new Set([ + 'constructor', + 'fetch', + 'alarm', + 'webSocketError', + 'webSocketClose', + 'webSocketMessage', +]); /** * Instruments a Durable Object class to capture errors and performance data. * - * Instruments the following methods: + * Instruments the following methods by default: * - fetch * - alarm * - webSocketMessage * - webSocketClose * - webSocketError * - * as well as any other public RPC methods on the Durable Object instance. + * To instrument RPC methods (prototype methods), enable the `enableRpcTracePropagation` option. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. @@ -116,140 +124,68 @@ export function instrumentDurableObjectWithSentry< ); } - for (const method of Object.getOwnPropertyNames(obj)) { - if ( - method === 'fetch' || - method === 'alarm' || - method === 'webSocketError' || - method === 'webSocketClose' || - method === 'webSocketMessage' - ) { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - const value = (obj as any)[method] as unknown; - if (typeof value === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - (obj as any)[method] = wrapMethodWithSentry( - { options, context, spanName: method, spanOp: 'rpc' }, - value as UncheckedMethod, - ); - } - } + // Get effective RPC propagation setting (handles deprecation of instrumentPrototypeMethods) + const rpcPropagation = getEffectiveRpcPropagation(options); - // Store context and options on the instance for prototype methods to access - Object.defineProperty(obj, '__SENTRY_CONTEXT__', { - value: context, - enumerable: false, - writable: false, - configurable: false, - }); - - Object.defineProperty(obj, '__SENTRY_OPTIONS__', { - value: options, - enumerable: false, - writable: false, - configurable: false, - }); - - const methodFilter = getPrototypeMethodFilter(options); - - if (methodFilter) { - instrumentPrototype(target, methodFilter); + // Skip RPC instrumentation if not enabled + if (!rpcPropagation) { + return obj; } - return obj; - }, - }); -} - -function instrumentPrototype(target: T, methodsToInstrument: boolean | string[]): void { - const proto = target.prototype; + // If `instrumentPrototypeMethods` was passed as an array (deprecated), + // only the listed method names should be instrumented. + const instrumentPrototypeMethods = Array.isArray(options.instrumentPrototypeMethods) + ? options.instrumentPrototypeMethods + : undefined; + const allowSet = instrumentPrototypeMethods ? new Set(instrumentPrototypeMethods) : null; + + // Return a Proxy that lazily wraps prototype methods on access. + // This avoids iterating the prototype chain at construction time — + // we only check if a property is an RPC method when it's accessed. + const rpcMethodCache = new Map(); + + return new Proxy(obj, { + get(proxyTarget, prop, receiver) { + const value = Reflect.get(proxyTarget, prop, receiver); + + if (typeof prop !== 'string' || BUILT_IN_DO_METHODS.has(prop)) { + return value; + } + + const cached = rpcMethodCache.get(prop); + + if (cached) { + return cached; + } + + if ( + typeof value !== 'function' || + Object.prototype.hasOwnProperty.call(proxyTarget, prop) || + (allowSet && !allowSet.has(prop)) || + // Exclude inherited Object.prototype methods (toString, valueOf, etc.) + // These are not RPC methods and should not create spans + prop in Object.prototype + ) { + return value; + } + + // Bind the method to the original object to ensure private fields work correctly. + // When called via the Proxy, `this` would be the Proxy, but private fields require + // the original object. Bound functions ignore the thisArg passed via Reflect.apply. + const boundValue = (value as UncheckedMethod).bind(proxyTarget); + + const wrapped = wrapMethodWithSentry( + { options, context, spanName: prop, spanOp: 'rpc' }, + boundValue, + undefined, + true, + ); - // Get all methods from the prototype chain - const methodNames = new Set(); - let current = proto; + rpcMethodCache.set(prop, wrapped); - while (current && current !== Object.prototype) { - Object.getOwnPropertyNames(current).forEach(name => { - if (name !== 'constructor' && typeof (current as Record)[name] === 'function') { - methodNames.add(name); - } - }); - current = Object.getPrototypeOf(current); - } - - // Create a set for efficient lookups when methodsToInstrument is an array - const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null; - - // Instrument each method on the prototype - methodNames.forEach(methodName => { - const originalMethod = (proto as Record)[methodName]; - - if (!originalMethod) { - return; - } - - const existingInstrumented = getInstrumented(originalMethod); - if (existingInstrumented) { - Object.defineProperty(proto, methodName, { - value: existingInstrumented, - enumerable: false, - writable: true, - configurable: true, - }); - return; - } - - // If methodsToInstrument is an array, only instrument methods in that set - if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) { - return; - } - - // Create a wrapper that gets context/options from the instance at runtime - const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { - const thisWithSentry = this as { - __SENTRY_CONTEXT__: DurableObjectState; - __SENTRY_OPTIONS__: CloudflareOptions; - }; - const instanceContext = thisWithSentry.__SENTRY_CONTEXT__; - const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__; - - if (!instanceOptions) { - // Fallback to original method if no Sentry data found - return (originalMethod as UncheckedMethod).apply(this, args); - } - - // Use the existing wrapper but with instance-specific context/options - const wrapper = wrapMethodWithSentry( - { - options: instanceOptions, - context: instanceContext, - spanName: methodName, - spanOp: 'rpc', + return wrapped; }, - originalMethod as UncheckedMethod, - undefined, - true, // noMark = true since we'll mark the prototype method - ); - - return wrapper.apply(this, args); - }; - - // Only mark wrappedMethod as instrumented (not originalMethod → wrappedMethod). - // originalMethod must stay unmapped because wrappedMethod calls - // wrapMethodWithSentry(options, originalMethod) on each invocation to create - // a per-instance proxy. If originalMethod mapped to wrappedMethod, that call - // would return wrappedMethod itself, causing infinite recursion. - markAsInstrumented(wrappedMethod); - - // Replace the prototype method - Object.defineProperty(proto, methodName, { - value: wrappedMethod, - enumerable: false, - writable: true, - configurable: true, - }); + }); + }, }); } diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index ebbabd9855ad..4c29f6e9595e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -1,6 +1,10 @@ import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types'; +import { appendRpcMeta } from '../utils/rpcMeta'; import { instrumentFetcher } from './worker/instrumentFetcher'; +// Built-in DurableObjectStub methods that are not RPC calls. +export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); + /** * Instruments a DurableObjectNamespace binding to create spans for DO interactions. * @@ -33,17 +37,22 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa } /** - * Instruments a DurableObjectStub to propagate trace context across fetch calls. + * Instruments a DurableObjectStub to create spans for outgoing fetch calls + * and propagate trace context across RPC calls. * * @param stub - The DurableObjectStub to instrument */ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { return new Proxy(stub, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); + get(target, prop) { + const value = Reflect.get(target, prop); if (prop === 'fetch' && typeof value === 'function') { - return instrumentFetcher((input, init) => Reflect.apply(value, target, [input, init])); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof prop === 'string' && !STUB_NON_RPC_METHODS.has(prop)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index fd6a3c72c097..a29bec79e2e5 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,7 +1,8 @@ import type { CloudflareOptions } from '../../client'; import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; -import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; +import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; function isProxyable(item: unknown): item is object { @@ -58,11 +59,15 @@ export function instrumentEnv>(env: Env, opt if (isJSRPC(item)) { const instrumented = new Proxy(item, { - get(target, p, rcv) { - const value = Reflect.get(target, p, rcv); + get(target, p) { + const value = Reflect.get(target, p); if (p === 'fetch' && typeof value === 'function') { - return instrumentFetcher(value.bind(target)); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts new file mode 100644 index 000000000000..9389a221230c --- /dev/null +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -0,0 +1,59 @@ +import { getTraceData, type SerializedTraceData } from '@sentry/core'; + +/** + * Key used to identify Sentry RPC metadata in a trailing argument. + * This enables transparent trace propagation across Cloudflare Workers RPC + * calls (Cap'n Proto), which have no native header/metadata support. + */ +const SENTRY_RPC_META_KEY = '__sentry_rpc_meta__'; + +interface SentryRpcMeta { + __sentry_rpc_meta__: SerializedTraceData; +} + +function isSentryRpcMeta(value: unknown): value is SentryRpcMeta { + if (typeof value !== 'object' || value === null || !(SENTRY_RPC_META_KEY in value)) { + return false; + } + const sentry = (value as SentryRpcMeta).__sentry_rpc_meta__; + return typeof sentry === 'object' && sentry !== null; +} + +/** + * Appends Sentry RPC metadata to an args array for trace propagation. + * If no active trace exists, returns the original args unchanged. + */ +export function appendRpcMeta(args: unknown[]): unknown[] { + const traceData = getTraceData(); + + if (!traceData['sentry-trace']) { + return args; + } + + return [...args, { [SENTRY_RPC_META_KEY]: traceData }]; +} + +/** + * Extracts Sentry RPC metadata from the trailing argument of an args array. + * Returns cleaned args (without meta) and the extracted trace data if found. + */ +export function extractRpcMeta( + args: T, +): { + args: T; + rpcMeta?: SerializedTraceData; +} { + if (args.length === 0) { + return { args }; + } + + const last = args[args.length - 1]; + if (isSentryRpcMeta(last)) { + return { + args: args.slice(0, -1) as T, + rpcMeta: last.__sentry_rpc_meta__, + }; + } + + return { args }; +} diff --git a/packages/cloudflare/src/utils/rpcOptions.ts b/packages/cloudflare/src/utils/rpcOptions.ts index 6e71bb84a46f..5e920675d85a 100644 --- a/packages/cloudflare/src/utils/rpcOptions.ts +++ b/packages/cloudflare/src/utils/rpcOptions.ts @@ -7,7 +7,7 @@ import { DEBUG_BUILD } from '../debug-build'; * * Priority: * 1. If `enableRpcTracePropagation` is set, use it (ignore `instrumentPrototypeMethods`) - * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning (converted to boolean) + * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning * 3. If neither is set, return `false` * * @returns The effective setting for RPC trace propagation @@ -43,37 +43,3 @@ export function getEffectiveRpcPropagation(options: CloudflareOptions): boolean return false; } - -/** - * Gets the method filter for prototype method instrumentation. - * - * Returns: - * - `null` if no instrumentation should occur - * - `true` if all methods should be instrumented - * - `string[]` if only specific methods should be instrumented (deprecated behavior) - * - * @returns The method filter or null if no instrumentation - */ -export function getPrototypeMethodFilter(options: CloudflareOptions): boolean | string[] { - const { enableRpcTracePropagation, instrumentPrototypeMethods } = options; - - // If the new option is explicitly set, use it (boolean only, no filtering) - if (enableRpcTracePropagation !== undefined) { - return !!enableRpcTracePropagation; - } - - // Fall back to deprecated option - preserve array filtering behavior - if (instrumentPrototypeMethods !== undefined) { - if (instrumentPrototypeMethods === true) { - return true; - } - - if (Array.isArray(instrumentPrototypeMethods) && instrumentPrototypeMethods.length > 0) { - return instrumentPrototypeMethods; - } - - return false; - } - - return false; -} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 3a7218057c4a..dffb0338c1da 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -1,6 +1,8 @@ import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import type { SerializedTraceData } from '@sentry/core'; import { captureException, + continueTrace, getClient, isThenable, type Scope, @@ -15,6 +17,7 @@ import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { extractRpcMeta } from './utils/rpcMeta'; import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks'; /** Extended DurableObjectState with originalStorage exposed by instrumentContext */ @@ -64,9 +67,21 @@ export function wrapMethodWithSentry( handler, original => new Proxy(original, { - apply(target, thisArg, args: Parameters) { + apply(target, thisArg, rawArgs: Parameters) { const { startNewTrace } = wrapperOptions; + // For RPC methods, extract Sentry trace context from the trailing argument. + // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; + // we strip it here so the user's method never sees it. + let args = rawArgs; + let rpcMeta: SerializedTraceData | undefined; + + if (wrapperOptions.spanOp === 'rpc') { + const extracted = extractRpcMeta(rawArgs); + args = extracted.args; + rpcMeta = extracted.rpcMeta; + } + // For startNewTrace, always use withIsolationScope to ensure a fresh scope // Otherwise, use existing client's scope or isolation scope const currentClient = getClient(); @@ -213,6 +228,13 @@ export function wrapMethodWithSentry( }); }; + if (rpcMeta) { + return continueTrace( + { sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, + executeSpan, + ); + } + if (startNewTrace) { return startNewTraceCore(() => executeSpan()); } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index efce592a6cdd..dc65cb44f6cc 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,12 +81,8 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('All available durable object methods are instrumented when instrumentPrototypeMethods is enabled', () => { + it('Built-in durable object methods are always instrumented', () => { const testClass = class { - propertyFunction = vi.fn(); - - rpcMethod() {} - fetch() {} alarm() {} @@ -97,24 +93,157 @@ describe('instrumentDurableObjectWithSentry', () => { webSocketError() {} }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); - for (const method_name of [ - 'propertyFunction', - 'fetch', - 'alarm', - 'webSocketMessage', - 'webSocketClose', - 'webSocketError', - 'rpcMethod', - ]) { + + // Built-in DO methods are always instrumented + for (const method_name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { expect(getInstrumented((obj as any)[method_name]), `Method ${method_name} is instrumented`).toBeTruthy(); } }); + it('Does not instrument RPC methods when instrumentPrototypeMethods is not set', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + + describe('instrumentPrototypeMethods option', () => { + it('instruments all RPC methods when option is true', () => { + const testClass = class { + rpcMethodOne() { + return 'one'; + } + rpcMethodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC methods (prototype methods) are wrapped via Proxy - verify they are callable and cached + expect(typeof obj.rpcMethodOne).toBe('function'); + expect(typeof obj.rpcMethodTwo).toBe('function'); + expect(obj.rpcMethodOne).toBe(obj.rpcMethodOne); // Cached wrapper + expect(obj.rpcMethodTwo).toBe(obj.rpcMethodTwo); // Cached wrapper + expect(obj.rpcMethodOne()).toBe('one'); + expect(obj.rpcMethodTwo()).toBe('two'); + }); + + it('instruments only specified methods when option is array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + methodThree() { + return 'three'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // methodOne and methodThree should be wrapped — i.e. they should NOT be + // identical to the underlying prototype method. + expect(obj.methodOne).not.toBe(testClass.prototype.methodOne); + expect(obj.methodThree).not.toBe(testClass.prototype.methodThree); + + // methodTwo is not in the allow-list and must remain the original + // prototype method (i.e. not wrapped). + expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + + // All methods should still be callable and behave correctly. + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + expect(obj.methodThree()).toBe('three'); + }); + + it('does not instrument any RPC methods when option is empty array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: [] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Empty array means no methods are allowed → none should be wrapped. + expect(obj.methodOne).toBe(testClass.prototype.methodOne); + expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + }); + + it('does not instrument RPC methods when option is false', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + + it('does not wrap Object.prototype methods as RPC methods', () => { + const testClass = class { + rpcMethod() { + return 'rpc-result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Object.prototype methods should NOT be wrapped - they should be the original methods + expect(obj.toString).toBe(Object.prototype.toString); + expect(obj.valueOf).toBe(Object.prototype.valueOf); + expect(obj.hasOwnProperty).toBe(Object.prototype.hasOwnProperty); + expect(obj.propertyIsEnumerable).toBe(Object.prototype.propertyIsEnumerable); + expect(obj.isPrototypeOf).toBe(Object.prototype.isPrototypeOf); + expect(obj.toLocaleString).toBe(Object.prototype.toLocaleString); + + // They should still work correctly + expect(obj.toString()).toBe('[object Object]'); + expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own + expect(obj.valueOf()).toBe(obj); + + // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) + expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod); + expect(obj.rpcMethod()).toBe('rpc-result'); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); @@ -164,93 +293,4 @@ describe('instrumentDurableObjectWithSentry', () => { // Verify that exactly one flush call was made during this test expect(delta).toBe(1); }); - - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('does not instrument prototype methods when option is false', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('instruments all prototype methods when option is true', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeTruthy(); - }); - - it('instruments only specified methods when option is array', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - methodThree() { - return 'three'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(getInstrumented(obj.methodThree)).toBeTruthy(); - }); - - it('still instruments instance methods regardless of prototype option', () => { - const testClass = class { - propertyFunction = vi.fn(); - - fetch() {} - alarm() {} - webSocketMessage() {} - webSocketClose() {} - webSocketError() {} - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - // Instance methods should still be instrumented - expect(getInstrumented(obj.propertyFunction)).toBeTruthy(); - expect(getInstrumented(obj.fetch)).toBeTruthy(); - expect(getInstrumented(obj.alarm)).toBeTruthy(); - expect(getInstrumented(obj.webSocketMessage)).toBeTruthy(); - expect(getInstrumented(obj.webSocketClose)).toBeTruthy(); - expect(getInstrumented(obj.webSocketError)).toBeTruthy(); - }); - }); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 1b29d5062ce2..67c6420147ac 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -177,6 +178,84 @@ describe('instrumentDurableObjectNamespace', () => { }); }); + describe('RPC method instrumentation', () => { + it('injects Sentry RPC meta into RPC method calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('rpc-result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('does not wrap built-in stub methods (connect, dup)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const connectFn = vi.fn(); + const dupFn = vi.fn(); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + connect: connectFn, + dup: dupFn, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + + // connect and dup should be the original functions, not wrapped + expect((stub as any).connect).toBe(connectFn); + expect((stub as any).dup).toBe(dupFn); + }); + }); + describe('non-function properties', () => { it('returns non-function properties unchanged', () => { const { namespace: originalNamespace } = createMockNamespace(); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index c127406b8c7e..ab115317b7b0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; @@ -6,6 +7,7 @@ vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ __instrumented: true, __original: namespace, })), + STUB_NON_RPC_METHODS: new Set(['fetch', 'connect', 'dup']), })); import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -173,4 +175,115 @@ describe('instrumentEnv', () => { expect(instrumented.NULL_VAL).toBeNull(); expect(instrumented.UNDEF_VAL).toBeUndefined(); }); + + describe('JSRPC RPC method instrumentation', () => { + it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + // Without enableRpcTracePropagation, no metadata should be injected + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('injects Sentry RPC meta when enableRpcTracePropagation is true', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta into JSRPC fetch calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-baggage=value', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const jsrpcProxy = new Proxy( + { fetch: mockFetch }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).fetch('https://example.com'); + + // fetch should use HTTP header injection, not trailing arg + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs).not.toContainEqual(expect.objectContaining({ __sentry: expect.anything() })); + }); + + it('does not inject meta into JSRPC RPC calls when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + }); }); diff --git a/packages/cloudflare/test/utils/rpcMeta.test.ts b/packages/cloudflare/test/utils/rpcMeta.test.ts new file mode 100644 index 000000000000..38af0de23115 --- /dev/null +++ b/packages/cloudflare/test/utils/rpcMeta.test.ts @@ -0,0 +1,157 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { appendRpcMeta, extractRpcMeta } from '../../src/utils/rpcMeta'; + +describe('rpcMeta', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('appendRpcMeta', () => { + it('appends meta with trace data when active trace exists', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const result = appendRpcMeta(['arg1', 42]); + + expect(result).toEqual([ + 'arg1', + 42, + { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]); + }); + + it('returns original args when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const args = ['arg1', 'arg2']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('returns original args when sentry-trace is empty', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '' }); + + const args = ['arg1']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('appends meta to empty args', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-sample_rate=1.0', + }); + + const result = appendRpcMeta([]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1', baggage: 'sentry-sample_rate=1.0' }, + }); + }); + + it('does not mutate original args array', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const args = ['arg1']; + appendRpcMeta(args); + + expect(args).toEqual(['arg1']); + }); + }); + + describe('extractRpcMeta', () => { + it('extracts meta from trailing argument', () => { + const args = [ + 'arg1', + 42, + { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', 42]); + expect(result.rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + + it('returns original args when no meta present', () => { + const args = ['arg1', { someKey: 'value' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', { someKey: 'value' }]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('returns empty args unchanged', () => { + const result = extractRpcMeta([]); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry_rpc_meta__ value is not an object', () => { + const args = ['arg1', { __sentry_rpc_meta__: 'not-an-object' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry_rpc_meta__ value is null', () => { + const args = ['arg1', { __sentry_rpc_meta__: null }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('handles meta with only trace (no baggage)', () => { + const args = [{ __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1' } }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toEqual({ 'sentry-trace': 'abc-def-1' }); + }); + + it('round-trips with appendRpcMeta', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const originalArgs = ['hello', { data: true }, 42]; + const withMeta = appendRpcMeta(originalArgs); + const { args, rpcMeta } = extractRpcMeta(withMeta); + + expect(args).toEqual(originalArgs); + expect(rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + }); +}); From 356cfed114253820faeca7160e621b3e68ec1a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 27 Apr 2026 10:34:14 +0200 Subject: [PATCH 38/77] chore: Add size limit reports on PRs for Cloudflare (#20055) Cloudflare does have a free tier limit of 3mb: https://developers.cloudflare.com/workers/platform/limits/#worker-size It would be too bad if we ship something for Cloudflare and by accident ship something which is super costly. --- .size-limit.js | 88 ++++++++++++++ package.json | 7 +- yarn.lock | 319 +++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 350 insertions(+), 64 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 6075311aaa01..a61e489a8610 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -9,6 +9,7 @@ module.exports = [ import: createImport('init'), gzip: true, limit: '26 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser - with treeshaking flags', @@ -16,6 +17,7 @@ module.exports = [ import: createImport('init'), gzip: true, limit: '25 KB', + disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -39,6 +41,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration'), gzip: true, limit: '44 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Tracing + Span Streaming)', @@ -46,6 +49,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), gzip: true, limit: '48 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -53,6 +57,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, limit: '49 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Tracing, Replay)', @@ -60,6 +65,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, limit: '84 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -67,6 +73,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, limit: '75 KB', + disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -90,6 +97,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, limit: '88 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -97,6 +105,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, limit: '101 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Feedback)', @@ -104,6 +113,7 @@ module.exports = [ import: createImport('init', 'feedbackIntegration'), gzip: true, limit: '44 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. sendFeedback)', @@ -111,6 +121,7 @@ module.exports = [ import: createImport('init', 'sendFeedback'), gzip: true, limit: '32 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. FeedbackAsync)', @@ -118,6 +129,7 @@ module.exports = [ import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, limit: '36 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Metrics)', @@ -125,6 +137,7 @@ module.exports = [ import: createImport('init', 'metrics'), gzip: true, limit: '28 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Logs)', @@ -132,6 +145,7 @@ module.exports = [ import: createImport('init', 'logger'), gzip: true, limit: '28 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/browser (incl. Metrics & Logs)', @@ -139,6 +153,7 @@ module.exports = [ import: createImport('init', 'metrics', 'logger'), gzip: true, limit: '29 KB', + disablePlugins: ['@size-limit/esbuild'], }, // React SDK (ESM) { @@ -148,6 +163,7 @@ module.exports = [ ignore: ['react/jsx-runtime'], gzip: true, limit: '28 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/react (incl. Tracing)', @@ -156,6 +172,7 @@ module.exports = [ ignore: ['react/jsx-runtime'], gzip: true, limit: '47 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Vue SDK (ESM) { @@ -164,6 +181,7 @@ module.exports = [ import: createImport('init'), gzip: true, limit: '31 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/vue (incl. Tracing)', @@ -171,6 +189,7 @@ module.exports = [ import: createImport('init', 'browserTracingIntegration'), gzip: true, limit: '46 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) { @@ -179,6 +198,7 @@ module.exports = [ import: createImport('init'), gzip: true, limit: '26 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles { @@ -186,54 +206,63 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: true, limit: '29 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, limit: '46.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, limit: '31 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, limit: '47.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, limit: '70 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, limit: '83.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, limit: '84.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, limit: '89 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, limit: '91 KB', + disablePlugins: ['@size-limit/esbuild'], }, // browser CDN bundles (non-gzipped) { @@ -242,6 +271,7 @@ module.exports = [ gzip: false, brotli: false, limit: '84 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', @@ -249,6 +279,7 @@ module.exports = [ gzip: false, brotli: false, limit: '138 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -256,6 +287,7 @@ module.exports = [ gzip: false, brotli: false, limit: '89 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -263,6 +295,7 @@ module.exports = [ gzip: false, brotli: false, limit: '141.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -270,6 +303,7 @@ module.exports = [ gzip: false, brotli: false, limit: '212 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -277,6 +311,7 @@ module.exports = [ gzip: false, brotli: false, limit: '255.5 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', @@ -284,6 +319,7 @@ module.exports = [ gzip: false, brotli: false, limit: '259 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -291,6 +327,7 @@ module.exports = [ gzip: false, brotli: false, limit: '269 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', @@ -298,6 +335,7 @@ module.exports = [ gzip: false, brotli: false, limit: '273 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) { @@ -307,6 +345,7 @@ module.exports = [ ignore: ['next/router', 'next/constants'], gzip: true, limit: '49 KB', + disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) { @@ -316,6 +355,7 @@ module.exports = [ ignore: ['$app/stores'], gzip: true, limit: '45 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Node-Core SDK (ESM) { @@ -325,6 +365,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '60 KB', + disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) { @@ -334,6 +375,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '177 KB', + disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/node - without tracing', @@ -341,6 +383,7 @@ module.exports = [ import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, limit: '100 KB', + disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -364,6 +407,51 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '117 KB', + disablePlugins: ['@size-limit/esbuild'], + }, + // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output + { + name: '@sentry/cloudflare (withSentry) - minified', + path: 'packages/cloudflare/build/esm/index.js', + import: createImport('withSentry', 'instrumentDurableObjectWithSentry', 'instrumentWorkflowWithSentry'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: false, + brotli: false, + limit: '250 KiB', + disablePlugins: ['@size-limit/webpack'], + webpack: false, + modifyEsbuildConfig: function (config) { + config.keepNames = true; + // Match wrangler's build settings + config.conditions = ['workerd', 'worker', 'browser']; + config.platform = 'browser'; + config.format = 'esm'; + return config; + }, + }, + // Cloudflare SDK (ESM) - uncompressed, unminified to match `wrangler deploy --dry-run` output + { + name: '@sentry/cloudflare (withSentry)', + path: 'packages/cloudflare/build/esm/index.js', + import: createImport('withSentry', 'instrumentDurableObjectWithSentry', 'instrumentWorkflowWithSentry'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: false, + brotli: false, + limit: '420 KiB', + disablePlugins: ['@size-limit/webpack'], + webpack: false, + modifyEsbuildConfig: function (config) { + config.minify = false; + config.minifyIdentifiers = false; + config.minifySyntax = false; + config.minifyWhitespace = false; + config.keepNames = true; + // Match wrangler's build settings + config.conditions = ['workerd', 'worker', 'browser']; + config.platform = 'browser'; + config.format = 'esm'; + return config; + }, }, ]; diff --git a/package.json b/package.json index ce569de85547..e39ffa6635da 100644 --- a/package.json +++ b/package.json @@ -122,8 +122,9 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@rollup/pluginutils": "^5.1.0", - "@size-limit/file": "~11.1.6", - "@size-limit/webpack": "~11.1.6", + "@size-limit/file": "~12.1.0", + "@size-limit/webpack": "~12.1.0", + "@size-limit/esbuild": "~12.1.0", "@types/jsdom": "^21.1.6", "@types/node": "^18.19.1", "@vitest/coverage-v8": "^3.2.4", @@ -142,7 +143,7 @@ "rollup": "^4.59.0", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-license": "^3.3.1", - "size-limit": "~11.1.6", + "size-limit": "~12.1.0", "sucrase": "^3.35.0", "ts-node": "10.9.2", "typescript": "~5.8.0", diff --git a/yarn.lock b/yarn.lock index bda61b7744ca..0d633a8582bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3373,6 +3373,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -3413,6 +3418,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + "@esbuild/android-arm@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" @@ -3458,6 +3468,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + "@esbuild/android-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" @@ -3498,6 +3513,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + "@esbuild/darwin-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" @@ -3538,6 +3558,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256" integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + "@esbuild/darwin-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" @@ -3578,6 +3603,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + "@esbuild/freebsd-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" @@ -3618,6 +3648,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + "@esbuild/freebsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" @@ -3658,6 +3693,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + "@esbuild/linux-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" @@ -3698,6 +3738,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + "@esbuild/linux-arm@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" @@ -3738,6 +3783,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + "@esbuild/linux-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" @@ -3778,6 +3828,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + "@esbuild/linux-loong64@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239" @@ -3828,6 +3883,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + "@esbuild/linux-mips64el@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" @@ -3868,6 +3928,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + "@esbuild/linux-ppc64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" @@ -3908,6 +3973,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + "@esbuild/linux-riscv64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" @@ -3948,6 +4018,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + "@esbuild/linux-s390x@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" @@ -3988,6 +4063,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + "@esbuild/linux-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" @@ -4028,6 +4108,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + "@esbuild/netbsd-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" @@ -4043,6 +4128,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + "@esbuild/netbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" @@ -4083,6 +4173,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + "@esbuild/openbsd-arm64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" @@ -4103,6 +4198,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + "@esbuild/openbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" @@ -4143,6 +4243,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + "@esbuild/openharmony-arm64@0.25.12": version "0.25.12" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" @@ -4158,6 +4263,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + "@esbuild/sunos-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" @@ -4198,6 +4308,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + "@esbuild/win32-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" @@ -4238,6 +4353,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + "@esbuild/win32-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" @@ -4278,6 +4398,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + "@esbuild/win32-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" @@ -4318,6 +4443,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -7869,18 +7999,26 @@ "@sinonjs/commons" "^3.0.1" type-detect "^4.1.0" -"@size-limit/file@~11.1.6": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-11.1.6.tgz#de1244aef06081a93bd594ddc28ef14080ca5b01" - integrity sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ== +"@size-limit/esbuild@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/esbuild/-/esbuild-12.1.0.tgz#d0527ee8eed98794966b089c7fe61760cf4e6c7b" + integrity sha512-Um6MVrX+05kIxI4+zk0ZByG9dA/Th1f+sfGc571D95BnCPc90/pl2+2OdsQuOyoWEbeAMqfcTKo0v07i+E65Vw== + dependencies: + esbuild "^0.28.0" + nanoid "^5.1.7" -"@size-limit/webpack@~11.1.6": - version "11.1.6" - resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-11.1.6.tgz#a73f5b82a88d0896e45697863370e7a56e6cf2b9" - integrity sha512-PTZCgwJsgdzdEj2wPFuLm0cCge8N2WbswMcKWNwMJibxQxPAmiF+sZ2F6GYBS7G7K3Fb4ovCliuN+wnnRACPNg== +"@size-limit/file@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-12.1.0.tgz#3e0740e98cbb5c46c7e53939a37df5de68269fe2" + integrity sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ== + +"@size-limit/webpack@~12.1.0": + version "12.1.0" + resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-12.1.0.tgz#34dae69cc571c504486e055face540f180cf1fb8" + integrity sha512-3v/evOHskR0eVD6hpRO9jFpRPkpJb+GhqKJGxrqf1j1ZeoYu9A8v8iT3+U4TJOXTZbCbJ+Q6lWRBPFEcLZbNuw== dependencies: - nanoid "^5.0.7" - webpack "^5.95.0" + nanoid "^5.1.7" + webpack "^5.106.1" "@smithy/abort-controller@^4.2.8": version "4.2.8" @@ -10920,10 +11058,10 @@ acorn@8.11.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: - version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +acorn@^8.0.4, acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.7.0, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -13196,7 +13334,7 @@ check-error@^2.1.1: optionalDependencies: fsevents "~2.3.2" -chokidar@^4.0.0, chokidar@^4.0.1, chokidar@^4.0.3: +chokidar@^4.0.0, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== @@ -15625,10 +15763,10 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.4, enhanced-resolve@^5.18.0: - version "5.19.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" - integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== +enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.4, enhanced-resolve@^5.18.0, enhanced-resolve@^5.20.0: + version "5.20.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz#eeeb3966bea62c348c40a0cc9e7912e2557d0be0" + integrity sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA== dependencies: graceful-fs "^4.2.4" tapable "^2.3.0" @@ -16383,6 +16521,38 @@ esbuild@^0.27.2: "@esbuild/win32-ia32" "0.27.2" "@esbuild/win32-x64" "0.27.2" +esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -20067,7 +20237,7 @@ jiti@^1.19.3, jiti@^1.21.0, jiti@^1.21.6: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== -jiti@^2.0.0, jiti@^2.1.2, jiti@^2.4.2, jiti@^2.6.1: +jiti@^2.1.2, jiti@^2.4.2, jiti@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== @@ -20573,7 +20743,7 @@ license-webpack-plugin@4.0.2: dependencies: webpack-sources "^3.0.0" -lilconfig@^3.1.2, lilconfig@^3.1.3: +lilconfig@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== @@ -22504,10 +22674,10 @@ nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.8: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== -nanoid@^5.0.7, nanoid@^5.1.0: - version "5.1.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.6.tgz#30363f664797e7d40429f6c16946d6bd7a3f26c9" - integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg== +nanoid@^5.1.0, nanoid@^5.1.7: + version "5.1.9" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.9.tgz#aac959acf7d685269fb1be7f70a90d9db0848948" + integrity sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw== nanomatch@^1.2.9: version "1.2.13" @@ -22526,12 +22696,12 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -nanospinner@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97" - integrity sha512-yFvNYMig4AthKYfHFl1sLj7B2nkHL4lzdig4osvl9/LdGbXwrdFRoqBS98gsEsOakr0yH+r5NZ/1Y9gdVB8trA== +nanospinner@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.2.2.tgz#5a38f4410b5bf7a41585964bee74d32eab3e040b" + integrity sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA== dependencies: - picocolors "^1.0.0" + picocolors "^1.1.1" nanotar@^0.2.0: version "0.2.0" @@ -24369,10 +24539,10 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== pidtree@^0.6.0: version "0.6.0" @@ -27080,7 +27250,7 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: +serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== @@ -27484,18 +27654,16 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -size-limit@~11.1.6: - version "11.1.6" - resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-11.1.6.tgz#75cd54f9326d1b065ebcb6ca9ec27294e7ccdfb1" - integrity sha512-S5ux2IB8rU26xwVgMskmknGMFkieaIAqDLuwgKiypk6oa4lFsie8yFPrzRFV+yrLDY2GddjXuCaVk5PveVOHiQ== +size-limit@~12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-12.1.0.tgz#4e62d95773f3ea86d4dac7727fa6de8478050782" + integrity sha512-VnDS2fycANrJFVPQwjaD+h+hkISY7EB3LsPsYWje4lBCjQwwsZLxjwwRwVJKHrcj2ZqyG+DdXykWm9mbZklZrw== dependencies: bytes-iec "^3.1.1" - chokidar "^4.0.1" - jiti "^2.0.0" - lilconfig "^3.1.2" - nanospinner "^1.1.0" - picocolors "^1.1.0" - tinyglobby "^0.2.7" + lilconfig "^3.1.3" + nanospinner "^1.2.2" + picocolors "^1.1.1" + tinyglobby "^0.2.16" skip-regex@^1.0.2: version "1.0.2" @@ -28684,15 +28852,14 @@ terracotta@^1.0.4: dependencies: solid-use "^0.8.0" -terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.3.16: - version "5.3.16" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" - integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== +terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.3.16, terser-webpack-plugin@^5.3.17: + version "5.4.0" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz#95fc4cf4437e587be11ecf37d08636089174d76b" + integrity sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g== dependencies: "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" schema-utils "^4.3.0" - serialize-javascript "^6.0.2" terser "^5.31.1" terser@5.14.2: @@ -28888,13 +29055,13 @@ tinyglobby@0.2.14: fdir "^6.4.4" picomatch "^4.0.2" -tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.2, tinyglobby@^0.2.7: - version "0.2.15" - resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" - integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== +tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.16, tinyglobby@^0.2.2: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== dependencies: fdir "^6.5.0" - picomatch "^4.0.3" + picomatch "^4.0.4" tinypool@2.1.0: version "2.1.0" @@ -30483,7 +30650,7 @@ watch-detector@^1.0.0, watch-detector@^1.0.2: silent-error "^1.1.1" tmp "^0.1.0" -watchpack@^2.4.0, watchpack@^2.4.4: +watchpack@^2.4.0, watchpack@^2.4.4, watchpack@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== @@ -30617,10 +30784,10 @@ webpack-merge@5.8.0: clone-deep "^4.0.1" wildcard "^2.0.0" -webpack-sources@^3.0.0, webpack-sources@^3.2.3, webpack-sources@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" - integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== +webpack-sources@^3.0.0, webpack-sources@^3.2.3, webpack-sources@^3.3.3, webpack-sources@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" + integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== webpack-subresource-integrity@5.1.0: version "5.1.0" @@ -30664,7 +30831,37 @@ webpack@5.76.1: watchpack "^2.4.0" webpack-sources "^3.2.3" -webpack@^5.0.0, webpack@^5.95.0, webpack@~5.104.1: +webpack@^5.0.0, webpack@^5.106.1, webpack@^5.95.0: + version "5.106.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5" + integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.16.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.20.0" + es-module-lexer "^2.0.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + loader-runner "^4.3.1" + mime-db "^1.54.0" + neo-async "^2.6.2" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.17" + watchpack "^2.5.1" + webpack-sources "^3.3.4" + +webpack@~5.104.1: version "5.104.1" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.104.1.tgz#94bd41eb5dbf06e93be165ba8be41b8260d4fb1a" integrity sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA== From 3c6078fda7f3486005fd77774bf319e0c1cd1354 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 27 Apr 2026 10:50:17 +0200 Subject: [PATCH 39/77] feat(sveltekit): Support span streaming in `svelteKitSpansEnhancement` integration (#20496) Adds a `processSpan` hook alongside the existing `preprocessEvent` so SvelteKit-emitted spans get enhanced with the right `sentry.op` / `sentry.origin` in span-streaming mode. The new `_enhanceKitSpanStreamed` mirrors `_enhanceKitSpan` for streamed spans. Refactored the lookup logic into a shared helper and added span streaming unit tests (that also exactly mirror the existing unit tests for non-streamed spans). Closes https://github.com/getsentry/sentry-javascript/issues/20364 --- .../integrations/svelteKitSpans.ts | 87 ++++++++------ .../integrations/svelteKitSpans.test.ts | 113 +++++++++++++++++- 2 files changed, 163 insertions(+), 37 deletions(-) diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index c38108c75542..fe5d1ed31e23 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -1,5 +1,9 @@ -import type { Integration, SpanJSON, SpanOrigin } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import type { Integration, SpanJSON, SpanOrigin, StreamedSpanJSON } from '@sentry/core'; +import { + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; /** * A small integration that preprocesses spans so that SvelteKit-generated spans @@ -20,6 +24,9 @@ export function svelteKitSpansIntegration(): Integration { event.spans?.forEach(_enhanceKitSpan); } }, + processSpan(span) { + _enhanceKitSpanStreamed(span); + }, }; } @@ -28,51 +35,63 @@ export function svelteKitSpansIntegration(): Integration { * @exported for testing */ export function _enhanceKitSpan(span: SpanJSON): void { - let op: string | undefined = undefined; - let origin: SpanOrigin | undefined = undefined; - - const spanName = span.description; + const { op, origin } = _getKitSpanEnhancement(span.description); const previousOp = span.op || span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]; const previousOrigin = span.origin || span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]; + if (!previousOp && op) { + span.op = op; + span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + } + + if ((!previousOrigin || previousOrigin === 'manual') && origin) { + span.origin = origin; + span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + } +} + +/** + * Streaming-mode counterpart of {@link _enhanceKitSpan} operating on {@link StreamedSpanJSON}. + * @exported for testing + */ +export function _enhanceKitSpanStreamed(span: StreamedSpanJSON): void { + const { op, origin } = _getKitSpanEnhancement(span.name); + const previousOrigin = span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined; + + if (op) { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op }); + } + + if (previousOrigin === 'manual' && origin) { + // `safeSetSpanJSONAttributes` skips existing keys, so overwrite the 'manual' sentinel directly. + span.attributes![SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + } else { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }); + } +} + +function _getKitSpanEnhancement(spanName: string | undefined): { + op?: string; + origin?: SpanOrigin; +} { switch (spanName) { case 'sveltekit.resolve': - op = 'function.sveltekit.resolve'; - origin = 'auto.http.sveltekit'; - break; + return { op: 'function.sveltekit.resolve', origin: 'auto.http.sveltekit' }; case 'sveltekit.load': - op = 'function.sveltekit.load'; - origin = 'auto.function.sveltekit.load'; - break; + return { op: 'function.sveltekit.load', origin: 'auto.function.sveltekit.load' }; case 'sveltekit.form_action': - op = 'function.sveltekit.form_action'; - origin = 'auto.function.sveltekit.action'; - break; + return { op: 'function.sveltekit.form_action', origin: 'auto.function.sveltekit.action' }; case 'sveltekit.remote.call': - op = 'function.sveltekit.remote'; - origin = 'auto.rpc.sveltekit.remote'; - break; + return { op: 'function.sveltekit.remote', origin: 'auto.rpc.sveltekit.remote' }; case 'sveltekit.handle.root': // We don't want to overwrite the root handle span at this point since // we already enhance the root span in our `sentryHandle` hook. - break; - default: { + return {}; + default: if (spanName?.startsWith('sveltekit.handle.sequenced.')) { - op = 'function.sveltekit.handle'; - origin = 'auto.function.sveltekit.handle'; + return { op: 'function.sveltekit.handle', origin: 'auto.function.sveltekit.handle' }; } - break; - } - } - - if (!previousOp && op) { - span.op = op; - span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; - } - - if ((!previousOrigin || previousOrigin === 'manual') && origin) { - span.origin = origin; - span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; + return {}; } } diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts index 0d95cb3d6fb6..b051d613aad1 100644 --- a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts +++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts @@ -1,14 +1,19 @@ -import type { SpanJSON, TransactionEvent } from '@sentry/core'; +import type { SpanJSON, StreamedSpanJSON, TransactionEvent } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { describe, expect, it } from 'vitest'; -import { _enhanceKitSpan, svelteKitSpansIntegration } from '../../../src/server-common/integrations/svelteKitSpans'; +import { + _enhanceKitSpan, + _enhanceKitSpanStreamed, + svelteKitSpansIntegration, +} from '../../../src/server-common/integrations/svelteKitSpans'; describe('svelteKitSpansIntegration', () => { - it('has a name and a preprocessEventHook', () => { + it('has a name and a preprocessEvent and processSpan hook', () => { const integration = svelteKitSpansIntegration(); expect(integration.name).toBe('SvelteKitSpansEnhancement'); expect(typeof integration.preprocessEvent).toBe('function'); + expect(typeof integration.processSpan).toBe('function'); }); it('enhances spans from SvelteKit', () => { @@ -169,4 +174,106 @@ describe('svelteKitSpansIntegration', () => { expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); }); }); + + describe('_enhanceKitSpanStreamed', () => { + function makeStreamedSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'unspecified', + span_id: '123', + trace_id: 'abc', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes: {}, + ...overrides, + }; + } + + it.each([ + ['sveltekit.resolve', 'function.sveltekit.resolve', 'auto.http.sveltekit'], + ['sveltekit.load', 'function.sveltekit.load', 'auto.function.sveltekit.load'], + ['sveltekit.form_action', 'function.sveltekit.form_action', 'auto.function.sveltekit.action'], + ['sveltekit.remote.call', 'function.sveltekit.remote', 'auto.rpc.sveltekit.remote'], + ['sveltekit.handle.sequenced.0', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'], + ['sveltekit.handle.sequenced.myHandler', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'], + ])('enhances %s span with the correct op and origin', (spanName, op, origin) => { + const span = makeStreamedSpan({ name: spanName, attributes: { someAttribute: 'someValue' } }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe(op); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(origin); + }); + + it("doesn't change spans from other origins", () => { + const span = makeStreamedSpan({ name: 'someOtherSpan' }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBeUndefined(); + }); + + it("doesn't overwrite the sveltekit.handle.root span", () => { + const rootHandleSpan = makeStreamedSpan({ + name: 'sveltekit.handle.root', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', + }, + }); + + _enhanceKitSpanStreamed(rootHandleSpan); + + expect(rootHandleSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server'); + expect(rootHandleSpan.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit'); + }); + + it("doesn't enhance unrelated spans", () => { + const span = makeStreamedSpan({ + name: 'someOtherSpan', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.pg', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('db'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.db.pg'); + }); + + it("doesn't overwrite already set ops or origins on sveltekit spans", () => { + // for example, if users manually set this (for whatever reason) + const span = makeStreamedSpan({ + name: 'sveltekit.resolve', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.custom.origin', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.custom.origin'); + }); + + it('overwrites previously set "manual" origins on sveltekit spans', () => { + const span = makeStreamedSpan({ + name: 'sveltekit.resolve', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', + }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op'); + expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit'); + }); + }); }); From cdd8b0850d195179ed7241348935abd001ac1c95 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 27 Apr 2026 10:54:25 +0200 Subject: [PATCH 40/77] fix(supabase): Consider `sendDefaultPii` for supabase integration (#20490) We did not consider `sendDefaultPii` for the supabase integration. However: > The Supabase integration captures the full request body of POST/PATCH/PUT/DELETE operations (database mutations) and attaches it as the 'db.body' span attribute (line 387). This body contains the actual data being inserted or updated in Supabase tables, which commonly includes PII such as user emails, names, addresses, and other sensitive fields. Unlike other integrations (e.g., the MCP server integration which checks sendDefaultPii), the Supabase integration performs no sendDefaultPii check and applies no filtering or redaction to the captured body. Additionally, query filter values from URL search parameters are captured at lines 351-355, which can also contain PII used in WHERE clauses. This PR fixes this. --- .../supabase/db-operations/init.js | 1 + .../supabase-nextjs/tests/performance.test.ts | 43 +-- packages/core/src/integrations/supabase.ts | 51 +++- .../test/lib/integrations/supabase.test.ts | 268 ++++++++++++++---- 4 files changed, 272 insertions(+), 91 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js index 0f600426009f..30928883d2d8 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js @@ -9,6 +9,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], tracesSampleRate: 1.0, + sendDefaultPii: true, }); // Simulate database operations diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts index cfb66b372420..40ce1462fcfd 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -57,37 +57,16 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', const transactionEvent = await pageloadTransactionPromise; - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', - data: expect.objectContaining({ - 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], - 'db.system': 'postgresql', - 'sentry.op': 'db', - 'sentry.origin': 'auto.db.supabase', - }), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.db.supabase', - }), - ); - - expect(transactionEvent.spans).toContainEqual({ + // Client uses default sendDefaultPii: false — URL filters and bodies are not attached to spans/breadcrumbs. + const redactedSelectSpan = expect.objectContaining({ + description: '[redacted] from(todos)', + op: 'db', data: expect.objectContaining({ 'db.operation': 'select', - 'db.query': ['select(*)', 'filter(order, asc)'], 'db.system': 'postgresql', 'sentry.op': 'db', 'sentry.origin': 'auto.db.supabase', }), - description: 'select(*) filter(order, asc) from(todos)', - op: 'db', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -97,20 +76,26 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', origin: 'auto.db.supabase', }); + expect(transactionEvent.spans).toContainEqual(redactedSelectSpan); + + const selectSpan = transactionEvent.spans?.find( + (s: { description?: string }) => s.description === '[redacted] from(todos)', + ); + expect(selectSpan).toBeDefined(); + expect(selectSpan!.data).not.toHaveProperty('db.query'); + expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.select', - message: 'select(*) filter(order, asc) from(todos)', - data: expect.any(Object), + message: '[redacted] from(todos)', }); expect(transactionEvent.breadcrumbs).toContainEqual({ timestamp: expect.any(Number), type: 'supabase', category: 'db.insert', - message: 'insert(...) select(*) from(todos)', - data: expect.any(Object), + message: 'insert(...) [redacted] from(todos)', }); }); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index dac7530b46f0..14427c0ee3b7 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-lines */ import { addBreadcrumb } from '../breadcrumbs'; +import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { captureException } from '../exports'; import { defineIntegration } from '../integration'; @@ -148,6 +149,25 @@ function isInstrumented(fn: T): boolean | undefined { } } +/** + * Plain-object bodies are copied into `plainBody`; array inserts (and other non-plain shapes) stay only on `rawBody`. + * Returns a payload suitable for span attributes / breadcrumbs when the client has `sendDefaultPii` enabled. + */ +function getMutationBodyPayloadForTelemetry(rawBody: unknown, plainBody: Record): unknown | undefined { + if (Object.keys(plainBody).length > 0) { + return plainBody; + } + if (Array.isArray(rawBody) && rawBody.length > 0) { + return rawBody; + } + return undefined; +} + +/** True when the PostgREST builder carries a mutation body (for `insert(...)`, etc. in span descriptions). */ +function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record): boolean { + return getMutationBodyPayloadForTelemetry(rawBody, plainBody) !== undefined; +} + /** * Extracts the database operation type from the HTTP method and headers * @param method - The HTTP method of the request @@ -361,12 +381,19 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } } + const sendDefaultPii = Boolean(getClient()?.getOptions().sendDefaultPii); + const bodyPayload = getMutationBodyPayloadForTelemetry(typedThis.body, body); + // Adding operation to the beginning of the description if it's not a `select` operation // For example, it can be an `insert` or `update` operation but the query can be `select(...)` // For `select` operations, we don't need repeat it in the description - const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join( - ' ', - )} from(${table})`; + const mutationPart = + operation === 'select' + ? '' + : `${operation}${hasMutationBodyForDescription(typedThis.body, body) ? '(...) ' : ''}`; + const queryPart = sendDefaultPii ? queryItems.join(' ') : queryItems.length > 0 ? '[redacted]' : ''; + const descriptionMiddle = [mutationPart.trimEnd(), queryPart].filter(Boolean).join(' '); + const description = descriptionMiddle ? `${descriptionMiddle} from(${table})` : `from(${table})`; const attributes: Record = { 'db.table': table, @@ -379,12 +406,12 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', }; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { attributes['db.query'] = queryItems; } - if (Object.keys(body).length) { - attributes['db.body'] = body; + if (bodyPayload !== undefined && sendDefaultPii) { + attributes['db.body'] = bodyPayload; } return startSpan( @@ -413,11 +440,11 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte } const supabaseContext: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { supabaseContext.query = queryItems; } - if (Object.keys(body).length) { - supabaseContext.body = body; + if (bodyPayload !== undefined && sendDefaultPii) { + supabaseContext.body = bodyPayload; } captureException(err, scope => { @@ -444,12 +471,12 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte const data: Record = {}; - if (queryItems.length) { + if (queryItems.length && sendDefaultPii) { data.query = queryItems; } - if (Object.keys(body).length) { - data.body = body; + if (bodyPayload !== undefined && sendDefaultPii) { + data.body = bodyPayload; } if (Object.keys(data).length) { diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index 519dda4f06a0..6906d48ec98a 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -8,21 +8,113 @@ import { } from '../../../src/integrations/supabase'; import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; -// Mock tracing to avoid needing full SDK setup -vi.mock('../../../src/tracing', () => ({ - startSpan: (_opts: any, cb: (span: any) => any) => { +const tracingMocks = vi.hoisted(() => ({ + startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) => { const mockSpan = { setStatus: vi.fn(), end: vi.fn(), }; return cb(mockSpan); - }, + }), +})); + +const currentScopesMocks = vi.hoisted(() => ({ + getClient: vi.fn(), +})); + +// Mock tracing to avoid needing full SDK setup +vi.mock('../../../src/tracing', () => ({ + startSpan: tracingMocks.startSpan, setHttpStatus: vi.fn(), SPAN_STATUS_OK: 1, SPAN_STATUS_ERROR: 2, })); +vi.mock('../../../src/currentScopes', () => ({ + getClient: currentScopesMocks.getClient, +})); + +type CreateMockSupabaseClientOptions = { + method?: string; + url?: URL | string; + body?: unknown; + /** When set, configures the mocked Sentry client `sendDefaultPii`. Omit to leave `getClient` to the test file `beforeEach`. */ + sendDefaultPii?: boolean; +}; + +const DEFAULT_MOCK_SUPABASE_REST_URL = 'https://example.supabase.co/rest/v1/todos'; + +/** Shared PATCH + query string + body shape for `sendDefaultPii` tests. */ +const MOCK_SUPABASE_PII_SCENARIO: Pick = { + method: 'PATCH', + url: 'https://example.supabase.co/rest/v1/users?email=eq.secret%40example.com&select=id', + body: { full_name: 'Jane Doe', phone: '555-0100' }, +}; + +function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupabaseClientOptions): unknown { + if (options?.sendDefaultPii !== undefined) { + currentScopesMocks.getClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: options.sendDefaultPii }), + } as any); + } + + const method = options?.method ?? 'GET'; + const requestUrl = + options?.url !== undefined + ? options.url instanceof URL + ? options.url + : new URL(options.url) + : new URL(DEFAULT_MOCK_SUPABASE_REST_URL); + const body = options?.body; + + class MockPostgRESTFilterBuilder { + method = method; + headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + url = requestUrl; + schema = 'public'; + body = body; + + then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { + return Promise.resolve(resolveWith).then(onfulfilled, onrejected); + } + } + + class MockPostgRESTQueryBuilder { + select() { + return new MockPostgRESTFilterBuilder(); + } + insert() { + return new MockPostgRESTFilterBuilder(); + } + upsert() { + return new MockPostgRESTFilterBuilder(); + } + update() { + return new MockPostgRESTFilterBuilder(); + } + delete() { + return new MockPostgRESTFilterBuilder(); + } + } + + class MockSupabaseClient { + auth = { + admin: {} as any, + } as SupabaseClientInstance['auth']; + + from(_table: string): PostgRESTQueryBuilder { + return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; + } + } + + return new MockSupabaseClient(); +} + describe('Supabase Integration', () => { + beforeEach(() => { + currentScopesMocks.getClient.mockReturnValue(undefined); + }); + describe('extractOperation', () => { it('returns select for GET', () => { expect(extractOperation('GET')).toBe('select'); @@ -72,52 +164,6 @@ describe('Supabase Integration', () => { vi.restoreAllMocks(); }); - function createMockSupabaseClient(resolveWith: unknown): unknown { - // Create a PostgRESTFilterBuilder-like class - class MockPostgRESTFilterBuilder { - method = 'GET'; - headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; - url = new URL('https://example.supabase.co/rest/v1/todos'); - schema = 'public'; - body = undefined; - - then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { - return Promise.resolve(resolveWith).then(onfulfilled, onrejected); - } - } - - class MockPostgRESTQueryBuilder { - select() { - return new MockPostgRESTFilterBuilder(); - } - insert() { - return new MockPostgRESTFilterBuilder(); - } - upsert() { - return new MockPostgRESTFilterBuilder(); - } - update() { - return new MockPostgRESTFilterBuilder(); - } - delete() { - return new MockPostgRESTFilterBuilder(); - } - } - - // Create a mock SupabaseClient constructor - class MockSupabaseClient { - auth = { - admin: {} as any, - } as SupabaseClientInstance['auth']; - - from(_table: string): PostgRESTQueryBuilder { - return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; - } - } - - return new MockSupabaseClient(); - } - it('handles undefined response without throwing', async () => { const client = createMockSupabaseClient(undefined); instrumentSupabaseClient(client); @@ -176,4 +222,126 @@ describe('Supabase Integration', () => { expect(captureExceptionSpy).toHaveBeenCalled(); }); }); + + describe('sendDefaultPii', () => { + let captureExceptionSpy: ReturnType; + let addBreadcrumbSpy: ReturnType; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('omits db.query, db.body, and breadcrumb query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 200 }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('[redacted]'); + expect(spanOptions.name).not.toContain('secret'); + expect(spanOptions.attributes['db.query']).toBeUndefined(); + expect(spanOptions.attributes['db.body']).toBeUndefined(); + + const breadcrumb = addBreadcrumbSpy.mock.calls[0]![0] as { data?: unknown }; + expect(breadcrumb).not.toHaveProperty('data'); + }); + + it('includes db.query, db.body, and breadcrumb query/body when sendDefaultPii is true', async () => { + const client = createMockSupabaseClient({ status: 200 }, { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: true }); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toContain('eq(email, secret@example.com)'); + expect(spanOptions.attributes['db.query']).toEqual( + expect.arrayContaining([expect.stringContaining('secret@example.com')]), + ); + expect(spanOptions.attributes['db.body']).toEqual( + expect.objectContaining({ full_name: 'Jane Doe', phone: '555-0100' }), + ); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + query: expect.any(Array), + body: expect.objectContaining({ full_name: 'Jane Doe' }), + }), + }), + ); + }); + + it('omits supabase error context query/body when sendDefaultPii is false', async () => { + const client = createMockSupabaseClient( + { status: 400, error: { message: 'Bad request', code: '400' } }, + { ...MOCK_SUPABASE_PII_SCENARIO, sendDefaultPii: false }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('users').update({}).then(); + + expect(captureExceptionSpy).toHaveBeenCalled(); + const scopeCallback = captureExceptionSpy.mock.calls[0]![1] as (scope: { + addEventProcessor: (fn: (e: unknown) => unknown) => void; + setContext: (key: string, ctx: Record) => void; + }) => unknown; + const contexts: Record> = {}; + scopeCallback({ + addEventProcessor: () => {}, + setContext(key: string, ctx: Record) { + contexts[key] = ctx; + }, + } as any); + expect(contexts.supabase).toEqual({}); + }); + }); + + describe('array insert body', () => { + beforeEach(() => { + vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('includes insert(...) in span description and db.body when payload is a non-empty array', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { + method: 'POST', + url: 'https://example.supabase.co/rest/v1/todos?columns=', + body: [{ title: 'Test Todo' }], + sendDefaultPii: true, + }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').insert({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toMatch(/^insert\(\.\.\.\)/); + expect(spanOptions.name).toContain('from(todos)'); + expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]); + }); + }); }); From 15a6a8ba06bb4486a6892e52356ca12cc122cb85 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 27 Apr 2026 11:25:10 +0200 Subject: [PATCH 41/77] feat(core): Support attribute matching in `ignoreSpans` (#20512) Extends `ignoreSpans` with a new optional attributes field per the `ignoreSpans` [spec](https://develop.sentry.dev/sdk/telemetry/spans/filtering/#filter-with-ignorespans), letting users drop spans based on span attributes (in addition to name/description or op). String attribute values use pattern matching (substring / RegExp). Non-string values match by strict equality (arrays element-wise). Closes #20360 --- .size-limit.js | 24 ++++---- .../ignoreSpans-streamed/attributes/init.js | 11 ++++ .../attributes/subject.js | 11 ++++ .../ignoreSpans-streamed/attributes/test.ts | 35 ++++++++++++ .../ignoreSpans-streamed/segment/subject.js | 4 +- .../attributes/instrument.mjs | 12 ++++ .../attributes/server.mjs | 25 +++++++++ .../ignoreSpans-streamed/attributes/test.ts | 44 +++++++++++++++ packages/core/src/client.ts | 13 ++++- packages/core/src/envelope.ts | 5 +- packages/core/src/tracing/idleSpan.ts | 8 ++- packages/core/src/tracing/trace.ts | 1 + packages/core/src/types-hoist/options.ts | 41 +++++++++++++- packages/core/src/utils/should-ignore-span.ts | 39 +++++++++---- .../test/lib/utils/should-ignore-span.test.ts | 55 +++++++++++++++++++ packages/opentelemetry/src/sampler.ts | 12 +++- 16 files changed, 308 insertions(+), 32 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts diff --git a/.size-limit.js b/.size-limit.js index a61e489a8610..611bcdbe7017 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '26 KB', + limit: '27 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -40,7 +40,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '44 KB', + limit: '45 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -128,7 +128,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -197,7 +197,7 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '26 KB', + limit: '27 KB', disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles @@ -254,7 +254,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '89 KB', + limit: '90 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -270,7 +270,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '84 KB', + limit: '85 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '138 KB', + limit: '139 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -294,7 +294,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '141.5 KB', + limit: '142 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -302,7 +302,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '212 KB', + limit: '213 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -310,7 +310,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '255.5 KB', + limit: '256 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '259 KB', + limit: '260 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '269 KB', + limit: '270 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js new file mode 100644 index 000000000000..a6a4e1f4740b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + ignoreSpans: [{ attributes: { 'http.status_code': 200 } }], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js new file mode 100644 index 000000000000..741f4077d2ca --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/subject.js @@ -0,0 +1,11 @@ +// This segment span matches ignoreSpans via attributes — segment + child should be dropped +Sentry.startSpan({ name: 'health-check', attributes: { 'http.status_code': 200 } }, () => { + Sentry.startSpan({ name: 'child-of-ignored' }, () => {}); +}); + +setTimeout(() => { + // This segment span does NOT match — segment + child should be sent + Sentry.startSpan({ name: 'normal-segment', attributes: { 'http.status_code': 500 } }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); +}, 1000); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts new file mode 100644 index 000000000000..903e2d4e9e2b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest('attribute-matching ignoreSpans drops the trace', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + observeStreamedSpan(page, span => { + if (span.name === 'health-check' || span.name === 'child-of-ignored') { + throw new Error('Ignored span found'); + } + return false; + }); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment')); + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(url); + + expect((await spansPromise)?.length).toBe(2); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 2, reason: 'ignored' }]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js index 645668376b36..0878cd4e9ad6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -1,10 +1,10 @@ -// This segment span matches ignoreSpans — should NOT produce a transaction +// This segment span matches ignoreSpans — segment + child should be dropped Sentry.startSpan({ name: 'ignore-segment' }, () => { Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {}); }); setTimeout(() => { - // This segment span does NOT match — should produce a transaction + // This segment span does NOT match — segment + child should be sent Sentry.startSpan({ name: 'normal-segment' }, () => { Sentry.startSpan({ name: 'child-span' }, () => {}); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs new file mode 100644 index 000000000000..7cf67b82fc11 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'http.method': 'POST' } }], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs new file mode 100644 index 000000000000..116c27711ef7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/server.mjs @@ -0,0 +1,25 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/keep', (_req, res) => { + res.send({ status: 'kept' }); + setTimeout(() => { + // flush to avoid waiting for the span buffer timeout to send spans + // but defer it to the next tick to let the SDK finish the http.server span first. + Sentry.flush(); + }); +}); + +app.post('/drop', (_req, res) => { + res.send({ status: 'dropped' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts new file mode 100644 index 000000000000..22eac608d616 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/attributes/test.ts @@ -0,0 +1,44 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('filtering segment spans by attribute with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('segment spans matching an attribute filter are dropped including all children', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /keep' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + .start(); + + const dropRes = await runner.makeRequest('post', '/drop'); + expect((dropRes as { status: string }).status).toBe('dropped'); + + const keepRes = await runner.makeRequest('get', '/keep'); + expect((keepRes as { status: string }).status).toBe('kept'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 00c12db06855..62f94619e62f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1600,7 +1600,13 @@ function processBeforeSend( const rootSpanJson = convertTransactionEventToSpanJson(processedEvent); // 1.1 If the root span should be ignored, drop the whole transaction - if (ignoreSpans?.length && shouldIgnoreSpan(rootSpanJson, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan( + { description: rootSpanJson.description, op: rootSpanJson.op, attributes: rootSpanJson.data }, + ignoreSpans, + ) + ) { // dropping the whole transaction! return null; } @@ -1624,7 +1630,10 @@ function processBeforeSend( for (const span of initialSpans) { // 2.a If the child span should be ignored, reparent it to the root span - if (ignoreSpans?.length && shouldIgnoreSpan(span, ignoreSpans)) { + if ( + ignoreSpans?.length && + shouldIgnoreSpan({ description: span.description, op: span.op, attributes: span.data }, ignoreSpans) + ) { reparentChildSpans(initialSpans, span); continue; } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index dd91d077f45c..6b7b251c542c 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -142,7 +142,10 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; const filteredSpans = ignoreSpans?.length - ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) + ? spans.filter(span => { + const json = spanToJSON(span); + return !shouldIgnoreSpan({ description: json.description, op: json.op, attributes: json.data }, ignoreSpans); + }) : spans; const droppedSpans = spans.length - filteredSpans.length; diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index 53848a9c9191..884a8bb05497 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -179,7 +179,13 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // Ignored spans will get dropped later (in the client) but since we already adjust // the idle span end timestamp here, we can already take to-be-ignored spans out of // the calculation here. - if (ignoreSpans && shouldIgnoreSpan(currentSpanJson, ignoreSpans)) { + if ( + ignoreSpans && + shouldIgnoreSpan( + { description: currentSpanJson.description, op: currentSpanJson.op, attributes: currentSpanJson.data }, + ignoreSpans, + ) + ) { return acc; } return acc ? Math.max(acc, currentSpanJson.timestamp) : currentSpanJson.timestamp; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 08411722cedf..45379866d56e 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -610,6 +610,7 @@ function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: Se { description: spanArguments.name || '', op: spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] || spanArguments.op, + attributes: spanArguments.attributes, }, ignoreSpans, ); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 4f3df1f6365a..a1fc1e074a75 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -100,9 +100,17 @@ export interface ServerRuntimeOptions { onFatalError?(this: void, error: Error): void; } +/** + * Allowed attribute value matchers in `ignoreSpans` filters. + * String span attributes use pattern matching (substring or RegExp). + * Non-string attribute values match by strict equality (arrays element-wise). + */ +export type IgnoreSpanAttributeValue = string | boolean | number | string[] | boolean[] | number[] | RegExp; + /** * A filter object for ignoring spans. - * At least one of the properties (`op` or `name`) must be set. + * At least one of the properties (`name`, `op`, or `attributes`) must be set. + * If multiple are set, all must match for the span to be ignored. */ type IgnoreSpanFilter = | { @@ -114,6 +122,12 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; } | { /** @@ -124,6 +138,28 @@ type IgnoreSpanFilter = * Spans with an op matching this pattern will be ignored. */ op: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes?: Record; + } + | { + /** + * Spans with a name matching this pattern will be ignored. + */ + name?: string | RegExp; + /** + * Spans with an op matching this pattern will be ignored. + */ + op?: string | RegExp; + /** + * Spans whose attributes ALL match the corresponding entries will be ignored. + * String attribute values are matched as patterns (substring or RegExp). + * Non-string values match by strict equality (arrays element-wise). + */ + attributes: Record; }; export interface ClientOptions { @@ -326,7 +362,8 @@ export interface ClientOptions): void * Check if a span should be ignored based on the ignoreSpans configuration. */ export function shouldIgnoreSpan( - span: Pick, + span: Pick & { attributes?: Record }, ignoreSpans: Required['ignoreSpans'], ): boolean { - if (!ignoreSpans?.length || !span.description) { + if (!ignoreSpans?.length) { return false; } for (const pattern of ignoreSpans) { if (isStringOrRegExp(pattern)) { - if (isMatchingPattern(span.description, pattern)) { + if (span.description && isMatchingPattern(span.description, pattern)) { DEBUG_BUILD && logIgnoredSpan(span); return true; } continue; } - if (!pattern.name && !pattern.op) { + const hasAttributes = !!pattern.attributes && Object.keys(pattern.attributes).length > 0; + if (!pattern.name && !pattern.op && !hasAttributes) { continue; } - const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; + const nameMatches = pattern.name ? span.description && isMatchingPattern(span.description, pattern.name) : true; const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + const attrsMatch = pattern.attributes + ? Object.entries(pattern.attributes).every(([key, valuePattern]) => + _matchesAttributeValue(span.attributes?.[key], valuePattern), + ) + : true; // This check here is only correct because we can guarantee that we ran `isMatchingPattern` - // for at least one of `nameMatches` and `opMatches`. So in contrary to how this looks, - // not both op and name actually have to match. This is the most efficient way to check - // for all combinations of name and op patterns. - if (nameMatches && opMatches) { + // for at least one of `nameMatches`, `opMatches`, or `attrsMatch`. So in contrary to how this looks, + // not all of op, name, and attributes actually have to match. This is the most efficient way to check + // for all combinations of name, op, and attribute patterns. + if (nameMatches && opMatches && attrsMatch) { DEBUG_BUILD && logIgnoredSpan(span); return true; } @@ -48,6 +54,19 @@ export function shouldIgnoreSpan( return false; } +function _matchesAttributeValue(actual: unknown, pat: IgnoreSpanAttributeValue): boolean { + // String values support pattern matching + if (typeof actual === 'string' && (typeof pat === 'string' || pat instanceof RegExp)) { + return isMatchingPattern(actual, pat); + } + // Arrays: element-wise strict equality + if (Array.isArray(actual) && Array.isArray(pat)) { + return actual.length === pat.length && actual.every((v, i) => v === pat[i]); + } + // Primitives: strict equality + return actual === pat; +} + /** * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. * This mutates the spans array in place! diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts index a9aa3953b458..e329f3d7f00b 100644 --- a/packages/core/test/lib/utils/should-ignore-span.test.ts +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -103,6 +103,61 @@ describe('shouldIgnoreSpan', () => { expect(shouldIgnoreSpan({ description: 'GET /health', op: 'http.server' }, [{ op: 'http.server' }])).toBe(true); }); + describe('attribute matching', () => { + it.each([ + // strings: pattern matching (substring + regex) + ['GET', 'GE', true], + ['GET', 'POST', false], + ['GET', /^GET$/, true], + ['GET', /^POST$/, false], + // numbers: strict equality + [200, 200, true], + [404, 200, false], + // booleans: strict equality + [true, true, true], + [true, false, false], + // no type coercion across primitive types + [true, 'true', false], + // arrays: element-wise strict equality (one positive per element type, plus mismatch shapes) + [['a', 'b'], ['a', 'b'], true], + [['a', 'b'], ['a', 'c'], false], + [['a', 'b'], ['a'], false], + [[1, 2], [1, 2], true], + [[true, false], [true, false], true], + ])('matches attribute value %j against pattern %j → %s', (actual, pattern, expected) => { + const span = { description: 'span', op: 'op', attributes: { x: actual } }; + expect(shouldIgnoreSpan(span, [{ attributes: { x: pattern } }])).toBe(expected); + }); + + it('does not match when the attribute key is absent on the span', () => { + const span = { description: 'span', op: 'op', attributes: {} }; + expect(shouldIgnoreSpan(span, [{ attributes: { 'missing.key': 'x' } }])).toBe(false); + }); + + it('does not match a filter with an empty attributes object', () => { + const span = { description: 'foo', op: 'bar', attributes: { x: 1 } }; + expect(shouldIgnoreSpan(span, [{ attributes: {} }])).toBe(false); + }); + + it('requires every attribute entry to match', () => { + const span = { description: 'span', op: 'op', attributes: { a: 1, b: 2 } }; + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 2 } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ attributes: { a: 1, b: 3 } }])).toBe(false); + }); + + it('requires both name and attributes to match', () => { + const span = { description: 'GET /healthz', op: 'http.server', attributes: { 'http.method': 'GET' } }; + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'GET' } }])).toBe(true); + expect(shouldIgnoreSpan(span, [{ name: /healthz?/, attributes: { 'http.method': 'POST' } }])).toBe(false); + expect(shouldIgnoreSpan(span, [{ name: /other/, attributes: { 'http.method': 'GET' } }])).toBe(false); + }); + + it('still matches an attribute-only filter on a span without a description', () => { + const span = { description: undefined as unknown as string, op: undefined, attributes: { foo: 'bar' } }; + expect(shouldIgnoreSpan(span, [{ attributes: { foo: 'bar' } }])).toBe(true); + }); + }); + it('emits a debug log when a span is ignored', () => { const debugLogSpy = vi.spyOn(debug, 'log'); const span = { description: 'testDescription', op: 'testOp' }; diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 1e65e9d15d14..235ff3247f5d 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -96,7 +96,11 @@ export class SentrySampler implements Sampler { const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); if ( shouldIgnoreSpan( - { description: inferredChildName, op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp }, + { + description: inferredChildName, + op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp, + attributes: spanAttributes, + }, ignoreSpans, ) ) { @@ -144,7 +148,11 @@ export class SentrySampler implements Sampler { this._isSpanStreaming && ignoreSpans?.length && shouldIgnoreSpan( - { description: inferredSpanName, op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op }, + { + description: inferredSpanName, + op: mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? op, + attributes: mergedAttributes, + }, ignoreSpans, ) ) { From cd1b022a9c20c56d0efa6d2a7735835b2beb0815 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 27 Apr 2026 13:06:30 +0200 Subject: [PATCH 42/77] fix(core): Use symbol for normalization checks (#20486) This changes how we check for normalization meta data, making it harder to trick. Previously, theoretically some JSON.parsed payload that we normalize could have the fields set we check for normalization logic. With this change, we use a symbol which cannot be generated by JSON, improving on this. --- .size-limit.js | 6 ++-- packages/core/src/index.ts | 1 + .../core/src/integrations/extraerrordata.ts | 4 +-- packages/core/src/trpc.ts | 5 ++- packages/core/src/utils/normalizationHints.ts | 30 ++++++++++++++++ packages/core/src/utils/normalize.ts | 18 ++++------ packages/core/src/utils/object.ts | 4 +-- .../core/test/lib/utils/normalize.test.ts | 35 +++++++++++++++---- packages/react/src/redux.ts | 11 ++++-- packages/vue/src/pinia.ts | 11 ++++-- 10 files changed, 91 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/utils/normalizationHints.ts diff --git a/.size-limit.js b/.size-limit.js index 611bcdbe7017..8e1f3e79ee2f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -56,7 +56,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, - limit: '49 KB', + limit: '50 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '270 KB', + limit: '271 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -334,7 +334,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '273 KB', + limit: '275 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72fb619ca4f2..99aed84e4896 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -257,6 +257,7 @@ export { } from './utils/misc'; export { isNodeEnv, loadModule } from './utils/node'; export { normalize, normalizeToSize, normalizeUrlToBase } from './utils/normalize'; +export { setNormalizationDepthOverrideHint, setSkipNormalizationHint } from './utils/normalizationHints'; export { addNonEnumerableProperty, convertToPlainObject, diff --git a/packages/core/src/integrations/extraerrordata.ts b/packages/core/src/integrations/extraerrordata.ts index 59bac56908c3..8afde618af36 100644 --- a/packages/core/src/integrations/extraerrordata.ts +++ b/packages/core/src/integrations/extraerrordata.ts @@ -7,7 +7,7 @@ import type { IntegrationFn } from '../types-hoist/integration'; import { debug } from '../utils/debug-logger'; import { isError, isPlainObject } from '../utils/is'; import { normalize } from '../utils/normalize'; -import { addNonEnumerableProperty } from '../utils/object'; +import { setSkipNormalizationHint } from '../utils/normalizationHints'; import { truncate } from '../utils/string'; const INTEGRATION_NAME = 'ExtraErrorData'; @@ -66,7 +66,7 @@ function _enhanceEventWithErrorData( if (isPlainObject(normalizedErrorData)) { // We mark the error data as "already normalized" here, because we don't want other normalization procedures to // potentially truncate the data we just already normalized, with a certain depth setting. - addNonEnumerableProperty(normalizedErrorData, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(normalizedErrorData); contexts[exceptionName] = normalizedErrorData; } diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 3a661ca90a3d..610a8d14870f 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -3,7 +3,7 @@ import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; import { normalize } from './utils/normalize'; -import { addNonEnumerableProperty } from './utils/object'; +import { setNormalizationDepthOverrideHint } from './utils/normalizationHints'; interface SentryTrpcMiddlewareOptions { /** Whether to include procedure inputs in reported events. Defaults to `false`. */ @@ -53,9 +53,8 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { procedure_type: type, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( trpcContext, - '__sentry_override_normalization_depth__', 1 + // 1 for context.input + the normal normalization depth (clientOptions?.normalizeDepth ?? 5), // 5 is a sane depth ); diff --git a/packages/core/src/utils/normalizationHints.ts b/packages/core/src/utils/normalizationHints.ts new file mode 100644 index 000000000000..5787f8decbbc --- /dev/null +++ b/packages/core/src/utils/normalizationHints.ts @@ -0,0 +1,30 @@ +import { addNonEnumerableProperty } from './object'; + +/** + * Internal symbols for normalization behavior. JSON and other structured user payloads cannot + * carry these keys, so they cannot spoof SDK-only normalization hints. + * We use Symbol.for to ensure that the symbols are the same across different modules/files. + */ +const SENTRY_SKIP_NORMALIZATION = Symbol.for('sentry.skipNormalization'); +const SENTRY_OVERRIDE_NORMALIZATION_DEPTH = Symbol.for('sentry.overrideNormalizationDepth'); + +/** Marks an object so `normalize` returns it unchanged (already-normalized SDK data). */ +export function setSkipNormalizationHint(obj: object): void { + addNonEnumerableProperty(obj, SENTRY_SKIP_NORMALIZATION, true); +} + +/** Overrides remaining normalization depth from this object downward (e.g. Redux / Pinia state). */ +export function setNormalizationDepthOverrideHint(obj: object, depth: number): void { + addNonEnumerableProperty(obj, SENTRY_OVERRIDE_NORMALIZATION_DEPTH, depth); +} + +/** @internal */ +export function hasSkipNormalizationHint(value: object) { + return Boolean((value as Record)[SENTRY_SKIP_NORMALIZATION]); +} + +/** @internal */ +export function getNormalizationDepthOverrideHint(value: object): number | undefined { + const v = (value as Record)[SENTRY_OVERRIDE_NORMALIZATION_DEPTH]; + return typeof v === 'number' ? v : undefined; +} diff --git a/packages/core/src/utils/normalize.ts b/packages/core/src/utils/normalize.ts index 1c25d937cfe4..117d32b3ae4d 100644 --- a/packages/core/src/utils/normalize.ts +++ b/packages/core/src/utils/normalize.ts @@ -1,5 +1,6 @@ import type { Primitive } from '../types-hoist/misc'; import { isSyntheticEvent, isVueViewModel } from './is'; +import { getNormalizationDepthOverrideHint, hasSkipNormalizationHint } from './normalizationHints'; import { convertToPlainObject } from './object'; import { getFunctionName, getVueInternalName } from './stacktrace'; @@ -101,20 +102,15 @@ function visit( // From here on, we can assert that `value` is either an object or an array. - // Do not normalize objects that we know have already been normalized. As a general rule, the - // "__sentry_skip_normalization__" property should only be used sparingly and only should only be set on objects that - // have already been normalized. - if ((value as ObjOrArray)['__sentry_skip_normalization__']) { + // Do not normalize objects that we know have already been normalized. Hints use internal symbols + // (see normalizationHints.ts) so user-controlled JSON cannot spoof them. + if (hasSkipNormalizationHint(value)) { return value as ObjOrArray; } - // We can set `__sentry_override_normalization_depth__` on an object to ensure that from there - // We keep a certain amount of depth. - // This should be used sparingly, e.g. we use it for the redux integration to ensure we get a certain amount of state. - const remainingDepth = - typeof (value as ObjOrArray)['__sentry_override_normalization_depth__'] === 'number' - ? ((value as ObjOrArray)['__sentry_override_normalization_depth__'] as number) - : depth; + // Override remaining depth from this node (e.g. Redux / Pinia state). Set via setNormalizationDepthOverrideHint. + const overrideDepth = getNormalizationDepthOverrideHint(value); + const remainingDepth = overrideDepth !== undefined ? overrideDepth : depth; // We're also done if we've reached the max depth if (remainingDepth === 0) { diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index 787a66ac8525..34d8a267aacf 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -53,7 +53,7 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa * @param name The name of the property to be set * @param value The value to which to set the property */ -export function addNonEnumerableProperty(obj: object, name: string, value: unknown): void { +export function addNonEnumerableProperty(obj: object, name: string | symbol, value: unknown): void { try { Object.defineProperty(obj, name, { // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it @@ -62,7 +62,7 @@ export function addNonEnumerableProperty(obj: object, name: string, value: unkno configurable: true, }); } catch { - DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${name}" to object`, obj); + DEBUG_BUILD && debug.log(`Failed to add non-enumerable property "${String(name)}" to object`, obj); } } diff --git a/packages/core/test/lib/utils/normalize.test.ts b/packages/core/test/lib/utils/normalize.test.ts index 17c0628e53df..b296a8766f4f 100644 --- a/packages/core/test/lib/utils/normalize.test.ts +++ b/packages/core/test/lib/utils/normalize.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, test, vi } from 'vitest'; -import { addNonEnumerableProperty, normalize } from '../../../src'; +import { normalize, setNormalizationDepthOverrideHint, setSkipNormalizationHint } from '../../../src'; import * as isModule from '../../../src/utils/is'; import * as stacktraceModule from '../../../src/utils/stacktrace'; @@ -655,7 +655,28 @@ describe('normalize()', () => { }); }); - describe('skips normalizing objects marked with a non-enumerable property __sentry_skip_normalization__', () => { + describe('regression: JSON cannot spoof skip-normalization via string keys', () => { + test('__sentry_skip_normalization__ as an own string property is still normalized', () => { + function someFun(): void { + /* no-empty */ + } + const jsonLikePayload = { + __sentry_skip_normalization__: true, + nan: NaN, + fun: someFun, + }; + + const result = normalize(jsonLikePayload); + + expect(result).toEqual({ + __sentry_skip_normalization__: true, + nan: '[NaN]', + fun: '[Function: someFun]', + }); + }); + }); + + describe('skips normalizing objects marked with setSkipNormalizationHint (internal symbol)', () => { test('by leaving non-serializable values intact', () => { const someFun = () => undefined; const alreadyNormalizedObj = { @@ -663,7 +684,7 @@ describe('normalize()', () => { fun: someFun, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const result = normalize(alreadyNormalizedObj); expect(result).toEqual({ @@ -681,7 +702,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(alreadyNormalizedObj, '__sentry_skip_normalization__', true); + setSkipNormalizationHint(alreadyNormalizedObj); const obj = { foo: { @@ -703,7 +724,7 @@ describe('normalize()', () => { }); }); - describe('overrides normalization depth with a non-enumerable property __sentry_override_normalization_depth__', () => { + describe('overrides normalization depth with setNormalizationDepthOverrideHint', () => { test('by increasing depth if it is higher', () => { const normalizationTarget = { foo: 'bar', @@ -717,7 +738,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 3); + setNormalizationDepthOverrideHint(normalizationTarget, 3); const result = normalize(normalizationTarget, 1); @@ -745,7 +766,7 @@ describe('normalize()', () => { }, }; - addNonEnumerableProperty(normalizationTarget, '__sentry_override_normalization_depth__', 1); + setNormalizationDepthOverrideHint(normalizationTarget, 1); const result = normalize(normalizationTarget, 3); diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index e9c5eac8424e..b04510a68cc9 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Scope } from '@sentry/core'; -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; interface Action { type: T; @@ -138,9 +144,8 @@ function createReduxEnhancer(enhancerOptions?: Partial): // Set the normalization depth of the redux state to the configured `normalizeDepth` option or a sane number as a fallback const newStateContext = { state: { type: 'redux', value: transformedState } }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newStateContext, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState` normalizationDepth, // rest for the actual state ); diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index 596efb6ef182..df0a3c4d8938 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -1,4 +1,10 @@ -import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, getGlobalScope } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + getCurrentScope, + getGlobalScope, + setNormalizationDepthOverrideHint, +} from '@sentry/core'; import type { Ref } from 'vue'; // Inline Pinia types @@ -112,9 +118,8 @@ export const createSentryPiniaPlugin: ( state: piniaStateContext, }; - addNonEnumerableProperty( + setNormalizationDepthOverrideHint( newState, - '__sentry_override_normalization_depth__', 3 + // 3 layers for `state.value.transformedState normalizationDepth, // rest for the actual state ); From b44ff358109807170092db2ab880acb3ebd00b21 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:27:38 +0200 Subject: [PATCH 43/77] feat(hono)!: Change setup for `@sentry/hono/node` (`init` in external file) (#20497) Let's users set up Sentry in an external file. This makes it easier to filter any integrations. The setup is now slightly different than it is for Bun and Cloudflare, but the Node runtime just works differently. Closes https://github.com/getsentry/sentry-javascript/issues/20460 BREAKING CHANGE: `sentry` from `@sentry/hono/node` does not accept `options` anymore as those need to be passed in a separate `Sentry.init()` that is called in an external file: ```ts // instrument.mjs (or instrument.ts) import * as Sentry from '@sentry/hono/node'; Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0, }); ``` --- .../test-applications/hono-4/package.json | 2 +- .../hono-4/src/entry.node.ts | 12 +- .../hono-4/src/instrument.node.ts | 8 + .../hono-4/tests/constants.ts | 1 - .../hono-4/tests/middleware.test.ts | 59 +----- .../suites/hono-sdk/instrument.mjs | 9 +- .../suites/hono-sdk/scenario.mjs | 10 +- packages/hono/README.md | 62 ++++--- packages/hono/src/node/middleware.ts | 26 ++- packages/hono/src/node/sdk.ts | 13 +- packages/hono/test/node/middleware.test.ts | 153 +++++---------- packages/hono/test/node/sdk.test.ts | 175 ++++++++++++++++++ 12 files changed, 309 insertions(+), 221 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts create mode 100644 packages/hono/test/node/sdk.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 53519a1bd80c..ba07bb7db4ca 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", - "dev:node": "node --import tsx/esm --import @sentry/node/preload src/entry.node.ts", + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", "dev:bun": "bun src/entry.bun.ts", "build": "wrangler deploy --dry-run", "test:build": "pnpm install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts index eb2c669c6806..898a92e08be4 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -3,17 +3,9 @@ import { sentry } from '@sentry/hono/node'; import { serve } from '@hono/node-server'; import { addRoutes } from './routes'; -const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); +const app = new Hono(); -app.use( - // @ts-expect-error - Env is not yet in type - sentry(app, { - dsn: process.env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); +app.use(sentry(app)); addRoutes(app); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts new file mode 100644 index 000000000000..82f2a3864125 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/hono/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts index 74905baee532..5295914a7805 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts @@ -1,6 +1,5 @@ export type Runtime = 'cloudflare' | 'node' | 'bun'; export const RUNTIME = (process.env.RUNTIME || 'cloudflare') as Runtime; -export const isNode = RUNTIME === 'node'; export const APP_NAME = 'hono-4'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index 967d639baa35..e3b4556bfd18 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -1,27 +1,20 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { type SpanJSON } from '@sentry/core'; -import { APP_NAME, isNode } from './constants'; - -// In Node, @sentry/node/preload eagerly activates the OTel HonoInstrumentation, -// which wraps all Hono instance methods at construction time via WrappedHono. -const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; -const OTEL_ORIGIN = 'auto.http.otel.hono'; +import { APP_NAME } from './constants'; const SCENARIOS = [ { name: 'root app middleware', prefix: '/test-middleware', - origin: MIDDLEWARE_ORIGIN, }, { name: 'sub-app middleware (route group)', prefix: '/test-subapp-middleware', - origin: isNode ? OTEL_ORIGIN : MIDDLEWARE_ORIGIN, }, ] as const; -for (const { name, prefix, origin } of SCENARIOS) { +for (const { name, prefix } of SCENARIOS) { test.describe(name, () => { test('creates a span for named middleware', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { @@ -43,7 +36,7 @@ for (const { name, prefix, origin } of SCENARIOS) { expect.objectContaining({ description: 'middlewareA', op: 'middleware.hono', - origin, + origin: 'auto.middleware.hono', status: 'ok', }), ); @@ -68,18 +61,13 @@ for (const { name, prefix, origin } of SCENARIOS) { expect.objectContaining({ description: '', op: 'middleware.hono', - origin: MIDDLEWARE_ORIGIN, + origin: 'auto.middleware.hono', status: 'ok', }), ); }); test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { - test.skip( - isNode, - 'Node double-instruments middleware (too many spans) - TODO: fix this in the SDK and re-enable the test', - ); - const transactionPromise = waitForTransaction(APP_NAME, event => { return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${prefix}/multi`; }); @@ -90,7 +78,6 @@ for (const { name, prefix, origin } of SCENARIOS) { const transaction = await transactionPromise; const spans = transaction.spans || []; - // Sort spans because they are in a different order in Node/Bun (OTel-based) const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); expect(middlewareSpans).toHaveLength(2); @@ -139,10 +126,6 @@ for (const { name, prefix, origin } of SCENARIOS) { const transaction = await transactionPromise; const spans = transaction.spans || []; - // On the /error path only one middleware (failingMiddleware) is registered, - // so we can find the error span by status alone. On Node for sub-apps, the - // OTel layer wraps before patchRoute, so the function name may be lost in - // the patchRoute span — but the error status is always set. const failingSpan = spans.find( (span: SpanJSON) => span.op === 'middleware.hono' && span.status === 'internal_error', ); @@ -169,36 +152,8 @@ for (const { name, prefix, origin } of SCENARIOS) { }); } -test.describe('.all() handler on sub-app (method ALL edge case)', () => { - test('Node: OTel wraps .all() and produces a hono span', async ({ baseURL }) => { - test.skip(!isNode, 'Node-specific: OTel wraps .all() at construction time'); - - const transactionPromise = waitForTransaction(APP_NAME, event => { - return ( - event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' - ); - }); - - const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); - expect(response.status).toBe(200); - - const body = await response.json(); - expect(body).toEqual({ handler: 'all' }); - - const transaction = await transactionPromise; - const spans = transaction.spans || []; - - // On Node, OTel wraps .all() at construction time. Since the handler - // returns a Response, OTel classifies it as 'request_handler' (not - // middleware). patchRoute also wraps it but sees the anonymous OTel wrapper. - // Either way, the handler IS instrumented — verify any hono span exists. - const honoSpan = spans.find((span: SpanJSON) => span.op?.endsWith('.hono')); - expect(honoSpan).toBeDefined(); - }); - - test('Bun/Cloudflare: patchRoute wraps .all() as middleware span', async ({ baseURL }) => { - test.skip(isNode, 'Bun/Cloudflare-specific: patchRoute is the sole wrapper'); - +test.describe('patchRoute wraps .all() as middleware span (in sub-app)', () => { + test('patchRoute wraps .all() as middleware span', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { return ( event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' @@ -225,7 +180,7 @@ test.describe('.all() handler on sub-app (method ALL edge case)', () => { expect.objectContaining({ description: 'allCatchAll', op: 'middleware.hono', - origin: MIDDLEWARE_ORIGIN, + origin: 'auto.middleware.hono', status: 'ok', }), ); diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs index 508cbe487e91..77c35dce5589 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs @@ -1 +1,8 @@ -// Sentry is initialized by the @sentry/hono/node middleware in scenario.mjs +import * as Sentry from '@sentry/hono/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs index 92a08fcb5bb5..d27dc20bbc30 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs @@ -1,17 +1,11 @@ import { serve } from '@hono/node-server'; import { sentry } from '@sentry/hono/node'; -import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import { Hono } from 'hono'; const app = new Hono(); -app.use( - sentry(app, { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1.0, - transport: loggingTransport, - }), -); +app.use(sentry(app)); app.get('/', c => { return c.text('Hello from Hono on Node!'); diff --git a/packages/hono/README.md b/packages/hono/README.md index 236a4133bf67..c0d791030134 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -33,7 +33,7 @@ npm install @sentry/hono Additionally to `@sentry/hono`, install the `@sentry/cloudflare` package: -```bashbash +```bash npm install --save @sentry/cloudflare ``` @@ -100,54 +100,60 @@ export default app; Additionally to `@sentry/hono`, install the `@sentry/node` package: -```bashbash +```bash npm install --save @sentry/node ``` Make sure the installed version always stays in sync. The `@sentry/node` package is a required peer dependency when using `@sentry/hono/node`. You won't import `@sentry/node` directly in your code, but it needs to be installed in your project. -### 2. Initialize Sentry in your Hono app +### 2. Initialize Sentry in a separate file -Initialize the Sentry Hono middleware as early as possible in your app: +Create an `instrument.mjs` (or `instrument.ts`) file that initializes Sentry before the rest of your application runs. +This ensures Sentry can wrap third-party libraries (e.g. database clients) as early as possible: ```ts -import { Hono } from 'hono'; -import { serve } from '@hono/node-server'; -import { sentry } from '@sentry/hono/node'; - -const app = new Hono(); - -// Initialize Sentry middleware right after creating the app -app.use( - sentry(app, { - dsn: '__DSN__', // or process.env.SENTRY_DSN - tracesSampleRate: 1.0, - }), -); - -// ... your routes and other middleware +// instrument.mjs (or instrument.ts) +import * as Sentry from '@sentry/hono/node'; -serve(app); +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +}); ``` -### 3. Add `preload` script to start command - -To ensure that Sentry can capture spans from third-party libraries (e.g. database clients) used in your Hono app, Sentry needs to wrap these libraries as early as possible. +### 3. Load the instrument file with `--import` -When starting the Hono Node application, use the `@sentry/node/preload` hook with the `--import` CLI option to ensure modules are wrapped before the application code runs: +When starting your Hono Node application, use the `--import` CLI flag to load `instrument.mjs` before your app code: ```bash -node --import @sentry/node/preload index.js +node --import ./instrument.mjs app.js ``` This option can also be added to the `NODE_OPTIONS` environment variable: ```bash -NODE_OPTIONS="--import @sentry/node/preload" +NODE_OPTIONS="--import ./instrument.mjs" ``` -Read more about this preload script in the docs: https://docs.sentry.io/platforms/javascript/guides/hono/install/late-initialization/#late-initialization-with-esm +### 4. Add the Sentry middleware to your Hono app + +Add the `sentry` middleware to your Hono app. Since Sentry was already initialized in the instrument file, no options are passed here: + +```ts +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { sentry } from '@sentry/hono/node'; + +const app = new Hono(); + +// Add Sentry middleware right after creating the app +app.use(sentry(app)); + +// ... your routes and other middleware + +serve(app); +``` ## Setup (Bun) @@ -155,7 +161,7 @@ Read more about this preload script in the docs: https://docs.sentry.io/platform Additionally to `@sentry/hono`, install the `@sentry/bun` package: -```bashbash +```bash npm install --save @sentry/bun ``` diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 07d3c4ed2fa8..bcfd65d573c1 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,5 +1,4 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; -import { init } from './sdk'; +import { type BaseTransportOptions, debug, type Options, getClient } from '@sentry/core'; import type { Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { applyPatches } from '../shared/applyPatches'; @@ -7,14 +6,23 @@ import { applyPatches } from '../shared/applyPatches'; export interface HonoNodeOptions extends Options {} /** - * Sentry middleware for Hono running in a Node runtime environment. + * Sentry middleware for Hono applications running in a Node.js environment. + * + * This middleware enhances your Hono application by automatically instrumenting incoming requests and outgoing responses. + * It also applies the necessary patches to ensure Sentry captures execution context correctly in Node.js. + * + * **Note:** You must initialize Sentry separately before using this middleware. Typically, this is done by calling `Sentry.init()` in an `instrument.ts` file and loading it via the Node `--import` flag. */ -export const sentry = (app: Hono, options: HonoNodeOptions): MiddlewareHandler => { - const isDebug = options.debug; - - isDebug && debug.log('Initialized Sentry Hono middleware (Node)'); - - init(options); +export const sentry = (app: Hono): MiddlewareHandler => { + const sentryClient = getClient(); + if (sentryClient === undefined) { + debug.warn( + 'Sentry is not initialized. Call `init()` from @sentry/hono/node in an `instrument.ts` file loaded via `--import` to set up Sentry for your application.', + ); + } else { + sentryClient.getOptions().debug && + debug.log('Sentry is initialized, proceeding to set up Hono `sentry` middleware.'); + } applyPatches(app); diff --git a/packages/hono/src/node/sdk.ts b/packages/hono/src/node/sdk.ts index 936cf612bb44..419d71d765eb 100644 --- a/packages/hono/src/node/sdk.ts +++ b/packages/hono/src/node/sdk.ts @@ -1,5 +1,5 @@ import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, debug, getClient } from '@sentry/core'; import { init as initNode } from '@sentry/node'; import type { HonoNodeOptions } from './middleware'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; @@ -7,14 +7,17 @@ import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; /** * Initializes Sentry for Hono running in a Node runtime environment. * - * In general, it is recommended to initialize Sentry via the `sentry()` middleware, as it sets up everything by default and calls `init` internally. - * - * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. + * This function should be called in an `instrument.ts` file loaded via `--import` to set up Sentry globally for the application. */ export function init(options: HonoNodeOptions): Client | undefined { + const existingClient = getClient(); + if (existingClient) { + existingClient.getOptions().debug && debug.log('Sentry is already initialized, skipping re-initialization.'); + return existingClient; + } + applySdkMetadata(options, 'hono', ['hono', 'node']); - // Remove Hono from the SDK defaults to prevent double instrumentation: @sentry/node const filteredOptions: HonoNodeOptions = { ...options, integrations: buildFilteredIntegrations(options.integrations, false), diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index b6561098ed8a..546350fd8377 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -1,8 +1,8 @@ import * as SentryCore from '@sentry/core'; -import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sentry } from '../../src/node/middleware'; +import { init } from '../../src/node/sdk'; vi.mock('@sentry/node', () => ({ init: vi.fn(), @@ -11,139 +11,80 @@ vi.mock('@sentry/node', () => ({ // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { init: initNodeMock } = await vi.importMock('@sentry/node'); -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - applySdkMetadata: vi.fn(actual.applySdkMetadata), - }; -}); - -const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; - describe('Hono Node Middleware', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('sentry middleware', () => { - it('calls applySdkMetadata with "hono"', () => { + describe('sentry middleware (external init)', () => { + it('does not call init', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - sentry(app, options); + sentry(app); - expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); - expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono', ['hono', 'node']); + expect(initNodeMock).not.toHaveBeenCalled(); }); - it('calls init from @sentry/node', () => { + it('returns a middleware handler function', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - sentry(app, options); + const middleware = sentry(app); - expect(initNodeMock).toHaveBeenCalledTimes(1); - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }), - ); + expect(middleware).toBeDefined(); + expect(typeof middleware).toBe('function'); + expect(middleware).toHaveLength(2); }); - it('sets SDK metadata before calling Node init', () => { + it('returns an async middleware handler', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - sentry(app, options); - - const applySdkMetadataCallOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; - const initNodeCallOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + const middleware = sentry(app); - expect(applySdkMetadataCallOrder).toBeLessThan(initNodeCallOrder as number); + expect(middleware.constructor.name).toBe('AsyncFunction'); }); - it('preserves all user options', () => { - const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'production', - sampleRate: 0.5, - tracesSampleRate: 1.0, - debug: true, - }; - - sentry(app, options); - - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'production', - sampleRate: 0.5, - tracesSampleRate: 1.0, - debug: true, - }), - ); - }); + it('emits a warning when Sentry is not initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); - it('returns a middleware handler function', () => { const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; + sentry(app); - const middleware = sentry(app, options); - - expect(middleware).toBeDefined(); - expect(typeof middleware).toBe('function'); - expect(middleware).toHaveLength(2); // Hono middleware takes (context, next) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sentry is not initialized')); }); - it('returns an async middleware handler', () => { + it('does not emit a warning when Sentry is already initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + const fakeClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(fakeClient as unknown as SentryCore.Client); + const app = new Hono(); - const middleware = sentry(app, {}); + sentry(app); - expect(middleware.constructor.name).toBe('AsyncFunction'); + expect(warnSpy).not.toHaveBeenCalled(); }); + }); - it('passes an integrations function to initNode (never a raw array)', () => { - const app = new Hono(); - sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + describe('double-init guard', () => { + it('skips re-initialization when a client already exists', () => { + const fakeClient = { getOptions: () => ({}) }; + const getClientSpy = vi + .spyOn(SentryCore, 'getClient') + .mockReturnValue(fakeClient as unknown as SentryCore.Client); - const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; - expect(typeof callArgs.integrations).toBe('function'); + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(fakeClient); + expect(initNodeMock).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); }); - it('includes hono SDK metadata', () => { - const app = new Hono(); - const options = { - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }; - - sentry(app, options); - - expect(initNodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - _metadata: expect.objectContaining({ - sdk: expect.objectContaining({ - name: 'sentry.javascript.hono', - version: SDK_VERSION, - packages: [ - { name: 'npm:@sentry/hono', version: SDK_VERSION }, - { name: 'npm:@sentry/node', version: SDK_VERSION }, - ], - }), - }), - }), - ); + it('initializes normally when no client exists yet', () => { + const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + + getClientSpy.mockRestore(); }); }); }); diff --git a/packages/hono/test/node/sdk.test.ts b/packages/hono/test/node/sdk.test.ts new file mode 100644 index 000000000000..99f2ef5568e9 --- /dev/null +++ b/packages/hono/test/node/sdk.test.ts @@ -0,0 +1,175 @@ +import * as SentryCore from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { init } from '../../src/node/sdk'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn().mockReturnValue({ + /* fake client returned by node init */ + }), +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { init: initNodeMock } = await vi.importMock('@sentry/node'); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + applySdkMetadata: vi.fn(actual.applySdkMetadata), + }; +}); + +const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; + +describe('Hono Node SDK – init()', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + }); + + // ─── Happy path ─────────────────────────────────────────────────────────── + + it('calls applySdkMetadata with the correct SDK identifiers', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledWith(expect.any(Object), 'hono', ['hono', 'node']); + }); + + it('calls @sentry/node init with the provided DSN', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }), + ); + }); + + it('applies SDK metadata before calling @sentry/node init', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const metaOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; + const initOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + + expect(metaOrder).toBeLessThan(initOrder as number); + }); + + it('attaches correct SDK metadata (name, version, packages)', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.hono', + version: SDK_VERSION, + packages: [ + { name: 'npm:@sentry/hono', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], + }), + }), + }), + ); + }); + + it('preserves all user-supplied options', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }), + ); + }); + + it('always passes integrations as a function, never a raw array', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('wraps a user-supplied integrations array into a function', () => { + const userIntegration = { name: 'MyIntegration', setupOnce: vi.fn() }; + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [userIntegration], + }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('wraps a user-supplied integrations factory into a function', () => { + const factory = vi.fn((defaults: SentryCore.Integration[]) => defaults); + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: factory, + }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + + it('returns the value produced by @sentry/node init', () => { + const fakeClient = { getOptions: () => ({}) }; + (initNodeMock as Mock).mockReturnValueOnce(fakeClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(fakeClient); + }); + + // ─── Double-init guard ───────────────────────────────────────────────────── + + it('returns the existing client without re-initializing when already set up', () => { + const existingClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(existingClient); + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); + }); + + it('logs a debug message when skipping re-initialization', () => { + const logSpy = vi.spyOn(SentryCore.debug, 'log'); + const existingClient = { getOptions: () => ({ debug: true }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('already initialized')); + }); + + it('does not log when debug is false and skipping re-initialization', () => { + const logSpy = vi.spyOn(SentryCore.debug, 'log'); + const existingClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(existingClient as unknown as SentryCore.Client); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('proceeds with initialization when no client exists', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + }); +}); From 4932714642c8e7afcb157edcd378d7aae756f4b4 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 27 Apr 2026 13:48:56 +0200 Subject: [PATCH 44/77] feat(astro): Drop prerendered http.server filter via `ignoreSpans` (#20513) Migrates the Astro server pre-rendered route drop filter from an event processor to `ignoreSpans` so it also works in the streaming path. This only adds two unit tests, since the actual drop behavior is already tested by several astro e2e test apps. Closes https://github.com/getsentry/sentry-javascript/issues/20375 --- packages/astro/src/server/sdk.ts | 34 ++++++++------------------ packages/astro/test/server/sdk.test.ts | 22 +++++++++++++++++ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/astro/src/server/sdk.ts b/packages/astro/src/server/sdk.ts index 25dbb9416fe6..727350f2b046 100644 --- a/packages/astro/src/server/sdk.ts +++ b/packages/astro/src/server/sdk.ts @@ -1,5 +1,5 @@ import { applySdkMetadata } from '@sentry/core'; -import type { Event, NodeClient, NodeOptions } from '@sentry/node'; +import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; /** @@ -13,28 +13,14 @@ export function init(options: NodeOptions): NodeClient | undefined { applySdkMetadata(opts, 'astro', ['astro', 'node']); - const client = initNodeSdk(opts); + opts.ignoreSpans = [ + ...(opts.ignoreSpans || []), + // For http.server spans that did not go though the astro middleware, + // we want to drop them + // this is the case with http.server spans of prerendered pages + // we do not care about those, as they are effectively static + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]; - client?.addEventProcessor( - Object.assign( - (event: Event) => { - // For http.server spans that did not go though the astro middleware, - // we want to drop them - // this is the case with http.server spans of prerendered pages - // we do not care about those, as they are effectively static - if ( - event.type === 'transaction' && - event.contexts?.trace?.op === 'http.server' && - event.contexts?.trace?.origin === 'auto.http.otel.http' - ) { - return null; - } - - return event; - }, - { id: 'AstroHttpEventProcessor' }, - ), - ); - - return client; + return initNodeSdk(opts); } diff --git a/packages/astro/test/server/sdk.test.ts b/packages/astro/test/server/sdk.test.ts index 1d915152fdcc..19c80f4f46f0 100644 --- a/packages/astro/test/server/sdk.test.ts +++ b/packages/astro/test/server/sdk.test.ts @@ -41,5 +41,27 @@ describe('Sentry server SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('configures ignoreSpans to drop prerendered http.server spans', () => { + init({}); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([ + { op: 'http.server', attributes: { 'sentry.origin': 'auto.http.otel.http' } }, + ]), + }), + ); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: [/keep-me/] }); + + expect(nodeInit).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreSpans: expect.arrayContaining([/keep-me/]), + }), + ); + }); }); }); From 4c053b6dbc24682c0ffdfdd09fcd224d38d1f6a8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 27 Apr 2026 14:29:48 +0200 Subject: [PATCH 45/77] feat(core): Backfill otel attributes on streamed spans (#20439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming path skips the `SentrySpanExporter`, which is why we backfill span data (`sentry.op`, `name`, `sentry.source`) in `captureSpan` now. Some of the inference logic is duplicated from the otel package — which we can likely drop once we move away from otel. closes https://github.com/getsentry/sentry-javascript/issues/20425 --- .size-limit.js | 16 +- .../fetch-basic-streamed/scenario.ts | 20 +++ .../fetch-basic-streamed/test.ts | 29 ++++ .../httpIntegration-streamed/instrument.mjs | 10 ++ .../httpIntegration-streamed/server.mjs | 16 ++ .../tracing/httpIntegration-streamed/test.ts | 34 ++++ .../core/src/tracing/spans/captureSpan.ts | 126 ++++++++++++++ .../lib/tracing/spans/captureSpan.test.ts | 157 +++++++++++++++++- 8 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts diff --git a/.size-limit.js b/.size-limit.js index 8e1f3e79ee2f..d688ea688fe2 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -212,7 +212,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '46.5 KB', + limit: '47 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -226,7 +226,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '47.5 KB', + limit: '48 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -240,14 +240,14 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '83.5 KB', + limit: '84 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '84.5 KB', + limit: '85 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '139 KB', + limit: '140 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -294,7 +294,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '142 KB', + limit: '143 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -310,7 +310,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '256 KB', + limit: '257 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '260 KB', + limit: '260.5 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts new file mode 100644 index 000000000000..3fe49e76fb35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +async function run(): Promise { + await Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + }); + + await Sentry.flush(); +} + +void run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts new file mode 100644 index 000000000000..c943957c8ae6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed/test.ts @@ -0,0 +1,29 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('infers sentry.op for streamed outgoing fetch spans', async () => { + expect.assertions(2); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + expect(true).toBe(true); + }) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + span: container => { + const httpClientSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && item.attributes['sentry.op'].value === 'http.client', + ); + + expect(httpClientSpan).toBeDefined(); + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs new file mode 100644 index 000000000000..4b86f31cb860 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/server.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts new file mode 100644 index 000000000000..7ebd70673b96 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -0,0 +1,34 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('httpIntegration-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('infers sentry.op, name, and source for streamed server spans', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find( + item => + item.attributes?.['sentry.op']?.type === 'string' && + item.attributes['sentry.op'].value === 'http.server', + ); + + expect(serverSpan).toBeDefined(); + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.name).toBe('GET /test'); + expect(serverSpan?.attributes?.['sentry.source']).toEqual({ type: 'string', value: 'route' }); + expect(serverSpan?.attributes?.['sentry.span.source']).toEqual({ type: 'string', value: 'route' }); + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index fe8bc31fcae7..e41a9cfdf484 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -2,7 +2,9 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; import { + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, @@ -51,6 +53,14 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW applyCommonSpanAttributes(spanJSON, serializedSegmentSpan, client, finalScopeData); + // Backfill span data from OTel semantic conventions when not explicitly set. + // OTel-originated spans don't have sentry.op, description, etc. — the non-streamed path + // infers these in the SentrySpanExporter, but streamed spans skip the exporter entirely. + // Access `kind` via duck-typing — OTel span objects have this property but it's not on Sentry's Span type. + // This must run before all hooks and beforeSendSpan so that user callbacks can see and override inferred values. + const spanKind = (span as { kind?: number }).kind; + inferSpanDataFromOtelAttributes(spanJSON, spanKind); + if (spanJSON.is_segment) { applyScopeToSegmentSpan(spanJSON, finalScopeData); // Allow hook subscribers to mutate the segment span JSON @@ -150,3 +160,119 @@ export function safeSetSpanJSONAttributes( } }); } + +// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) +const SPAN_KIND_SERVER = 1; +const SPAN_KIND_CLIENT = 2; + +/** + * Infer and backfill span data from OTel semantic conventions. + * This mirrors what the `SentrySpanExporter` does for non-streamed spans via `getSpanData`/`inferSpanData`. + * Streamed spans skip the exporter, so we do the inference here during capture. + * + * Backfills: `sentry.op`, `sentry.source`, and `name` (description). + * Uses `safeSetSpanJSONAttributes` so explicitly set attributes are never overwritten. + */ +/** Exported only for tests. */ +export function inferSpanDataFromOtelAttributes(spanJSON: StreamedSpanJSON, spanKind?: number): void { + const attributes = spanJSON.attributes; + if (!attributes) { + return; + } + + const httpMethod = attributes['http.request.method'] || attributes['http.method']; + if (httpMethod) { + inferHttpSpanData(spanJSON, attributes, spanKind, httpMethod); + return; + } + + const dbSystem = attributes['db.system.name'] || attributes['db.system']; + const opIsCache = + typeof attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'string' && + `${attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]}`.startsWith('cache.'); + if (dbSystem && !opIsCache) { + inferDbSpanData(spanJSON, attributes); + return; + } + + if (attributes['rpc.service']) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc' }); + return; + } + + if (attributes['messaging.system']) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'message' }); + return; + } + + const faasTrigger = attributes['faas.trigger']; + if (faasTrigger) { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${faasTrigger}` }); + } +} + +function inferHttpSpanData( + spanJSON: StreamedSpanJSON, + attributes: RawAttributes>, + spanKind: number | undefined, + httpMethod: unknown, +): void { + // Infer op: http.client, http.server, or just http + const opParts = ['http']; + if (spanKind === SPAN_KIND_CLIENT) { + opParts.push('client'); + } else if (spanKind === SPAN_KIND_SERVER) { + opParts.push('server'); + } + if (attributes['sentry.http.prefetch']) { + opParts.push('prefetch'); + } + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: opParts.join('.') }); + + // If the user set a custom span name via updateSpanName(), apply it — OTel instrumentation + // may have overwritten span.name after the user set it, so we restore from the attribute. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { + return; + } + + // Only overwrite the span name when we have an explicit http.route — it's more specific than + // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), + // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). + const httpRoute = attributes['http.route']; + if (typeof httpRoute === 'string') { + spanJSON.name = `${httpMethod} ${httpRoute}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); + } else { + // Fallback: set source to 'url' for HTTP spans without a route. + // The spec requires sentry.span.source on segment spans, and the non-streamed exporter + // always sets this — so we need to ensure it's present for streamed spans too. + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); + } +} + +function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); + + // If the user set a custom span name via updateSpanName(), apply it. + const customName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + if (typeof customName === 'string') { + spanJSON.name = customName; + return; + } + + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom') { + return; + } + + const statement = attributes['db.statement']; + if (statement) { + spanJSON.name = `${statement}`; + safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task' }); + } +} diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index d429d50714a2..56b039d56b67 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -21,7 +21,7 @@ import { withScope, withStreamedSpan, } from '../../../../src'; -import { safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { @@ -483,3 +483,158 @@ describe('safeSetSpanJSONAttributes', () => { expect(spanJSON.attributes).toEqual({}); }); }); + +describe('inferSpanDataFromOtelAttributes', () => { + function makeSpanJSON(name: string, attributes: Record): StreamedSpanJSON { + return { + name, + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes, + }; + } + + describe('http spans', () => { + it('infers http.client op for CLIENT kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); // SPAN_KIND_CLIENT + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + + it('infers http.server op for SERVER kind', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); // SPAN_KIND_SERVER + expect(spanJSON.attributes?.['sentry.op']).toBe('http.server'); + }); + + it('infers http op when kind is unknown', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + + it('appends prefetch to op', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.http.prefetch': true }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.prefetch'); + }); + + it('sets name and source from http.route', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'http.route': '/users/:id' }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('GET /users/:id'); + expect(spanJSON.attributes?.['sentry.source']).toBe('route'); + }); + + it('does not overwrite name when no http.route but sets source to url', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.name).toBe('GET'); + expect(spanJSON.attributes?.['sentry.source']).toBe('url'); + }); + + it('does not overwrite sentry.op if already set', () => { + const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'sentry.op': 'http.client.custom' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client.custom'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten-by-otel', { + 'http.request.method': 'GET', + 'sentry.custom_span_name': 'my-custom-name', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-custom-name'); + }); + + it('does not overwrite name when sentry.source is custom', () => { + const spanJSON = makeSpanJSON('my-name', { + 'http.request.method': 'GET', + 'sentry.source': 'custom', + 'http.route': '/users/:id', + }); + inferSpanDataFromOtelAttributes(spanJSON, 1); + expect(spanJSON.name).toBe('my-name'); + }); + + it('supports legacy http.method attribute', () => { + const spanJSON = makeSpanJSON('GET', { 'http.method': 'GET' }); + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes?.['sentry.op']).toBe('http.client'); + }); + }); + + describe('db spans', () => { + it('infers db op', () => { + const spanJSON = makeSpanJSON('redis', { 'db.system': 'redis' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('db'); + }); + + it('sets name from db.statement', () => { + const spanJSON = makeSpanJSON('mysql', { 'db.system': 'mysql', 'db.statement': 'SELECT * FROM users' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('SELECT * FROM users'); + expect(spanJSON.attributes?.['sentry.source']).toBe('task'); + }); + + it('skips db inference for cache spans', () => { + const spanJSON = makeSpanJSON('cache-get', { 'db.system': 'redis', 'sentry.op': 'cache.get_item' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('cache.get_item'); + expect(spanJSON.name).toBe('cache-get'); + }); + + it('restores custom span name from sentry.custom_span_name', () => { + const spanJSON = makeSpanJSON('overwritten', { + 'db.system': 'mysql', + 'db.statement': 'SELECT 1', + 'sentry.custom_span_name': 'my-db-span', + 'sentry.source': 'custom', + }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.name).toBe('my-db-span'); + }); + }); + + describe('other span types', () => { + it('infers rpc op', () => { + const spanJSON = makeSpanJSON('grpc', { 'rpc.service': 'UserService' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('rpc'); + }); + + it('infers message op', () => { + const spanJSON = makeSpanJSON('kafka', { 'messaging.system': 'kafka' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('message'); + }); + + it('infers faas op from trigger', () => { + const spanJSON = makeSpanJSON('lambda', { 'faas.trigger': 'http' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBe('http'); + }); + }); + + it('does nothing when attributes are missing', () => { + const spanJSON = makeSpanJSON('test', undefined as unknown as Record); + spanJSON.attributes = undefined; + inferSpanDataFromOtelAttributes(spanJSON, 2); + expect(spanJSON.attributes).toBeUndefined(); + }); + + it('does nothing for spans without recognizable attributes', () => { + const spanJSON = makeSpanJSON('test', { 'custom.attr': 'value' }); + inferSpanDataFromOtelAttributes(spanJSON); + expect(spanJSON.attributes?.['sentry.op']).toBeUndefined(); + expect(spanJSON.name).toBe('test'); + }); +}); From 786fdf74e99840780dcd699b830d60a115508f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 27 Apr 2026 17:21:29 +0200 Subject: [PATCH 46/77] feat(core): clear up integrations on dispose (#20407) closes #19573 closes [JS-1829](https://linear.app/getsentry/issue/JS-1829/investigate-in-memory-leak-within-console-integrations-on-cloudflare) As with Cloudflare we create a new client on every request, that means that every integration that uses an `addHandler` and is used by the Cloudflare SDK is makes the client not disposable - so the garbage collector can't remove it properly. This PR adds a callback for `addHandler` that basically removes the handler from the global handler array (for now only for integrations, which are used by the Cloudflare SDK). I actually also tried to change the global handler to be a `WeakMap`, but it still showed some memory leaks with that, so we need to actively remove these callbacks. For now, to not increase the bundle sizes for core too much, it is actually removing the handlers only in the `ServerRuntimeClient`, as for browsers it is usually not really an issue. --- .size-limit.js | 4 +- packages/core/src/client.ts | 17 +++- packages/core/src/instrument/console.ts | 6 +- packages/core/src/instrument/fetch.ts | 12 ++- packages/core/src/instrument/handlers.ts | 14 ++- packages/core/src/integrations/console.ts | 4 +- packages/core/src/logs/console-integration.ts | 4 +- packages/core/src/server-runtime-client.ts | 19 ++++ .../core/test/lib/instrument/handlers.test.ts | 99 ++++++++++++++++++- .../test/lib/server-runtime-client.test.ts | 85 ++++++++++++++++ .../test/integrations/console.test.ts | 2 +- 11 files changed, 250 insertions(+), 16 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index d688ea688fe2..13be2c2e0baf 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -180,7 +180,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '31 KB', + limit: '32 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -294,7 +294,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '143 KB', + limit: '144 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 62f94619e62f..2cf7c1afb171 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1169,10 +1169,25 @@ export abstract class Client { return {}; } + /** + * Register a cleanup function to be called when the client is disposed. + * This is useful for integrations that need to clean up global state. + * + * NOTE: This is a no-op in the base `Client` class. Subclasses like `ServerRuntimeClient` + * override this method to actually register and execute cleanup callbacks. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public registerCleanup(callback: () => void): void { + // No-op in base class - subclasses override to implement cleanup registration + } + /** * Disposes of the client and releases all resources. * - * Subclasses should override this method to clean up their own resources. + * Subclasses should override this method to clean up their own resources, including invoking + * any callbacks registered via {@link Client.registerCleanup}. The base implementation is a + * no-op and does NOT execute registered cleanup callbacks. + * * After calling dispose(), the client should not be used anymore. */ public dispose(): void { diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index cecf1e5cad8a..ef7e9c804943 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -8,14 +8,16 @@ import { addHandler, maybeInstrument, triggerHandlers } from './handlers'; /** * Add an instrumentation handler for when a console.xxx method is called. + * Returns a function to remove the handler. * * Use at your own risk, this might break without changelog notice, only used internally. * @hidden */ -export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): void { +export function addConsoleInstrumentationHandler(handler: (data: HandlerDataConsole) => void): () => void { const type = 'console'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, instrumentConsole); + return removeHandler; } function instrumentConsole(): void { diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 590830ab4e20..a3165cfbc13a 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -15,6 +15,7 @@ type FetchResource = string | { toString(): string } | { url: string }; * Add an instrumentation handler for when a fetch request happens. * The handler function is called once when the request starts and once when it ends, * which can be identified by checking if it has an `endTimestamp`. + * Returns a function to remove the handler. * * Use at your own risk, this might break without changelog notice, only used internally. * @hidden @@ -22,24 +23,27 @@ type FetchResource = string | { toString(): string } | { url: string }; export function addFetchInstrumentationHandler( handler: (data: HandlerDataFetch) => void, skipNativeFetchCheck?: boolean, -): void { +): () => void { const type = 'fetch'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, () => instrumentFetch(undefined, skipNativeFetchCheck)); + return removeHandler; } /** * Add an instrumentation handler for long-lived fetch requests, like consuming server-sent events (SSE) via fetch. * The handler will resolve the request body and emit the actual `endTimestamp`, so that the * span can be updated accordingly. + * Returns a function to remove the handler. * * Only used internally * @hidden */ -export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): void { +export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFetch) => void): () => void { const type = 'fetch-body-resolved'; - addHandler(type, handler); + const removeHandler = addHandler(type, handler); maybeInstrument(type, () => instrumentFetch(streamHandler)); + return removeHandler; } function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void { diff --git a/packages/core/src/instrument/handlers.ts b/packages/core/src/instrument/handlers.ts index 74dbc9902348..bd15263021c8 100644 --- a/packages/core/src/instrument/handlers.ts +++ b/packages/core/src/instrument/handlers.ts @@ -18,10 +18,20 @@ export type InstrumentHandlerCallback = (data: any) => void; const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } = {}; const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; -/** Add a handler function. */ -export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { +/** Add a handler function. Returns a function to remove the handler. */ +export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): () => void { handlers[type] = handlers[type] || []; handlers[type].push(handler); + + return () => { + const typeHandlers = handlers[type]; + if (typeHandlers) { + const index = typeHandlers.indexOf(handler); + if (index !== -1) { + typeHandlers.splice(index, 1); + } + } + }; } /** diff --git a/packages/core/src/integrations/console.ts b/packages/core/src/integrations/console.ts index dda44543cc03..e39fd5ddcf0d 100644 --- a/packages/core/src/integrations/console.ts +++ b/packages/core/src/integrations/console.ts @@ -41,13 +41,15 @@ export const consoleIntegration = defineIntegration((options: Partial { + const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => { if (getClient() !== client || !levels.has(level)) { return; } addConsoleBreadcrumb(level, args); }); + + client.registerCleanup(unsubscribe); }, }; }); diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index ccf14e3ebf48..e16016a1154a 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -31,7 +31,7 @@ const _consoleLoggingIntegration = ((options: Partial = { return; } - addConsoleInstrumentationHandler(({ args, level }) => { + const unsubscribe = addConsoleInstrumentationHandler(({ args, level }) => { if (getClient() !== client || !levels.includes(level)) { return; } @@ -66,6 +66,8 @@ const _consoleLoggingIntegration = ((options: Partial = { attributes, }); }); + + client.registerCleanup(unsubscribe); }, }; }) satisfies IntegrationFn; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index a1958f0bcbbb..da697c682436 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -31,6 +31,8 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends Client { + private _disposeCallbacks: (() => void)[] = []; + /** * Creates a new Edge SDK instance. * @param options Configuration options for this SDK. @@ -154,6 +156,13 @@ export class ServerRuntimeClient< return id; } + /** + * @inheritDoc + */ + public override registerCleanup(callback: () => void): void { + this._disposeCallbacks.push(callback); + } + /** * Disposes of the client and releases all resources. * @@ -168,6 +177,16 @@ export class ServerRuntimeClient< public override dispose(): void { DEBUG_BUILD && debug.log('Disposing client...'); + // Run all registered cleanup callbacks + for (const callback of this._disposeCallbacks) { + try { + callback(); + } catch { + // Ignore errors in cleanup callbacks + } + } + this._disposeCallbacks.length = 0; + for (const hookName of Object.keys(this._hooks)) { this._hooks[hookName]?.clear(); } diff --git a/packages/core/test/lib/instrument/handlers.test.ts b/packages/core/test/lib/instrument/handlers.test.ts index 87e227a99323..cb894514b24a 100644 --- a/packages/core/test/lib/instrument/handlers.test.ts +++ b/packages/core/test/lib/instrument/handlers.test.ts @@ -1,5 +1,14 @@ -import { describe, test } from 'vitest'; -import { maybeInstrument } from '../../../src/instrument/handlers'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + addHandler, + maybeInstrument, + resetInstrumentationHandlers, + triggerHandlers, +} from '../../../src/instrument/handlers'; + +afterEach(() => { + resetInstrumentationHandlers(); +}); describe('maybeInstrument', () => { test('does not throw when instrumenting fails', () => { @@ -12,3 +21,89 @@ describe('maybeInstrument', () => { maybeInstrument('xhr', undefined as any); }); }); + +describe('addHandler', () => { + test('returns an unsubscribe function', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + expect(typeof unsubscribe).toBe('function'); + }); + + test('handler is called when triggerHandlers is invoked', () => { + const handler = vi.fn(); + addHandler('fetch', handler); + + triggerHandlers('fetch', { url: 'https://example.com' }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ url: 'https://example.com' }); + }); + + test('unsubscribe removes the handler', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + triggerHandlers('fetch', { test: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + unsubscribe(); + + triggerHandlers('fetch', { test: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('unsubscribe only removes the specific handler', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + const unsubscribe1 = addHandler('fetch', handler1); + addHandler('fetch', handler2); + + triggerHandlers('fetch', { test: 1 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + triggerHandlers('fetch', { test: 2 }); + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(2); + }); + + test('calling unsubscribe multiple times is safe', () => { + const handler = vi.fn(); + const unsubscribe = addHandler('fetch', handler); + + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + }); + + test('unsubscribe works with different handler types', () => { + const consoleHandler = vi.fn(); + const fetchHandler = vi.fn(); + + const unsubscribeConsole = addHandler('console', consoleHandler); + const unsubscribeFetch = addHandler('fetch', fetchHandler); + + triggerHandlers('console', { level: 'log' }); + triggerHandlers('fetch', { url: 'test' }); + + expect(consoleHandler).toHaveBeenCalledTimes(1); + expect(fetchHandler).toHaveBeenCalledTimes(1); + + unsubscribeConsole(); + + triggerHandlers('console', { level: 'warn' }); + triggerHandlers('fetch', { url: 'test2' }); + + expect(consoleHandler).toHaveBeenCalledTimes(1); + expect(fetchHandler).toHaveBeenCalledTimes(2); + + unsubscribeFetch(); + + triggerHandlers('fetch', { url: 'test3' }); + expect(fetchHandler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index bbe9ee84a716..8d7f7fbc48c5 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -320,5 +320,90 @@ describe('ServerRuntimeClient', () => { // Verify it's a fresh buffer with no pending items expect(bufferAfterDispose.$).toEqual([]); }); + + it('calls registered cleanup callbacks on dispose', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup1 = vi.fn(); + const cleanup2 = vi.fn(); + const cleanup3 = vi.fn(); + + client.registerCleanup(cleanup1); + client.registerCleanup(cleanup2); + client.registerCleanup(cleanup3); + + expect(cleanup1).not.toHaveBeenCalled(); + expect(cleanup2).not.toHaveBeenCalled(); + expect(cleanup3).not.toHaveBeenCalled(); + + client.dispose(); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(cleanup2).toHaveBeenCalledTimes(1); + expect(cleanup3).toHaveBeenCalledTimes(1); + }); + + it('clears cleanup callbacks after dispose', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup = vi.fn(); + client.registerCleanup(cleanup); + + client.dispose(); + expect(cleanup).toHaveBeenCalledTimes(1); + + // Calling dispose again should not call cleanup again + client.dispose(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('continues to call other cleanup callbacks if one throws', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup1 = vi.fn(); + const throwingCleanup = vi.fn(() => { + throw new Error('cleanup error'); + }); + const cleanup2 = vi.fn(); + + client.registerCleanup(cleanup1); + client.registerCleanup(throwingCleanup); + client.registerCleanup(cleanup2); + + expect(() => client.dispose()).not.toThrow(); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(throwingCleanup).toHaveBeenCalledTimes(1); + expect(cleanup2).toHaveBeenCalledTimes(1); + }); + }); + + describe('registerCleanup', () => { + it('accepts cleanup functions', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanup = vi.fn(); + + expect(() => client.registerCleanup(cleanup)).not.toThrow(); + }); + + it('can register multiple cleanup functions', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + const cleanups = Array.from({ length: 10 }, () => vi.fn()); + + cleanups.forEach(cleanup => client.registerCleanup(cleanup)); + + client.dispose(); + + cleanups.forEach(cleanup => { + expect(cleanup).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/packages/node-core/test/integrations/console.test.ts b/packages/node-core/test/integrations/console.test.ts index 0355fe2d076b..39086e9768e3 100644 --- a/packages/node-core/test/integrations/console.test.ts +++ b/packages/node-core/test/integrations/console.test.ts @@ -25,7 +25,7 @@ describe('consoleIntegration in Lambda (patchWithDefineProperty)', () => { it('calls registered handler when console.log is called', () => { const handler = vi.fn(); // Setup the integration so it calls maybeInstrument with the Lambda strategy - consoleIntegration().setup?.({ on: vi.fn() } as any); + consoleIntegration().setup?.({ on: vi.fn(), registerCleanup: vi.fn() } as any); addConsoleInstrumentationHandler(handler); From 300b0189d625bd8f29e981969aa8fcc822a088c7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 27 Apr 2026 11:24:47 -0400 Subject: [PATCH 47/77] fix(core): Avoid parse-time SyntaxError on Safari <16.4 in postgresjs (#20498) Bandaid fix for #20433. Constructs the negative-lookbehind regex via `new RegExp(...)` instead of a regex literal, so it is evaluated at runtime rather than at parse time. [Safari <16.4 does not support lookbehind assertions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Lookbehind_assertion) as a literal, this causes a parse-time `SyntaxError` that kills the entire script. As a constructor call with `new RegExp`, parsing succeeds;. Correctness in Safari doesn't matter here, we just don't want the file to fail to parse and it would never execute anyways. The deeper issue is that server-only code from `@sentry/core` (postgresjs, express, trpc, mcp-server, the AI instrumentations, etc.) can end up in browser bundles because it seems like some bundlers can't tree-shake it out of the core barrel. We should be more disciplined about what the main `@sentry/core` entry re-exports so apps stop shipping server code that never executes. I managed to reproduce this setup in a webpack with CJS app and noticed it didn't tree-shake the postgres integration. Vite seems to do it correctly. Bringing this up next week, but we should not rely on tree-shaking to eliminate code-paths. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- packages/core/src/integrations/postgresjs.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index b01ac13b1708..da1a7cee17c6 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -342,6 +342,8 @@ export function _reconstructQuery(strings: string[] | undefined): string | undef return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); } +let integerLiteralRE: RegExp | undefined; + /** * Sanitize SQL query as per the OTEL semantic conventions * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext @@ -356,6 +358,13 @@ export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { return 'Unknown SQL Query'; } + // Lazy init: constructing this at module scope would evaluate the lookbehind + // on import and crash Safari <16.4 browser bundles that reach this file via + // the core barrel. Building it on first call keeps the cost off the import path. + if (!integerLiteralRE) { + integerLiteralRE = new RegExp('(? Date: Tue, 28 Apr 2026 08:52:15 +0200 Subject: [PATCH 48/77] feat(nextjs): Migrate server event processors to span-first APIs (#20527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR ports the Next.js server SDK's transaction‑shaped event processors to the new span‑first equivalents (`ignoreSpans`, `processSegmentSpan`) so the same behavior holds whether the SDK is in tx or streamed mode. The preprocessEvent hook that normalizes BaseServer.handleRequest segments (op, transaction name, source, route backfill, middleware naming, /_error rewrite) is extracted into a small helper: `enhanceHandleRequestRootSpan`, which exposes a MutableRootSpan adapter so the same logic can run against both the legacy transaction event's `contexts.trace.data` and the streamed StreamedSpanJSON. The hook is now registered twice: once on `preprocessEvent` for the legacy path and once on `processSegmentSpan` for streamed mode — they're complementary, since each only fires in its respective mode. Will follow up with a streaming e2e for nextjs Closes #20367 / [JS-2208](https://linear.app/getsentry/issue/JS-2208). --- .../server/enhanceHandleRequestRootSpan.ts | 78 ++++++++ packages/nextjs/src/server/index.ts | 185 +++++------------- .../enhanceHandleRequestRootSpan.test.ts | 179 +++++++++++++++++ packages/nextjs/test/serverSdk.test.ts | 35 ++++ 4 files changed, 344 insertions(+), 133 deletions(-) create mode 100644 packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts create mode 100644 packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts new file mode 100644 index 000000000000..a934380492dc --- /dev/null +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -0,0 +1,78 @@ +import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_ROUTE, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_TARGET, +} from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, stripUrlQueryAndFragment } from '@sentry/core'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../common/span-attributes-with-logic-attached'; + +export interface MutableRootSpan { + attributes: Record; + getName(): string | undefined; + setName(name: string): void; + setOp(op: string): void; +} + +/** + * Normalizes name, op and source for the root span of a Next.js `BaseServer.handleRequest` request. + * + * Called from two places that operate on different shapes of the same underlying root span: + * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` + * holds the root span's attributes and whose `event.transaction` is the root span's name. + * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed + * counterpart of the legacy transaction root) directly. + * + * The `MutableRootSpan` adapter hides those differences so the enhancement logic can be shared. + */ +export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { + const { attributes } = span; + + if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') { + return; + } + + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; + span.setOp('http.server'); + + const currentName = span.getName(); + if (currentName) { + span.setName(stripUrlQueryAndFragment(currentName)); + } + + // eslint-disable-next-line deprecation/deprecation + const method = attributes[SEMATTRS_HTTP_METHOD] ?? attributes[ATTR_HTTP_REQUEST_METHOD]; + // eslint-disable-next-line deprecation/deprecation + const target = attributes[SEMATTRS_HTTP_TARGET]; + const route = attributes[ATTR_HTTP_ROUTE] || attributes[ATTR_NEXT_ROUTE]; + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; + + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { + const cleanRoute = route.replace(/\/route$/, ''); + span.setName(`${method} ${cleanRoute}`); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + // Preserve next.route in case it did not get hoisted + attributes[ATTR_NEXT_ROUTE] = cleanRoute; + } + + // backfill transaction name for pages that would otherwise contain unparameterized routes + const routeBackfill = attributes[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]; + if (typeof routeBackfill === 'string' && span.getName() !== 'GET /_app') { + span.setName(`${typeof method === 'string' ? method : 'GET'} ${routeBackfill}`); + } + + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + span.setName(`middleware ${middlewareMatch[1]}`); + span.setOp('http.server.middleware'); + } + + // Next.js overrides transaction names for page loads that throw an error + // but we want to keep the original target name + if (span.getName() === 'GET /_error' && typeof target === 'string') { + span.setName(`${typeof method === 'string' ? `${method} ` : ''}${target}`); + } +} diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 0483ab6448ff..1008601a3318 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,39 +1,27 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { - ATTR_HTTP_ROUTE, - ATTR_URL_QUERY, - SEMATTRS_HTTP_METHOD, - SEMATTRS_HTTP_TARGET, -} from '@opentelemetry/semantic-conventions'; +import { ATTR_URL_QUERY, SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { EventProcessor } from '@sentry/core'; import { applySdkMetadata, debug, - extractTraceparentData, getClient, getGlobalScope, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; -import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; -import { - TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, - TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, - TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, -} from '../common/span-attributes-with-logic-attached'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring'; @@ -155,6 +143,23 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + const nextjsIgnoreSpans: NonNullable = [ + // Static assets (matches `_next/static` anywhere in the name to handle custom basePath) + /^GET (\/.*)?\/_next\/static\//, + // Dev source-map fetch endpoints + /\/__nextjs_original-stack-frame/, + // Pages router /404 + /^\/404$/, + // App router /404 and /_not-found segments (any HTTP method) + /^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/, + // Next.js 13 root transactions named "NextServer.getRequestHandler" containing useless tracing + /^NextServer\.getRequestHandler$/, + // Spans flagged via TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION + // (set in `dropMiddlewareTunnelRequests` during `spanStart`) + { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + if (DEBUG_BUILD && opts.debug) { debug.enable(); } @@ -195,62 +200,6 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanEnd', maybeCompleteCronCheckIn); client?.on('spanEnd', maybeCleanupQueueSpan); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - if (event.type === 'transaction') { - // Filter out transactions for static assets - // This regex matches the default path to the static assets (`_next/static`) and could potentially filter out too many transactions. - // We match `/_next/static/` anywhere in the transaction name because its location may change with the basePath setting. - if (event.transaction?.match(/^GET (\/.*)?\/_next\/static\//)) { - return null; - } - - // Filter out requests to resolve source maps for stack frames in dev mode - if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { - return null; - } - - // Filter out /404 transactions which seem to be created excessively - if ( - // Pages router - event.transaction === '/404' || - // App router (could be "GET /404", "POST /404", ...) - event.transaction?.match(/^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/) - ) { - return null; - } - - // Filter transactions that we explicitly want to drop. - if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { - return null; - } - - // Next.js 13 sometimes names the root transactions like this containing useless tracing. - if (event.transaction === 'NextServer.getRequestHandler') { - return null; - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if (typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string') { - const traceparentData = extractTraceparentData( - event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL], - ); - - if (traceparentData?.parentSampled === false) { - return null; - } - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - getGlobalScope().addEventProcessor( Object.assign( ((event, hint) => { @@ -289,74 +238,44 @@ export function init(options: NodeOptions): NodeClient | undefined { // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to // "custom", doesn't trigger. + // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; + // `enhanceHandleRequestRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. + // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { - // Enhance route handler transactions - if ( - event.type === 'transaction' && - event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' - ) { - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; - event.contexts.trace.op = 'http.server'; - - if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.transaction); - } - - // eslint-disable-next-line deprecation/deprecation - const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; - // eslint-disable-next-line deprecation/deprecation - const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; - const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - - if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { - const cleanRoute = route.replace(/\/route$/, ''); - event.transaction = `${method} ${cleanRoute}`; - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; - // Preserve next.route in case it did not get hoisted - event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; - } - - // backfill transaction name for pages that would otherwise contain unparameterized routes - if (event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL] && event.transaction !== 'GET /_app') { - event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; - } - - const middlewareMatch = - typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); - - if (middlewareMatch) { - const normalizedName = `middleware ${middlewareMatch[1]}`; - event.transaction = normalizedName; - event.contexts.trace.op = 'http.server.middleware'; - } - - // Next.js overrides transaction names for page loads that throw an error - // but we want to keep the original target name - if (event.transaction === 'GET /_error' && target) { - event.transaction = `${method ? `${method} ` : ''}${target}`; - } - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if ( - event.type === 'transaction' && - typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string' - ) { - const traceparentData = extractTraceparentData(event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL]); - - if (traceparentData?.traceId) { - event.contexts.trace.trace_id = traceparentData.traceId; - } - - if (traceparentData?.parentSpanId) { - event.contexts.trace.parent_span_id = traceparentData.parentSpanId; - } + if (event.type === 'transaction' && event.contexts?.trace?.data) { + enhanceHandleRequestRootSpan({ + attributes: event.contexts.trace.data, + getName: () => event.transaction, + setName: name => { + event.transaction = name; + }, + setOp: op => { + event.contexts!.trace!.op = op; + }, + }); } setUrlProcessingMetadata(event); }); + // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become + // transaction events, so the same enhancement has to be applied here directly on the span JSON. + client?.on('processSegmentSpan', span => { + const attributes = (span.attributes ??= {}); + enhanceHandleRequestRootSpan({ + attributes, + getName: () => span.name, + setName: name => { + span.name = name; + }, + // For streamed spans, op lives in `attributes['sentry.op']` - mirror it there so middleware + // overrides land somewhere readable (the legacy path uses a separate `event.contexts.trace.op`). + setOp: op => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }); + }); + if (process.env.NODE_ENV === 'development') { getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); } diff --git a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts new file mode 100644 index 000000000000..8373c3a6e744 --- /dev/null +++ b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts @@ -0,0 +1,179 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../../src/common/span-attributes-with-logic-attached'; +import { enhanceHandleRequestRootSpan } from '../../src/server/enhanceHandleRequestRootSpan'; + +function makeSpan(attributes: Record, name?: string) { + let currentName = name; + let op: string | undefined; + return { + span: { + attributes, + getName: () => currentName, + setName: (n: string) => { + currentName = n; + }, + setOp: (o: string) => { + op = o; + }, + }, + getName: () => currentName, + getOp: () => op, + }; +} + +describe('enhanceHandleRequestRootSpan', () => { + it('does nothing for non-BaseServer.handleRequest spans', () => { + const { span, getName, getOp } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Render.getServerSideProps' }, 'GET /api/foo'); + enhanceHandleRequestRootSpan(span); + expect(getName()).toBe('GET /api/foo'); + expect(getOp()).toBeUndefined(); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); + }); + + it('sets http.server op and source=route for parameterized routes', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': '/api/users/[id]', + }, + 'GET /api/users/123', + ); + enhanceHandleRequestRootSpan(span); + + expect(getOp()).toBe('http.server'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server'); + expect(getName()).toBe('GET /api/users/[id]'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toBe('route'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/users/[id]'); + }); + + it('strips trailing /route from app router route handler routes', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'POST', + 'next.route': '/api/widgets/route', + }, + 'POST /api/widgets/route', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('POST /api/widgets'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/widgets'); + }); + + it('strips URL query and fragment from the segment name', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, + 'GET /search?q=foo#section', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /search'); + }); + + it('does not rename middleware-prefixed routes via the route attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': 'middleware GET', + }, + 'GET /foo', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /foo'); + }); + + it('uses the route backfill attribute when present', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /posts/hello-world', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /posts/[slug]'); + }); + + it('does not apply the backfill for the special GET /_app transaction', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /_app', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /_app'); + }); + + it('normalizes middleware span names and sets http.server.middleware op', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware POST /api/protected', + }, + 'middleware POST /api/protected?token=abc', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('middleware POST'); + expect(getOp()).toBe('http.server.middleware'); + }); + + it('writes the middleware op into attributes when the adapter mirrors op writes (streamed shape)', () => { + // Mirrors the `processSegmentSpan` adapter in src/server/index.ts where `setOp` writes back + // into `attributes['sentry.op']` because that is the only op storage for streamed segment spans. + const attributes: Record = { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware GET /api', + }; + let name: string | undefined = 'middleware GET /api'; + const span = { + attributes, + getName: () => name, + setName: (n: string) => { + name = n; + }, + setOp: (op: string) => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }; + + enhanceHandleRequestRootSpan(span); + + expect(name).toBe('middleware GET'); + expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server.middleware'); + }); + + it('rewrites GET /_error using the http.target attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'http.target': '/api/broken', + }, + 'GET /_error', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /api/broken'); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 26a73aa676d3..5ef92ae2d890 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -3,6 +3,7 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { getCurrentScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; // normally this is set as part of the build process, so mock it here @@ -116,6 +117,40 @@ describe('Server init()', () => { expect(init({})).not.toBeUndefined(); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = nodeInit.mock.calls[0]?.[0] as SentryNode.NodeOptions; + return callArgs.ignoreSpans ?? []; + } + + function regexSources(patterns: NonNullable): string[] { + return patterns.filter((p): p is RegExp => p instanceof RegExp).map(p => p.source); + } + + it('appends the Next.js name patterns and attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + const sources = regexSources(patterns); + + expect(sources).toContain('^GET (\\/.*)?\\/_next\\/static\\/'); + expect(sources).toContain('\\/__nextjs_original-stack-frame'); + expect(sources).toContain('^\\/404$'); + expect(sources).toContain('^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \\/(404|_not-found)$'); + expect(sources).toContain('^NextServer\\.getRequestHandler$'); + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(regexSources(patterns)).toContain('custom-regex'); + }); + }); + describe('OpenNext/Cloudflare runtime detection', () => { const cloudflareContextSymbol = Symbol.for('__cloudflare-context__'); From 1b33ddf0a1850df4721d622a421b703cad419799 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 28 Apr 2026 09:50:37 +0200 Subject: [PATCH 49/77] fix(core): Ensure `isSentryRequest` handles subdomains properly (#20530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adjusts our check for internal Sentry requests to ensure we do not accidentally mark more complex subdomain URLs as sentry-internal. How this _could_ be mis-used: 1. Attacker discovers the victim application's Sentry DSN host from the client-side JavaScript (e.g., o123456.ingest.sentry.io). 2. Attacker registers a domain containing the DSN host as a substring (e.g., o123456.ingest.sentry.io.attacker.com). 3. Attacker exploits an existing XSS or client-side vulnerability to make the victim's browser send data exfiltration requests to https://o123456.ingest.sentry.io.attacker.com/steal?sentry_key=fake&data=stolen_session. 5. Because isSentryRequestUrl classifies this URL as a Sentry request (hostname substring matches and sentry_key= is present), the exfiltration requests are silently excluded from all Sentry monitoring — no HTTP error events, no session replay network logs, no fetch spans, and no OpenTelemetry traces. 6. The attacker's data exfiltration activity is invisible to the security team reviewing Sentry dashboards. --- packages/core/src/utils/isSentryRequestUrl.ts | 10 +++++++++- .../test/lib/utils/isSentryRequestUrl.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts index 8cda9404164a..edcb6fe30591 100644 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -32,7 +32,15 @@ function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { return false; } - return dsn ? urlParts.host.includes(dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search) : false; + if (!dsn) { + return false; + } + + return hostnameMatchesDsnHost(urlParts.hostname, dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search); +} + +function hostnameMatchesDsnHost(hostname: string, dsnHost: string): boolean { + return hostname === dsnHost || (dsnHost.length > 0 && hostname.endsWith(`.${dsnHost}`)); } function removeTrailingSlash(str: string): string { diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts index 806165fb52be..e8aeef78b631 100644 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -46,4 +46,24 @@ describe('isSentryRequestUrl', () => { it('handles undefined client', () => { expect(isSentryRequestUrl('http://sentry-dsn.com/my-url?sentry_key=123', undefined)).toBe(false); }); + + it('does not treat attacker-controlled hostnames that merely contain the DSN host as Sentry URLs', () => { + const dsnHost = 'o123456.ingest.sentry.io'; + const client = { + getOptions: () => ({ tunnel: '' }), + getDsn: () => ({ host: dsnHost }), + } as unknown as Client; + + expect(isSentryRequestUrl(`https://${dsnHost}.attacker.com/exfil?sentry_key=fake&data=stolen`, client)).toBe(false); + }); + + it('still matches legitimate subdomains of the DSN host', () => { + const dsnHost = 'ingest.sentry.io'; + const client = { + getOptions: () => ({ tunnel: '' }), + getDsn: () => ({ host: dsnHost }), + } as unknown as Client; + + expect(isSentryRequestUrl('https://o123456.ingest.sentry.io/api/1/store/?sentry_key=abc', client)).toBe(true); + }); }); From 49bd7e737fe94524beed8cedb89c3a0ea0a7b31f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 28 Apr 2026 09:50:54 +0200 Subject: [PATCH 50/77] fix(replay): Ensure `maskAttributes` works with `maskAllText=false` (#20491) This was found by claude security review, we did not look at `maskAttributes` properly when `maskAllText=false` was configured. --- .../replay-internal/src/util/maskAttribute.ts | 22 ++++++------ .../test/unit/util/maskAttribute.test.ts | 34 +++++++++++++++++-- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/replay-internal/src/util/maskAttribute.ts b/packages/replay-internal/src/util/maskAttribute.ts index 12daaeb97dff..feb3898fc6ae 100644 --- a/packages/replay-internal/src/util/maskAttribute.ts +++ b/packages/replay-internal/src/util/maskAttribute.ts @@ -11,6 +11,8 @@ interface MaskAttributeParams { /** * Masks an attribute if necessary, otherwise return attribute value as-is. + * Keys listed in `maskAttributes` are masked even when `maskAllText` is false; + * masking `value` on submit/button inputs without listing `value` still requires `maskAllText`. */ export function maskAttribute({ el, @@ -20,22 +22,20 @@ export function maskAttribute({ privacyOptions, value, }: MaskAttributeParams): string { - // We only mask attributes if `maskAllText` is true - if (!maskAllText) { - return value; - } - // unmaskTextSelector takes precedence if (privacyOptions.unmaskTextSelector && el.matches(privacyOptions.unmaskTextSelector)) { return value; } - if ( - maskAttributes.includes(key) || - // Need to mask `value` attribute for `` if it's a button-like - // type - (key === 'value' && el.tagName === 'INPUT' && ['submit', 'button'].includes(el.getAttribute('type') || '')) - ) { + const masksNamedAttribute = maskAttributes.includes(key); + // When `maskAllText` is enabled, also mask `value` on button-like inputs even if `value` is not listed. + const masksSubmitButtonValue = + maskAllText && + key === 'value' && + el.tagName === 'INPUT' && + ['submit', 'button'].includes(el.getAttribute('type') || ''); + + if (masksNamedAttribute || masksSubmitButtonValue) { return value.replace(/[\S]/g, '*'); } diff --git a/packages/replay-internal/test/unit/util/maskAttribute.test.ts b/packages/replay-internal/test/unit/util/maskAttribute.test.ts index 4819e5411e11..0446f67b04a3 100644 --- a/packages/replay-internal/test/unit/util/maskAttribute.test.ts +++ b/packages/replay-internal/test/unit/util/maskAttribute.test.ts @@ -33,11 +33,15 @@ describe('maskAttribute', () => { test.each([ ['masks if `maskAllText` is true', defaultArgs, '***'], [ - 'does not mask if `maskAllText` is false, despite `maskTextSelector` ', - { ...defaultArgs, maskAllText: false, maskTextSelector: 'classy' }, + 'masks when key is in `maskAttributes` even if `maskAllText` is false', + { ...defaultArgs, maskAllText: false }, + '***', + ], + [ + 'does not mask when key is not in `maskAttributes` and `maskAllText` is false', + { ...defaultArgs, maskAllText: false, key: 'id', maskAttributes: ['title'] }, 'foo', ], - ['does not mask if `maskAllText` is false', { ...defaultArgs, maskAllText: false }, 'foo'], [ 'does not mask if `unmaskTextSelector` matches', { ...defaultArgs, privacyOptions: { ...privacyOptions, unmaskTextSelector: '.classy' } }, @@ -53,6 +57,30 @@ describe('maskAttribute', () => { { ...defaultArgs, el: inputButton, value: 'input value' }, '***** *****', ], + [ + 'does not mask submit `value` when `maskAllText` is false unless `value` is in `maskAttributes`', + { + ...defaultArgs, + el: inputSubmit, + key: 'value', + maskAttributes: ['title'], + maskAllText: false, + value: 'input value', + }, + 'input value', + ], + [ + 'masks submit `value` when `maskAllText` is false if `value` is in `maskAttributes`', + { + ...defaultArgs, + el: inputSubmit, + key: 'value', + maskAttributes: ['value'], + maskAllText: false, + value: 'input value', + }, + '***** *****', + ], ])('%s', (_: string, input, output) => { expect(maskAttribute(input)).toEqual(output); }); From df62ed915fe6f2f3f2ef64dfbcb0a386684b5335 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 28 Apr 2026 11:46:49 +0200 Subject: [PATCH 51/77] test(nextjs): Pin `eslint-config-next` package to major (#20552) `@next/eslint-plugin-next@16.3.0-canary.3` was never published which is why our tests started failing. We do not use lint in tests anyway so it's fine to keep them on latest 16 closes https://github.com/getsentry/sentry-javascript/issues/20546, https://github.com/getsentry/sentry-javascript/issues/20545, https://github.com/getsentry/sentry-javascript/issues/20544, https://github.com/getsentry/sentry-javascript/issues/20542, https://github.com/getsentry/sentry-javascript/issues/20543, https://github.com/getsentry/sentry-javascript/issues/20541, https://github.com/getsentry/sentry-javascript/issues/20540, https://github.com/getsentry/sentry-javascript/issues/20539, https://github.com/getsentry/sentry-javascript/issues/20538, https://github.com/getsentry/sentry-javascript/issues/20537, https://github.com/getsentry/sentry-javascript/issues/20536, https://github.com/getsentry/sentry-javascript/issues/20535 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .size-limit.js | 6 +++--- .../nextjs-16-cacheComponents/package.json | 2 +- .../test-applications/nextjs-16-cf-workers/package.json | 6 ++++-- .../test-applications/nextjs-16-tunnel/package.json | 2 +- .../e2e-tests/test-applications/nextjs-16/package.json | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 13be2c2e0baf..38880cc91247 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -96,7 +96,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '88 KB', + limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -310,7 +310,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '257 KB', + limit: '258 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '260.5 KB', + limit: '261 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json index 3f3907a77bed..c1070677f383 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -39,7 +39,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 14334483d116..59f192d9bd1b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -31,7 +31,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5", "wrangler": "^4.61.0" }, @@ -43,7 +43,9 @@ { "build-command": "pnpm test:build-latest", "label": "nextjs-16-cf-workers (latest)" - }, + } + ], + "optionalVariants": [ { "build-command": "pnpm test:build-canary", "label": "nextjs-16-cf-workers (canary)" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index ee74ff6e9259..0821c63d43f5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -40,7 +40,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 1e417a48fd1f..944102e188b3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -41,7 +41,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "canary", + "eslint-config-next": "^16", "typescript": "^5" }, "volta": { From 6c2e0625d13b5f92dec2571fa542161da255165a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 28 Apr 2026 13:13:58 +0200 Subject: [PATCH 52/77] feat(react-router): Drop low-quality transactions via `ignoreSpans` (#20514) Migrates the React Router low-quality transactions filter from a dedicated event-processor integration (`lowQualityTransactionsFilterIntegration`) to `ignoreSpans` so it also works in the streaming path. Adds some unit tests plus a new e2e test in `react-router-7-framework` that asserts no server transaction is sent for filtered `/__manifest?` requests during client-side navigation (no e2e covered this filter before). Closes https://github.com/getsentry/sentry-javascript/issues/20362 --- .../react-router-7-framework/app/routes.ts | 1 + .../app/routes/sentry-flush.tsx | 6 + .../low-quality-filter.server.test.ts | 34 ++++++ ...lowQualityTransactionsFilterIntegration.ts | 56 ++++----- ...alityTransactionsFilterIntegration.test.ts | 107 ++++++++---------- 5 files changed, 114 insertions(+), 90 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index 731081b54f52..1c5bb472d162 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes export default [ index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), ...prefix('errors', [ route('client', 'routes/errors/client.tsx'), route('client/:client-param', 'routes/errors/client-param.tsx'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0e5351a5704f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('low-quality transaction filter', () => { + test('does not send a server transaction for /__manifest? requests', async ({ page }) => { + const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions before we assert + await page.evaluate(() => fetch('/__sentry-flush')); + + const targetIsManifest = (t: (typeof serverTxns)[number]) => + typeof t.contexts?.trace?.data?.['http.target'] === 'string' && + (t.contexts.trace.data['http.target'] as string).includes('/__manifest'); + expect(serverTxns.some(targetIsManifest)).toBe(false); + }); +}); diff --git a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts index e4471167f7ce..b17627f4bb85 100644 --- a/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts @@ -1,37 +1,27 @@ -import { type Client, debug, defineIntegration, type Event, type EventHint } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; +const LOW_QUALITY_TRANSACTIONS_FILTERS = [ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + // The span description for the `__manifest` endpoint is `GET *` (`http.route` resolves to `*`). + // Filter by `http.target` instead, which carries the raw request path. + { attributes: { 'http.target': /\/__manifest/ } }, +]; + +// TODO(v11): Remove the `_options` parameter (unused and only kept for back-compat with the previous signature) +const _lowQualityTransactionsFilterIntegration = ((_options?: NodeOptions) => ({ + name: 'LowQualityTransactionsFilter', + beforeSetup(client) { + const opts = client.getOptions(); + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...LOW_QUALITY_TRANSACTIONS_FILTERS]; + }, +})) satisfies IntegrationFn; + /** - * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/ - * + * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/, __manifest. + * Adds entries to `ignoreSpans` so the filter applies in both static and streaming trace lifecycles. */ - -function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { - name: string; - processEvent: (event: Event, hint: EventHint, client: Client) => Event | null; -} { - const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/]; - - return { - name: 'LowQualityTransactionsFilter', - - processEvent(event: Event, _hint: EventHint, _client: Client): Event | null { - if (event.type !== 'transaction' || !event.transaction) { - return event; - } - - const transaction = event.transaction; - - if (matchedRegexes.some(regex => transaction.match(regex))) { - options.debug && debug.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); - return null; - } - - return event; - }, - }; -} - -export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) => - _lowQualityTransactionsFilterIntegration(options), -); +export const lowQualityTransactionsFilterIntegration = defineIntegration(_lowQualityTransactionsFilterIntegration); diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts index 7edd75c9e996..b64b850c9b94 100644 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -1,67 +1,60 @@ -import type { Event, EventType } from '@sentry/core'; -import * as SentryCore from '@sentry/core'; -import * as SentryNode from '@sentry/node'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Client, ClientOptions } from '@sentry/core'; +import { shouldIgnoreSpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration'; -const debugLoggerLogSpy = vi.spyOn(SentryCore.debug, 'log').mockImplementation(() => {}); - -describe('Low Quality Transactions Filter Integration', () => { - afterEach(() => { - vi.clearAllMocks(); - SentryNode.getGlobalScope().clear(); +function makeMockClient(initial: Partial = {}): Client { + const options = { ...initial } as ClientOptions; + return { getOptions: () => options } as Client; +} + +function setupIntegrationAndGetIgnoreSpans(initial: Partial = {}) { + const integration = lowQualityTransactionsFilterIntegration({}); + const client = makeMockClient(initial); + integration.beforeSetup!(client); + return client.getOptions().ignoreSpans!; +} + +describe('lowQualityTransactionsFilterIntegration', () => { + it('appends the low-quality filters to ignoreSpans', () => { + expect(setupIntegrationAndGetIgnoreSpans()).toEqual([ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); }); - describe('integration functionality', () => { - describe('filters out low quality transactions', () => { - it.each([ - ['node_modules requests', 'GET /node_modules/some-package/index.js'], - ['favicon.ico requests', 'GET /favicon.ico'], - ['@id/ requests', 'GET /@id/some-id'], - ['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({ debug: true }); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toBeNull(); - - expect(debugLoggerLogSpy).toHaveBeenCalledWith('[ReactRouter] Filtered node_modules transaction:', transaction); - }); - }); - - describe('allows high quality transactions', () => { - it.each([ - ['normal page requests', 'GET /api/users'], - ['API endpoints', 'POST /data'], - ['app routes', 'GET /projects/123'], - ])('%s', (description, transaction) => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + it('preserves user-provided ignoreSpans entries', () => { + expect(setupIntegrationAndGetIgnoreSpans({ ignoreSpans: [/keep-me/] })).toEqual([ + /keep-me/, + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, + { attributes: { 'http.target': /\/__manifest/ } }, + ]); + }); - expect(result).toEqual(event); - }); + describe('drops low-quality transactions', () => { + it.each([ + ['node_modules requests', { description: 'GET /node_modules/some-package/index.js' }], + ['favicon.ico requests', { description: 'GET /favicon.ico' }], + ['@id/ requests', { description: 'GET /@id/some-id' }], + ['manifest requests', { description: 'GET *', attributes: { 'http.target': '/__manifest?paths=foo' } }], + ])('%s', (_label, span) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ op: 'http.server', ...span }, ignoreSpans)).toBe(true); }); + }); - it('does not affect non-transaction events', () => { - const integration = lowQualityTransactionsFilterIntegration({}); - const event = { - type: 'error' as EventType, - transaction: 'GET /node_modules/some-package/index.js', - } as Event; - - const result = integration.processEvent!(event, {}, {} as SentryCore.Client); - - expect(result).toEqual(event); + describe('keeps high-quality transactions', () => { + it.each([ + ['normal page requests', 'GET /api/users'], + ['API endpoints', 'POST /data'], + ['app routes', 'GET /projects/123'], + ])('%s', (_label, name) => { + const ignoreSpans = setupIntegrationAndGetIgnoreSpans(); + expect(shouldIgnoreSpan({ description: name, op: 'http.server' }, ignoreSpans)).toBe(false); }); }); }); From f72f7435c73a7ffa7673ffae83f3c10b71466d66 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 28 Apr 2026 14:24:22 +0200 Subject: [PATCH 53/77] chore(ci): Vendor nx-affected-list action, drop dkhunt27 dependency (#20463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace the third-party `dkhunt27/action-nx-affected-list@v6.1` with a vendored composite action at `.github/actions/nx-affected-list/`. **Why:** - The external action is outdated (last release Sep 2024) and uses Node.js 20 (GHA deprecation warning) - It's a heavy wrapper (~200 lines of compiled JS) around a single command: `nx show projects --affected` - Third-party CI dependencies are a supply chain risk **What the external action did:** 1. `nx --version` + `nx reset` (prep) 2. `nx show projects --affected --base=X --head=Y` (core logic) 3. Parse output into a list, set as action output **What the vendored action does:** - Runs `nx show projects --affected` directly in bash (~15 lines) - Outputs space-separated project names (compatible with existing `contains()` checks) - No Node.js runtime dependency, no `nx reset` (unnecessary in our setup) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/actions/nx-affected-list/action.yml | 40 +++++++++++++++++++++ .github/workflows/build.yml | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .github/actions/nx-affected-list/action.yml diff --git a/.github/actions/nx-affected-list/action.yml b/.github/actions/nx-affected-list/action.yml new file mode 100644 index 000000000000..314386659540 --- /dev/null +++ b/.github/actions/nx-affected-list/action.yml @@ -0,0 +1,40 @@ +name: 'Nx Affected List' +description: 'Outputs a space-separated list of Nx projects affected by changes between base and head commits.' + +inputs: + base: + description: 'Base commit SHA' + required: false + head: + description: 'Head commit SHA' + required: false + +outputs: + affected: + description: 'Space-separated list of affected project names' + value: ${{ steps.affected.outputs.affected }} + +runs: + using: 'composite' + steps: + - name: Get affected Nx projects + id: affected + shell: bash + env: + INPUT_BASE: ${{ inputs.base }} + INPUT_HEAD: ${{ inputs.head }} + run: | + set -euo pipefail + extra_args=() + if [ -n "${INPUT_BASE:-}" ]; then extra_args+=(--base="$INPUT_BASE"); fi + if [ -n "${INPUT_HEAD:-}" ]; then extra_args+=(--head="$INPUT_HEAD"); fi + + # Fail the step on nx/git errors so empty output cannot skip integration jobs silently. + AFFECTED=$(./node_modules/.bin/nx show projects --affected "${extra_args[@]}" | tr '\n' ' ' | xargs) + echo "affected=$AFFECTED" >> "$GITHUB_OUTPUT" + + if [ -n "$AFFECTED" ]; then + echo "Affected projects: $AFFECTED" + else + echo "No affected projects found" + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f014114e7584..ffcfe94821b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,7 +100,7 @@ jobs: id: install_dependencies - name: Check for Affected Nx Projects - uses: dkhunt27/action-nx-affected-list@v6.1 + uses: ./.github/actions/nx-affected-list id: checkForAffected if: github.event_name == 'pull_request' with: From dd4766c114bfd91daaedde7c583a5a93703c7525 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 28 Apr 2026 15:40:28 +0200 Subject: [PATCH 54/77] feat(browser): Add `ingest_settings` to span v2 envelope payload (#20411) Adds `version: 2` and `ingest_settings` to the span streaming envelope payload so Relay can infer the end-user IP address and User-Agent from the incoming request ([link to spec](https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#version-and-ingest_settings-properties)). This is only emitted by the browser SDK. Both settings are currently gated behind `sendDefaultPii` (modeled after how `event.sdk.settings.infer_ip` works today). Closes https://github.com/getsentry/sentry-javascript/issues/20275 --- .../public-api/startSpan/streamed/test.ts | 2 + packages/core/src/tracing/spans/envelope.ts | 16 +++- packages/core/src/types-hoist/span.ts | 5 ++ .../test/lib/tracing/spans/envelope.test.ts | 75 ++++++++++++++++++- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index ba4e492c9f26..1f1e44e97c43 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -56,6 +56,8 @@ sentryTest( [ { content_type: 'application/vnd.sentry.items.span.v2+json', item_count: 4, type: 'span' }, { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, items: expect.any(Array), }, ], diff --git a/packages/core/src/tracing/spans/envelope.ts b/packages/core/src/tracing/spans/envelope.ts index 8429b22d7e1c..57714fdf8117 100644 --- a/packages/core/src/tracing/spans/envelope.ts +++ b/packages/core/src/tracing/spans/envelope.ts @@ -3,6 +3,7 @@ import type { DynamicSamplingContext, SpanContainerItem, StreamedSpanEnvelope } import type { SerializedStreamedSpan } from '../../types-hoist/span'; import { dsnToString } from '../../utils/dsn'; import { createEnvelope, getSdkMetadataForEnvelopeHeader } from '../../utils/envelope'; +import { isBrowser } from '../../utils/isBrowser'; /** * Creates a span v2 span streaming envelope @@ -12,9 +13,10 @@ export function createStreamedSpanEnvelope( dsc: Partial, client: Client, ): StreamedSpanEnvelope { + const options = client.getOptions(); const dsn = client.getDsn(); - const tunnel = client.getOptions().tunnel; - const sdk = getSdkMetadataForEnvelopeHeader(client.getOptions()._metadata); + const tunnel = options.tunnel; + const sdk = getSdkMetadataForEnvelopeHeader(options._metadata); const headers: StreamedSpanEnvelope[0] = { sent_at: new Date().toISOString(), @@ -23,9 +25,17 @@ export function createStreamedSpanEnvelope( ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; + const inferSetting = options.sendDefaultPii ? 'auto' : 'never'; + const spanContainer: SpanContainerItem = [ { type: 'span', item_count: serializedSpans.length, content_type: 'application/vnd.sentry.items.span.v2+json' }, - { items: serializedSpans }, + { + version: 2, + ...(isBrowser() && { + ingest_settings: { infer_ip: inferSetting, infer_user_agent: inferSetting }, + }), + items: serializedSpans, + }, ]; return createEnvelope(headers, [spanContainer]); diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index a918cc57859c..26dbbf9d29a4 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -69,6 +69,11 @@ export type SerializedStreamedSpan = Omit; }; diff --git a/packages/core/test/lib/tracing/spans/envelope.test.ts b/packages/core/test/lib/tracing/spans/envelope.test.ts index 197b7ed40365..983a7e198b73 100644 --- a/packages/core/test/lib/tracing/spans/envelope.test.ts +++ b/packages/core/test/lib/tracing/spans/envelope.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createStreamedSpanEnvelope } from '../../../../src/tracing/spans/envelope'; import type { DynamicSamplingContext } from '../../../../src/types-hoist/envelope'; import type { SerializedStreamedSpan } from '../../../../src/types-hoist/span'; +import { isBrowser } from '../../../../src/utils/isBrowser'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; +vi.mock('../../../../src/utils/isBrowser', () => ({ + isBrowser: vi.fn(() => false), +})); + +afterEach(() => { + vi.mocked(isBrowser).mockReturnValue(false); +}); + function createMockSerializedSpan(overrides: Partial = {}): SerializedStreamedSpan { return { trace_id: 'abc123', @@ -181,6 +190,7 @@ describe('createStreamedSpanEnvelope', () => { type: 'span', }, { + version: 2, items: [mockSpan], }, ], @@ -199,7 +209,7 @@ describe('createStreamedSpanEnvelope', () => { expect(envelopeItems).toEqual([ [ { type: 'span', item_count: 3, content_type: 'application/vnd.sentry.items.span.v2+json' }, - { items: [mockSpan1, mockSpan2, mockSpan3] }, + { version: 2, items: [mockSpan1, mockSpan2, mockSpan3] }, ], ]); }); @@ -222,11 +232,72 @@ describe('createStreamedSpanEnvelope', () => { type: 'span', }, { + version: 2, items: [], }, ], ], ]); }); + + it("includes ingest_settings with 'auto' values when in browser and sendDefaultPii is true", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: true })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + ingest_settings: { infer_ip: 'auto', infer_user_agent: 'auto' }, + items: [mockSpan], + }, + ], + ]); + }); + + it("includes ingest_settings with 'never' values when in browser and sendDefaultPii is false", () => { + vi.mocked(isBrowser).mockReturnValue(true); + + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: false })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + ingest_settings: { infer_ip: 'never', infer_user_agent: 'never' }, + items: [mockSpan], + }, + ], + ]); + }); + + it('omits ingest_settings when not in browser', () => { + const mockSpan = createMockSerializedSpan(); + const mockClient = new TestClient(getDefaultTestClientOptions({ sendDefaultPii: true })); + const dsc: Partial = {}; + + const envelopeItems = createStreamedSpanEnvelope([mockSpan], dsc, mockClient)[1]; + + expect(envelopeItems).toEqual([ + [ + { type: 'span', item_count: 1, content_type: 'application/vnd.sentry.items.span.v2+json' }, + { + version: 2, + items: [mockSpan], + }, + ], + ]); + }); }); }); From 124dfebfe6c04d5ab35847202f9f5f95124bd824 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 28 Apr 2026 16:03:41 +0200 Subject: [PATCH 55/77] chore(ci): Automatically bump size limit every week (#20531) Adds a github workflow for automatically bumping `.size-limit.js` thresholds (setting the new limit to currentSize + 5 KB), opening a PR against develop. Triggered both manually or cron every friday. If the workflow fails we just open an issue. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/bump-size-limits.yml | 99 +++++++++ package.json | 2 +- scripts/__fixtures__/size-limit-sample.js | 27 +++ scripts/bump-size-limits.mjs | 252 ++++++++++++++++++++++ scripts/bump-size-limits.test.ts | 241 +++++++++++++++++++++ 5 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/bump-size-limits.yml create mode 100644 scripts/__fixtures__/size-limit-sample.js create mode 100644 scripts/bump-size-limits.mjs create mode 100644 scripts/bump-size-limits.test.ts diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml new file mode 100644 index 000000000000..c713a52bae4a --- /dev/null +++ b/.github/workflows/bump-size-limits.yml @@ -0,0 +1,99 @@ +name: 'Auto-bump size-limit thresholds' + +on: + schedule: + - cron: '0 9 * * 5' # Friday 09:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: bump-size-limits + cancel-in-progress: false + +jobs: + bump: + name: Bump size-limit thresholds + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} + + - name: Checkout develop + uses: actions/checkout@v6 + with: + ref: develop + token: ${{ steps.app-token.outputs.token }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Build packages + run: yarn build + + - name: Run bumper + # Capture stdout AND exit code without failing the step on exit-2 (no-op). + # The script writes .size-limit.js in place; create-pull-request handles + # commit/branch/PR — if there's no diff, it skips opening a PR. + run: | + set +e + node scripts/bump-size-limits.mjs > /tmp/bump-summary.md + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then + echo "::error::bump script failed with exit code $code" + cat /tmp/bump-summary.md || true + exit "$code" + fi + cat /tmp/bump-summary.md + + - name: Create or update PR + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: 'chore(size-limit): auto-bump weekly drift' + title: 'chore(size-limit): weekly auto-bump' + body-path: /tmp/bump-summary.md + branch: bot/bump-size-limits + base: develop + labels: 'Dev: CI' + add-paths: '.size-limit.js' + delete-branch: true + + - name: Open or comment on failure issue + if: failure() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title='Weekly size-limit auto-bump failure' + existing=$(gh issue list --search "in:title \"$title\"" --state open --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1) + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "Auto-bump workflow failed again: $RUN_URL" + else + body=$(cat <} + * @throws {TypeError | SyntaxError} on malformed input + */ +export function parseSizeLimitOutput(raw) { + const data = JSON.parse(raw); + if (!Array.isArray(data)) { + throw new TypeError(`size-limit output: expected array, got ${typeof data}`); + } + return data.map((entry, i) => { + if (!entry || typeof entry !== 'object') { + throw new TypeError(`size-limit entry [${i}]: expected object`); + } + if (typeof entry.name !== 'string' || entry.name.length === 0) { + throw new TypeError(`size-limit entry [${i}]: 'name' must be a non-empty string`); + } + if (typeof entry.size !== 'number' || !Number.isFinite(entry.size)) { + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'size' must be a finite number`); + } + if (typeof entry.sizeLimit !== 'number' || !Number.isFinite(entry.sizeLimit)) { + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'sizeLimit' must be a finite number`); + } + return { name: entry.name, size: entry.size, sizeLimit: entry.sizeLimit }; + }); +} + +/** + * Escape a string for safe inclusion in a markdown table cell. + * Replaces newlines with spaces, escapes pipes and backticks. + * + * @param {unknown} value + * @returns {string} + */ +export function sanitizeMarkdownCell(value) { + return String(value) + .replace(/\r\n|\r|\n/g, ' ') + .replace(/[|`]/g, m => `\\${m}`); +} + +/** + * Escape a string for literal use inside a RegExp. + */ +function reEscape(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Inspect the source for the current limit string of a given entry. + * Returns null if no entry with that name is found. + * + * @param {string} src + * @param {string} name + * @returns {{ value: number, unit: 'KB' | 'KiB', raw: string } | null} + */ +export function extractCurrentLimit(src, name) { + const namePattern = `name:\\s*'${reEscape(name)}'`; + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; + const re = new RegExp(`${namePattern}[^]*?${limitPattern}`); + const m = re.exec(src); + if (!m) return null; + return { value: Number(m[1]), unit: /** @type {'KB' | 'KiB'} */ (m[2]), raw: `${m[1]} ${m[2]}` }; +} + +/** + * Convert a numeric byte value into a whole-unit display value matching the + * entry's existing unit. KB uses 1000, KiB uses 1024. + * + * @param {number} newBytes + * @param {'KB' | 'KiB'} unit + * @returns {number} + */ +function bytesToDisplay(newBytes, unit) { + const divisor = unit === 'KiB' ? BYTES_PER_KIB : BYTES_PER_KB; + return Math.ceil(newBytes / divisor); +} + +/** + * Rewrite `.size-limit.js` source to apply a list of limit updates. + * Operates on plain text — never executes the source. For each change, + * locates the entry by exact `name:` match and rewrites the next `limit:` + * line in that window. + * + * @param {string} src - contents of .size-limit.js + * @param {Array<{ name: string, newLimitKb: number, unit: 'KB' | 'KiB' }>} changes + * @returns {string} updated source + * @throws {Error} if any change's name doesn't match exactly one entry + */ +export function rewriteSizeLimitFile(src, changes) { + let out = src; + for (const { name, newLimitKb, unit } of changes) { + const namePattern = `name:\\s*'${reEscape(name)}'`; + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; + const re = new RegExp(`(${namePattern}[^]*?)${limitPattern}`); + + let matchCount = 0; + const replaced = out.replace(re, (_full, prefix) => { + matchCount++; + return `${prefix}limit: '${newLimitKb} ${unit}'`; + }); + + if (matchCount === 0) { + throw new Error(`rewriteSizeLimitFile: no entry matched for name='${name}'`); + } + out = replaced; + } + return out; +} + +/** + * Render a markdown summary of size-limit changes for the PR body. + * + * @param {Array<{ name: string, oldLimit: string, newLimit: string, delta: number, unit: 'KB' | 'KiB' }>} changes + * @returns {string} + */ +export function renderSummary(changes) { + const header = '## Size limit auto-bump\n'; + if (changes.length === 0) { + return `${header}\nAll size limits already provide ≥5 KB headroom. No changes needed.\n`; + } + const lines = [header, '| Entry | Old limit | New limit | Δ |', '| --- | --- | --- | --- |']; + for (const c of changes) { + const sign = c.delta >= 0 ? '+' : ''; + const delta = `${sign}${c.delta} ${c.unit}`; + lines.push(`| ${sanitizeMarkdownCell(c.name)} | ${c.oldLimit} | ${c.newLimit} | ${delta} |`); + } + return `${lines.join('\n')}\n`; +} + +// CLI entrypoint +async function main() { + // 1. Run size-limit. Capture JSON. execFile (no shell). + let raw; + try { + // `--silent` suppresses yarn's `yarn run v…` header and `Done in …` footer, + // which would otherwise break JSON.parse on the captured stdout. + const { stdout } = await execFileAsync('yarn', ['--silent', 'size-limit', '--json'], { + cwd: REPO_ROOT, + maxBuffer: 16 * 1024 * 1024, + }); + raw = stdout; + } catch (err) { + // size-limit exits non-zero when entries fail their existing limit. We still want the JSON. + if (err && typeof err === 'object' && 'stdout' in err && err.stdout) { + raw = /** @type {string} */ (err.stdout); + } else { + throw err; + } + } + + const measurements = parseSizeLimitOutput(raw); + + // 2. Read .size-limit.js as text. NEVER require() it. + const src = await readFile(SIZE_LIMIT_FILE, 'utf8'); + + // 3. Compute changes. + const changes = []; + const summaryRows = []; + for (const m of measurements) { + const newBytes = computeNewLimit(m.size); + + const cur = extractCurrentLimit(src, m.name); + if (!cur) { + throw new Error(`size-limit reported entry '${m.name}' but it was not found in .size-limit.js`); + } + + const displayValue = bytesToDisplay(newBytes, cur.unit); + const newLimitStr = `${displayValue} ${cur.unit}`; + + if (newLimitStr === cur.raw) { + // After unit conversion the displayed value didn't move. Skip — avoids + // no-op edits caused by KiB rounding. + continue; + } + + changes.push({ name: m.name, newLimitKb: displayValue, unit: cur.unit }); + summaryRows.push({ + name: m.name, + oldLimit: cur.raw, + newLimit: newLimitStr, + delta: displayValue - cur.value, + unit: cur.unit, + }); + } + + // 4. Print summary regardless (workflow captures stdout). + process.stdout.write(renderSummary(summaryRows)); + + if (changes.length === 0) { + process.exit(2); + } + + // 5. Atomic write: temp file + rename. + const updated = rewriteSizeLimitFile(src, changes); + const tmpPath = `${SIZE_LIMIT_FILE}.tmp`; + await writeFile(tmpPath, updated, 'utf8'); + await rename(tmpPath, SIZE_LIMIT_FILE); + + process.exit(0); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + main().catch(err => { + // oxlint-disable-next-line no-console + console.error(err.stack || err.message || err); + process.exit(1); + }); +} diff --git a/scripts/bump-size-limits.test.ts b/scripts/bump-size-limits.test.ts new file mode 100644 index 000000000000..ee046ea9f619 --- /dev/null +++ b/scripts/bump-size-limits.test.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +// @ts-expect-error -- .mjs source has no declarations under `moduleResolution: "node"` +import * as bumpSizeLimits from './bump-size-limits.mjs'; + +const { + BYTES_PER_KB, + BYTES_PER_KIB, + computeNewLimit, + extractCurrentLimit, + HEADROOM_BYTES, + parseSizeLimitOutput, + renderSummary, + rewriteSizeLimitFile, + sanitizeMarkdownCell, +} = bumpSizeLimits; + +const FIXTURE_PATH = path.join(__dirname, '__fixtures__', 'size-limit-sample.js'); +function readFixture(): string { + return fs.readFileSync(FIXTURE_PATH, 'utf8'); +} + +describe('constants', () => { + it('exports the documented thresholds', () => { + expect(HEADROOM_BYTES).toBe(5000); + expect(BYTES_PER_KB).toBe(1000); + expect(BYTES_PER_KIB).toBe(1024); + }); +}); + +describe('computeNewLimit', () => { + it('always returns currentSize + 5 KB, rounded up to the next full KB', () => { + // current 27_500 → +5000 = 32_500 → ceil to 33_000 + expect(computeNewLimit(27_500)).toBe(33_000); + // current 21_000 → +5000 = 26_000 → already round → 26_000 + expect(computeNewLimit(21_000)).toBe(26_000); + }); + + it('rounds up to next full KB', () => { + // current 27_001 → +5000 = 32_001 → ceil to 33_000 + expect(computeNewLimit(27_001)).toBe(33_000); + // current 27_999 → +5000 = 32_999 → ceil to 33_000 + expect(computeNewLimit(27_999)).toBe(33_000); + // current 28_000 → +5000 = 33_000 → already round → 33_000 + expect(computeNewLimit(28_000)).toBe(33_000); + }); + + it('handles zero-size measurements safely', () => { + expect(computeNewLimit(0)).toBe(5_000); + }); +}); + +describe('parseSizeLimitOutput', () => { + it('accepts well-formed input and returns name/size/sizeLimit triples', () => { + const raw = JSON.stringify([ + { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000, passed: false }, + { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000, passed: true }, + ]); + expect(parseSizeLimitOutput(raw)).toEqual([ + { name: '@sentry/browser', size: 27_500, sizeLimit: 27_000 }, + { name: 'CDN Bundle', size: 28_000, sizeLimit: 29_000 }, + ]); + }); + + it('rejects non-array root', () => { + expect(() => parseSizeLimitOutput('{}')).toThrow(/expected array/i); + expect(() => parseSizeLimitOutput('null')).toThrow(/expected array/i); + }); + + it('rejects malformed JSON', () => { + expect(() => parseSizeLimitOutput('not json')).toThrow(SyntaxError); + }); + + it('rejects entries missing required fields', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 1 }]))).toThrow(/sizeLimit/); + expect(() => parseSizeLimitOutput(JSON.stringify([{ size: 1, sizeLimit: 2 }]))).toThrow(/name/); + }); + + it('rejects entries with non-string name', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 42, size: 1, sizeLimit: 2 }]))).toThrow(/name/); + }); + + it('rejects entries with non-finite numbers', () => { + expect(() => parseSizeLimitOutput(JSON.stringify([{ name: 'x', size: 'one', sizeLimit: 2 }]))).toThrow(/size/); + expect(() => parseSizeLimitOutput('[{"name":"x","size":1e500,"sizeLimit":2}]')).toThrow(/size/); + }); + + it('ignores extra fields without complaint', () => { + const raw = JSON.stringify([{ name: 'x', size: 1, sizeLimit: 2, passed: true, extra: 'ok' }]); + expect(parseSizeLimitOutput(raw)).toEqual([{ name: 'x', size: 1, sizeLimit: 2 }]); + }); +}); + +describe('sanitizeMarkdownCell', () => { + it('passes plain text through unchanged', () => { + expect(sanitizeMarkdownCell('@sentry/browser')).toBe('@sentry/browser'); + }); + + it('escapes pipes', () => { + expect(sanitizeMarkdownCell('a|b')).toBe('a\\|b'); + }); + + it('escapes backticks', () => { + expect(sanitizeMarkdownCell('a`b')).toBe('a\\`b'); + }); + + it('replaces newlines with spaces', () => { + expect(sanitizeMarkdownCell('a\nb')).toBe('a b'); + expect(sanitizeMarkdownCell('a\r\nb')).toBe('a b'); + }); + + it('preserves parentheses, commas, periods', () => { + expect(sanitizeMarkdownCell('CDN Bundle (incl. Tracing, Replay)')).toBe('CDN Bundle (incl. Tracing, Replay)'); + }); +}); + +describe('renderSummary', () => { + it('renders an empty header when there are no changes', () => { + const out = renderSummary([]); + expect(out).toContain('## Size limit auto-bump'); + expect(out).toContain('All size limits already provide ≥5 KB headroom. No changes needed.'); + }); + + it('renders a markdown table for one change', () => { + const out = renderSummary([ + { name: '@sentry/browser', oldLimit: '27 KB', newLimit: '28 KB', delta: 1, unit: 'KB' }, + ]); + expect(out).toContain('| Entry | Old limit | New limit | Δ |'); + expect(out).toContain('| @sentry/browser | 27 KB | 28 KB | +1 KB |'); + }); + + it('formats negative deltas with a minus', () => { + const out = renderSummary([ + { name: '@sentry/node', oldLimit: '177 KB', newLimit: '175 KB', delta: -2, unit: 'KB' }, + ]); + expect(out).toContain('| @sentry/node | 177 KB | 175 KB | -2 KB |'); + }); + + it('uses the entry unit for the delta column (KiB)', () => { + const out = renderSummary([ + { + name: '@sentry/cloudflare (withSentry)', + oldLimit: '420 KiB', + newLimit: '425 KiB', + delta: 5, + unit: 'KiB', + }, + ]); + expect(out).toContain('| @sentry/cloudflare (withSentry) | 420 KiB | 425 KiB | +5 KiB |'); + }); + + it('escapes pipes in entry names', () => { + const out = renderSummary([{ name: 'evil|name', oldLimit: '1 KB', newLimit: '2 KB', delta: 1, unit: 'KB' }]); + expect(out).toContain('evil\\|name'); + }); +}); + +describe('rewriteSizeLimitFile', () => { + it('updates a single entry, preserving KB unit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); + expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '28 KB',/); + expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '25 KB',/); + }); + + it('updates entries with name-prefix collision correctly', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [ + { name: '@sentry/browser - with treeshaking flags', newLimitKb: 30, unit: 'KB' }, + ]); + expect(out).toMatch(/name: '@sentry\/browser',[\s\S]*?limit: '27 KB',/); + expect(out).toMatch(/name: '@sentry\/browser - with treeshaking flags',[\s\S]*?limit: '30 KB',/); + }); + + it('preserves KiB unit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: '@sentry/cloudflare (withSentry)', newLimitKb: 425, unit: 'KiB' }]); + expect(out).toMatch(/name: '@sentry\/cloudflare \(withSentry\)',[\s\S]*?limit: '425 KiB',/); + }); + + it('handles names with parentheses and decimals in original limit', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [{ name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }]); + expect(out).toMatch(/name: 'CDN Bundle \(incl\. Tracing\)',[\s\S]*?limit: '50 KB',/); + expect(out).not.toContain("limit: '46.5 KB'"); + }); + + it('applies multiple changes', () => { + const src = readFixture(); + const out = rewriteSizeLimitFile(src, [ + { name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }, + { name: 'CDN Bundle (incl. Tracing)', newLimitKb: 50, unit: 'KB' }, + ]); + expect(out).toContain("limit: '28 KB'"); + expect(out).toContain("limit: '50 KB'"); + }); + + it('throws if a name does not match any entry', () => { + const src = readFixture(); + expect(() => rewriteSizeLimitFile(src, [{ name: '@sentry/nonexistent', newLimitKb: 1, unit: 'KB' }])).toThrow( + /@sentry\/nonexistent/, + ); + }); + + it('returns unchanged source when changes is empty', () => { + const src = readFixture(); + expect(rewriteSizeLimitFile(src, [])).toBe(src); + }); + + it('does not modify the input string in-place', () => { + const src = readFixture(); + const before = src; + rewriteSizeLimitFile(src, [{ name: '@sentry/browser', newLimitKb: 28, unit: 'KB' }]); + expect(src).toBe(before); + }); +}); + +describe('extractCurrentLimit', () => { + const FIXTURE_SRC = `module.exports = [ + { name: '@sentry/browser', limit: '27 KB' }, + { name: '@sentry/cloudflare (withSentry)', limit: '420 KiB' }, +];`; + + it('extracts the limit value and unit by name', () => { + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/browser')).toEqual({ + value: 27, + unit: 'KB', + raw: '27 KB', + }); + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/cloudflare (withSentry)')).toEqual({ + value: 420, + unit: 'KiB', + raw: '420 KiB', + }); + }); + + it('returns null when the name is not present', () => { + expect(extractCurrentLimit(FIXTURE_SRC, '@sentry/missing')).toBeNull(); + }); +}); From 7b6325a9dabc0494ba4d1aac7e2c9a0f5fd41aca Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:37:38 +0200 Subject: [PATCH 56/77] deps(minimatch): Upgrade patch version to use new `brace-expansion` peer-dep (#20198) `brace-expansion` package causes zero-step sequence causes process hang and memory exhaustion (range: >=4.0.0 <5.0.5). Updating the patch version of minimatch uses the newer version. Related GHSA: https://github.com/advisories/GHSA-f886-m6hf-6m8v Related issue (comment): https://github.com/getsentry/sentry-javascript/issues/19447#issuecomment-4214234576 --- package.json | 2 +- yarn.lock | 19 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 83bcf10c7805..396d361ade40 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "we need to resolve them to the CommonJS versions." ], "resolutions": { - "**/nx/minimatch": "10.2.4", + "**/nx/minimatch": "10.2.5", "**/ng-packagr/postcss-url/minimatch": "3.1.5", "**/@angular-devkit/build-angular/minimatch": "5.1.9", "gauge/strip-ansi": "6.0.1", diff --git a/yarn.lock b/yarn.lock index 0d633a8582bd..40d531ea0275 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12419,7 +12419,7 @@ brace-expansion@^2.0.1, brace-expansion@^2.0.2: dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.2, brace-expansion@^5.0.5: +brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== @@ -22162,12 +22162,12 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@10.2.4: - version "10.2.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" - integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== +minimatch@10.2.4, minimatch@10.2.5, minimatch@^10.2.2, minimatch@^10.2.4: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== dependencies: - brace-expansion "^5.0.2" + brace-expansion "^5.0.5" "minimatch@2 || 3", minimatch@3.1.5, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@~3.0.4: version "3.1.5" @@ -22183,13 +22183,6 @@ minimatch@5.1.0, minimatch@5.1.9, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.2.2, minimatch@^10.2.4: - version "10.2.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - dependencies: - brace-expansion "^5.0.5" - minimatch@^7.4.1, minimatch@~7.4.9: version "7.4.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.9.tgz#ef35412b1b36261b78ef1b2f0db29b759bbcaf5d" From 2a48cd0fa68bea09c556ece038660eaa51b46f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 28 Apr 2026 17:04:36 +0200 Subject: [PATCH 57/77] test(supabase): Stop supabase before initializing (#20563) closes #20214 The test failed with: ``` failed to start docker container: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint supabase_db_test-application (a2890079e9476a7ded9e9fbba9a9a60e82b00e74ea23f746beef34d99cbfb830): failed to bind host port for 0.0.0.0:54322:172.18.0.2:5432/tcp: address already in use ``` So for some reason the runner was reusing something from before, where the container ran. With that we stop Docker via the `supabase` CLI and then init it. --- .../e2e-tests/test-applications/supabase-nextjs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json index 4b9d9062dca3..c5c86c0d31ae 100644 --- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "clean": "npx rimraf node_modules pnpm-lock.yaml .next", - "start-local-supabase": "supabase init --force --workdir . && supabase start -o env && supabase db reset", + "start-local-supabase": "supabase stop --no-backup 2>/dev/null || true && supabase init --force --workdir . && supabase start -o env && supabase db reset", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", "test:assert": "pnpm test:prod" From 0daf9622019f93e4eb32b81d70c201579bcfb7e4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 28 Apr 2026 17:34:17 +0200 Subject: [PATCH 58/77] ci(size-bump): Fix path in size-limit auto-bump workflow (#20566) The install-dependencies composite action references `env.CACHED_DEPENDENCY_PATHS` as the `actions/cache` path input. Without it, the cache step fails with `'Input required and not supplied: path'`. Mirror the env block from build.yml. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/bump-size-limits.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/bump-size-limits.yml b/.github/workflows/bump-size-limits.yml index c713a52bae4a..d837fc254bf2 100644 --- a/.github/workflows/bump-size-limits.yml +++ b/.github/workflows/bump-size-limits.yml @@ -10,6 +10,13 @@ permissions: pull-requests: write issues: write +env: + CACHED_DEPENDENCY_PATHS: | + ${{ github.workspace }}/node_modules + ${{ github.workspace }}/packages/*/node_modules + ${{ github.workspace }}/dev-packages/*/node_modules + ~/.cache/mongodb-binaries/ + concurrency: group: bump-size-limits cancel-in-progress: false From 6708ff21c33527ae0c5d910a8d638bc457d24b09 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 28 Apr 2026 12:14:38 -0400 Subject: [PATCH 59/77] fix(replay): Avoid main-thread blocking in WorkerHandler under event bursts (#20548) Closes #20547 `WorkerHandler.postMessage` attached a fresh `'message'` listener per request, removed only when the matching response arrived. Under a burst of N in-flight requests, every worker response was dispatched to all N attached listeners, causing the main-thread to lock up. I replaced multiple listeners registration with a single long-lived listener attached in the constructor, routing responses through a `Map`. `destroy()` also removes the listener and rejects pending requests instead of leaving them hanging. Public API unchanged. I created a repro in https://github.com/logaretm/sentry-replay-worker-quadratic-repro and verified the issue before the fix and after. --- .size-limit.js | 2 +- .../src/eventBuffer/WorkerHandler.ts | 80 +++++--- .../unit/eventBuffer/WorkerHandler.test.ts | 174 ++++++++++++++++++ 3 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts diff --git a/.size-limit.js b/.size-limit.js index 38880cc91247..18580bf7b182 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '271 KB', + limit: '272 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts index 790185712b1c..dba3c858b711 100644 --- a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts +++ b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts @@ -2,6 +2,12 @@ import { DEBUG_BUILD } from '../debug-build'; import type { WorkerRequest, WorkerResponse } from '../types'; import { debug } from '../util/logger'; +interface PendingRequest { + method: WorkerRequest['method']; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +} + /** * Event buffer that uses a web worker to compress events. * Exported only for testing. @@ -10,10 +16,16 @@ export class WorkerHandler { private _worker: Worker; private _id: number; private _ensureReadyPromise?: Promise; + private _pending: Map; public constructor(worker: Worker) { this._worker = worker; this._id = 0; + this._pending = new Map(); + // A single long-lived listener routes responses by id. Per-request + // listeners would make worker dispatch O(n) per response, so a burst of N + // in-flight requests becomes O(n^2) main-thread work. + this._worker.addEventListener('message', this._onMessage); } /** @@ -62,6 +74,9 @@ export class WorkerHandler { */ public destroy(): void { DEBUG_BUILD && debug.log('Destroying compression worker'); + this._worker.removeEventListener('message', this._onMessage); + this._pending.forEach(pending => pending.reject(new Error('Worker destroyed'))); + this._pending.clear(); this._worker.terminate(); } @@ -71,39 +86,46 @@ export class WorkerHandler { public postMessage(method: WorkerRequest['method'], arg?: WorkerRequest['arg']): Promise { const id = this._getAndIncrementId(); - return new Promise((resolve, reject) => { - const listener = ({ data }: MessageEvent): void => { - const response = data as WorkerResponse; - if (response.method !== method) { - return; - } - - // There can be multiple listeners for a single method, the id ensures - // that the response matches the caller. - if (response.id !== id) { - return; - } - - // At this point, we'll always want to remove listener regardless of result status - this._worker.removeEventListener('message', listener); + return new Promise((resolve, reject) => { + this._pending.set(id, { + method, + resolve: resolve as (value: unknown) => void, + reject, + }); + try { + this._worker.postMessage({ id, method, arg }); + } catch (error) { + // If postMessage throws synchronously (e.g. DataCloneError, worker + // already terminated), drop the pending entry so it doesn't leak. + this._pending.delete(id); + reject(error); + } + }); + } - if (!response.success) { - // TODO: Do some error handling, not sure what - DEBUG_BUILD && debug.error('Error in compression worker: ', response.response); + private _onMessage = ({ data }: MessageEvent): void => { + const response = data as WorkerResponse; + // The worker emits an init message with `id: undefined` on load, which is + // handled by `ensureReady()` via its own listener. Ignore anything that + // doesn't carry a numeric id we issued. + if (typeof response.id !== 'number') { + return; + } + const pending = this._pending.get(response.id); + if (!pending || pending.method !== response.method) { + return; + } - reject(new Error('Error in compression worker')); - return; - } + this._pending.delete(response.id); - resolve(response.response as T); - }; + if (!response.success) { + DEBUG_BUILD && debug.error('Error in compression worker: ', response.response); + pending.reject(new Error('Error in compression worker')); + return; + } - // Note: we can't use `once` option because it's possible it needs to - // listen to multiple messages - this._worker.addEventListener('message', listener); - this._worker.postMessage({ id, method, arg }); - }); - } + pending.resolve(response.response); + }; /** Get the current ID and increment it for the next call. */ private _getAndIncrementId(): number { diff --git a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts new file mode 100644 index 000000000000..0b28cec37348 --- /dev/null +++ b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; +import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler'; +import type { WorkerResponse } from '../../../src/types'; + +/** + * Minimal Worker stub that lets tests control when responses dispatch and + * track how many 'message' listeners are attached at any time. Real workers + * are async; we model that with a queue we drain manually so the test can + * assert on the listener count while requests are in flight. + */ +class MockWorker implements Pick { + public listenerCount = 0; + public terminated = false; + + private _listeners = new Map>(); + private _pendingRequests: Array<{ id: number; method: string }> = []; + + public addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (!this._listeners.has(type)) this._listeners.set(type, new Set()); + this._listeners.get(type)!.add(listener); + if (type === 'message') this.listenerCount++; + } + + public removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + const set = this._listeners.get(type); + if (set?.delete(listener) && type === 'message') this.listenerCount--; + } + + public postMessage(data: unknown): void { + const { id, method } = data as { id: number; method: string }; + this._pendingRequests.push({ id, method }); + } + + public terminate(): void { + this.terminated = true; + } + + /** Dispatch the queued response for a given id (FIFO order otherwise). */ + public flushOne(overrides?: Partial): void { + const next = this._pendingRequests.shift(); + if (!next) return; + const response: WorkerResponse = { + id: next.id, + method: next.method, + success: true, + response: `result-${next.id}`, + ...overrides, + }; + this._dispatch('message', { data: response } as MessageEvent); + } + + public flushAll(): void { + while (this._pendingRequests.length > 0) this.flushOne(); + } + + /** Dispatch a message that doesn't correspond to a queued request. */ + public dispatchRaw(response: Partial): void { + this._dispatch('message', { data: response } as MessageEvent); + } + + public get pendingCount(): number { + return this._pendingRequests.length; + } + + private _dispatch(type: string, event: MessageEvent): void { + const set = this._listeners.get(type); + if (!set) return; + for (const listener of set) { + if (typeof listener === 'function') listener(event); + else listener.handleEvent(event); + } + } +} + +const makeHandler = () => { + const worker = new MockWorker(); + const handler = new WorkerHandler(worker as unknown as Worker); + return { worker, handler }; +}; + +describe('Unit | eventBuffer | WorkerHandler', () => { + it('does not attach a new message listener per postMessage call (regression: #20547)', async () => { + const { worker, handler } = makeHandler(); + + // One listener is attached at construction time. + expect(worker.listenerCount).toBe(1); + + // Fire a burst of in-flight requests. The pre-fix implementation attached + // one listener per call, growing linearly; this would dispatch every + // response to all attached listeners (O(n^2) main-thread work). + const promises = Array.from({ length: 100 }, (_, i) => handler.postMessage('addEvent', `arg-${i}`)); + + expect(worker.listenerCount).toBe(1); + expect(worker.pendingCount).toBe(100); + + worker.flushAll(); + await Promise.all(promises); + + // Listener count is still 1 after the burst drains. + expect(worker.listenerCount).toBe(1); + }); + + it('resolves concurrent postMessage calls with the correct response per id', async () => { + const { worker, handler } = makeHandler(); + + const p0 = handler.postMessage('addEvent', 'a'); + const p1 = handler.postMessage('addEvent', 'b'); + const p2 = handler.postMessage('addEvent', 'c'); + + worker.flushAll(); + + await expect(p0).resolves.toBe('result-0'); + await expect(p1).resolves.toBe('result-1'); + await expect(p2).resolves.toBe('result-2'); + }); + + it('rejects when the worker reports success: false', async () => { + const { worker, handler } = makeHandler(); + + const promise = handler.postMessage('addEvent', 'a'); + worker.flushOne({ success: false, response: 'boom' }); + + await expect(promise).rejects.toThrow('Error in compression worker'); + }); + + it('rejects and cleans up the pending entry when worker.postMessage throws synchronously', async () => { + const { worker, handler } = makeHandler(); + const error = new Error('DataCloneError'); + worker.postMessage = () => { + throw error; + }; + + await expect(handler.postMessage('addEvent', 'a')).rejects.toBe(error); + + // A subsequent successful call should still work — the previous failure + // didn't leave a stale entry behind. + worker.postMessage = MockWorker.prototype.postMessage.bind(worker); + const promise = handler.postMessage('addEvent', 'b'); + worker.flushOne(); + await expect(promise).resolves.toBe('result-1'); + }); + + it('ignores messages without a numeric id (e.g. the worker init message)', async () => { + const { worker, handler } = makeHandler(); + + const promise = handler.postMessage('addEvent', 'a'); + + // Simulate the init message the worker emits on load. Should be ignored + // and not crash. + worker.dispatchRaw({ id: undefined, method: 'init', success: true }); + + // The legitimate response still resolves. + worker.flushOne(); + await expect(promise).resolves.toBe('result-0'); + }); + + it('destroy() rejects pending requests and detaches the listener', async () => { + const { worker, handler } = makeHandler(); + + const p1 = handler.postMessage('addEvent', 'a'); + const p2 = handler.postMessage('addEvent', 'b'); + + handler.destroy(); + + await expect(p1).rejects.toThrow('Worker destroyed'); + await expect(p2).rejects.toThrow('Worker destroyed'); + expect(worker.terminated).toBe(true); + expect(worker.listenerCount).toBe(0); + }); +}); From 30eb68fff5077211c30c61ba74625e66ab514870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 28 Apr 2026 22:56:11 +0200 Subject: [PATCH 60/77] test(tanstack): Prefix test labels (#20569) --- .../test-applications/tanstackstart-react/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index f689aba2d7e2..0525acfad587 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -48,22 +48,22 @@ "sentryTest": { "variants": [ { - "label": "tunnel-generated", + "label": "tanstackstart-react (tunnel-generated)", "build-command": "pnpm test:build:tunnel-generated", "assert-command": "pnpm test:assert:tunnel-generated" }, { - "label": "tunnel-static", + "label": "tanstackstart-react (tunnel-static)", "build-command": "pnpm test:build:tunnel-static", "assert-command": "pnpm test:assert:tunnel-static" }, { - "label": "tunnel-custom", + "label": "tanstackstart-react (tunnel-custom)", "build-command": "pnpm test:build:tunnel-custom", "assert-command": "pnpm test:assert:tunnel-custom" }, { - "label": "tunnel-object", + "label": "tanstackstart-react (tunnel-object)", "build-command": "pnpm test:build:tunnel-object", "assert-command": "pnpm test:assert:tunnel-object" } From 75c8d0a75b5fca40df277348362515194637c146 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:34:21 +0200 Subject: [PATCH 61/77] chore(size-limit): weekly auto-bump (#20572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Size limit auto-bump | Entry | Old limit | New limit | Δ | | --- | --- | --- | --- | | @sentry/browser | 27 KB | 32 KB | +5 KB | | @sentry/browser - with treeshaking flags | 25 KB | 30 KB | +5 KB | | @sentry/browser (incl. Tracing) | 45 KB | 50 KB | +5 KB | | @sentry/browser (incl. Tracing + Span Streaming) | 48 KB | 52 KB | +4 KB | | @sentry/browser (incl. Tracing, Profiling) | 50 KB | 55 KB | +5 KB | | @sentry/browser (incl. Tracing, Replay) | 84 KB | 89 KB | +5 KB | | @sentry/browser (incl. Tracing, Replay) - with treeshaking flags | 75 KB | 78 KB | +3 KB | | @sentry/browser (incl. Tracing, Replay with Canvas) | 89 KB | 94 KB | +5 KB | | @sentry/browser (incl. Tracing, Replay, Feedback) | 101 KB | 106 KB | +5 KB | | @sentry/browser (incl. Feedback) | 44 KB | 49 KB | +5 KB | | @sentry/browser (incl. sendFeedback) | 32 KB | 36 KB | +4 KB | | @sentry/browser (incl. FeedbackAsync) | 37 KB | 42 KB | +5 KB | | @sentry/browser (incl. Metrics) | 28 KB | 33 KB | +5 KB | | @sentry/browser (incl. Logs) | 28 KB | 33 KB | +5 KB | | @sentry/browser (incl. Metrics & Logs) | 29 KB | 34 KB | +5 KB | | @sentry/react | 28 KB | 33 KB | +5 KB | | @sentry/react (incl. Tracing) | 47 KB | 52 KB | +5 KB | | @sentry/vue | 32 KB | 37 KB | +5 KB | | @sentry/vue (incl. Tracing) | 46 KB | 51 KB | +5 KB | | @sentry/svelte | 27 KB | 32 KB | +5 KB | | CDN Bundle | 29 KB | 34 KB | +5 KB | | CDN Bundle (incl. Tracing) | 47 KB | 52 KB | +5 KB | | CDN Bundle (incl. Logs, Metrics) | 31 KB | 36 KB | +5 KB | | CDN Bundle (incl. Tracing, Logs, Metrics) | 48 KB | 53 KB | +5 KB | | CDN Bundle (incl. Replay, Logs, Metrics) | 70 KB | 75 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay) | 84 KB | 89 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Logs, Metrics) | 85 KB | 90 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Feedback) | 90 KB | 95 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) | 91 KB | 96 KB | +5 KB | | CDN Bundle - uncompressed | 85 KB | 90 KB | +5 KB | | CDN Bundle (incl. Tracing) - uncompressed | 140 KB | 145 KB | +5 KB | | CDN Bundle (incl. Logs, Metrics) - uncompressed | 89 KB | 94 KB | +5 KB | | CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed | 144 KB | 149 KB | +5 KB | | CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed | 213 KB | 218 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay) - uncompressed | 258 KB | 263 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed | 261 KB | 266 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed | 271 KB | 276 KB | +5 KB | | CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed | 275 KB | 280 KB | +5 KB | | @sentry/nextjs (client) | 49 KB | 54 KB | +5 KB | | @sentry/sveltekit (client) | 45 KB | 50 KB | +5 KB | | @sentry/node-core | 60 KB | 64 KB | +4 KB | | @sentry/node | 177 KB | 176 KB | -1 KB | | @sentry/node - without tracing | 100 KB | 103 KB | +3 KB | | @sentry/aws-serverless | 117 KB | 120 KB | +3 KB | | @sentry/cloudflare (withSentry) - minified | 250 KiB | 167 KiB | -83 KiB | | @sentry/cloudflare (withSentry) | 420 KiB | 412 KiB | -8 KiB | Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> Co-authored-by: Charly Gomez --- .size-limit.js | 92 +++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 18580bf7b182..34ea3e254e90 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '27 KB', + limit: '32 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -16,7 +16,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '25 KB', + limit: '30 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -40,7 +40,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '45 KB', + limit: '50 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -48,7 +48,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), gzip: true, - limit: '48 KB', + limit: '52 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -56,7 +56,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, - limit: '50 KB', + limit: '55 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -64,7 +64,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '84 KB', + limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -72,7 +72,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75 KB', + limit: '78 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -96,7 +96,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '89 KB', + limit: '94 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -104,7 +104,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '101 KB', + limit: '106 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -112,7 +112,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '44 KB', + limit: '49 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -120,7 +120,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '32 KB', + limit: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -128,7 +128,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '37 KB', + limit: '42 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -136,7 +136,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '28 KB', + limit: '33 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -144,7 +144,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'logger'), gzip: true, - limit: '28 KB', + limit: '33 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -152,7 +152,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '29 KB', + limit: '34 KB', disablePlugins: ['@size-limit/esbuild'], }, // React SDK (ESM) @@ -162,7 +162,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '28 KB', + limit: '33 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -171,7 +171,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '47 KB', + limit: '52 KB', disablePlugins: ['@size-limit/esbuild'], }, // Vue SDK (ESM) @@ -180,7 +180,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '32 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -188,7 +188,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '46 KB', + limit: '51 KB', disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) @@ -197,7 +197,7 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '27 KB', + limit: '32 KB', disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles @@ -205,63 +205,63 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '29 KB', + limit: '34 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '47 KB', + limit: '52 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '31 KB', + limit: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '48 KB', + limit: '53 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '70 KB', + limit: '75 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '84 KB', + limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '85 KB', + limit: '90 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '90 KB', + limit: '95 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '91 KB', + limit: '96 KB', disablePlugins: ['@size-limit/esbuild'], }, // browser CDN bundles (non-gzipped) @@ -270,7 +270,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '85 KB', + limit: '90 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -278,7 +278,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '140 KB', + limit: '145 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -286,7 +286,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '89 KB', + limit: '94 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -294,7 +294,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '144 KB', + limit: '149 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -302,7 +302,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '213 KB', + limit: '218 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -310,7 +310,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '258 KB', + limit: '263 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -318,7 +318,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '261 KB', + limit: '266 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -326,7 +326,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '272 KB', + limit: '276 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -334,7 +334,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '275 KB', + limit: '280 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -344,7 +344,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '49 KB', + limit: '54 KB', disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) @@ -354,7 +354,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '45 KB', + limit: '50 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node-Core SDK (ESM) @@ -364,7 +364,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '60 KB', + limit: '64 KB', disablePlugins: ['@size-limit/esbuild'], }, // Node SDK (ESM) @@ -374,7 +374,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '177 KB', + limit: '176 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -382,7 +382,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '100 KB', + limit: '103 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '117 KB', + limit: '120 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -417,7 +417,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '250 KiB', + limit: '167 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -437,7 +437,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '420 KiB', + limit: '412 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { From 76d60a0a9917fd349ff205e3825509e624e72809 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 29 Apr 2026 09:37:34 +0200 Subject: [PATCH 62/77] feat(nextjs): Set global attribute for turbopack usage (#20558) leftover from https://github.com/getsentry/sentry-javascript/pull/20527 --- packages/nextjs/src/server/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 1008601a3318..d283bef38263 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -284,6 +284,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { getGlobalScope().setTag('turbopack', true); + getGlobalScope().setAttribute('turbopack', true); } } catch { // Noop From 337c8da9b1ef996407c54bd5338575ef5d643ea9 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:47:20 +0200 Subject: [PATCH 63/77] docs: Add deprecation notices to `bin` scripts (#20570) If there is only one `bin` script (which is the case for Remix and Profiling), it's possible to just run `npx @sentry/remix` and it will run this one script. But to make it more clear what's going to happen, we're introducing an argument `--upload-sourcemaps`. **Remix:** The script usage changes so we can rename the `bin` script in the next major version. **Profiling:** The script is going to be deleted and deprecation notices are added. Related to: https://github.com/getsentry/sentry-javascript/issues/20422 --- packages/profiling-node/README.md | 6 ++++-- .../scripts/prune-profiler-binaries.js | 4 ++++ packages/remix/README.md | 6 +++--- packages/remix/scripts/sentry-upload-sourcemaps.js | 12 +++++++++++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/profiling-node/README.md b/packages/profiling-node/README.md index 51e447640c14..b5957f023b10 100644 --- a/packages/profiling-node/README.md +++ b/packages/profiling-node/README.md @@ -238,8 +238,10 @@ Once you run `node esbuild.serverless.js` esbuild wil bundle and output the file the binaries will be copied. This is wasteful as you will likely only need one of these libraries to be available during runtime. -To prune the other libraries, profiling-node ships with a small utility script that helps you prune unused binaries. The -script can be invoked via `sentry-prune-profiler-binaries`: +> **Deprecation notice:** This script will be removed in the next major version. If you depend on it, please comment on +> [this issue](https://github.com/getsentry/sentry-javascript/issues/20567). + +To prune the other libraries, profiling-node ships with a small utility script that helps you prune unused binaries: ```bash npx --package=@sentry/profiling-node sentry-prune-profiler-binaries diff --git a/packages/profiling-node/scripts/prune-profiler-binaries.js b/packages/profiling-node/scripts/prune-profiler-binaries.js index 4314c2cb7fb2..11e8dc7f05f4 100755 --- a/packages/profiling-node/scripts/prune-profiler-binaries.js +++ b/packages/profiling-node/scripts/prune-profiler-binaries.js @@ -56,6 +56,10 @@ Arguments:\n process.exit(0); } +console.warn( + '[Sentry] Warning: This script will be removed in the next major version. See: https://github.com/getsentry/sentry-javascript/issues/20567', +); + const ARGV_ERRORS = []; const NODE_TO_ABI = { diff --git a/packages/remix/README.md b/packages/remix/README.md index 2589ed9f7e6b..ddb0c643a869 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -122,13 +122,13 @@ Sentry.captureEvent({ The Remix SDK provides a script that automatically creates a release and uploads sourcemaps. To generate sourcemaps with Remix, you need to call `remix build` with the `--sourcemap` option. -On release, call `sentry-upload-sourcemaps` to upload source maps and create a release: +On release, call the upload sourcemaps command to upload source maps and create a release: ```bash -npx --package=@sentry/remix sentry-upload-sourcemaps +npx @sentry/remix --upload-sourcemaps ``` -To see more details on how to use the command, call `npx --package=@sentry/remix sentry-upload-sourcemaps --help`. +To see more details on how to use the command, run `npx @sentry/remix --upload-sourcemaps --help`. For more advanced configuration, [directly use `sentry-cli` to upload source maps.](https://github.com/getsentry/sentry-cli). diff --git a/packages/remix/scripts/sentry-upload-sourcemaps.js b/packages/remix/scripts/sentry-upload-sourcemaps.js index 625526af5a8a..ceb41d3ac14c 100755 --- a/packages/remix/scripts/sentry-upload-sourcemaps.js +++ b/packages/remix/scripts/sentry-upload-sourcemaps.js @@ -8,6 +8,10 @@ const DEFAULT_URL_PREFIX = '~/build/'; const DEFAULT_BUILD_PATH = 'public/build'; const argv = yargs(process.argv.slice(2)) + .option('upload-sourcemaps', { + type: 'boolean', + describe: 'Specifies the upload sourcemaps command. Recommended for forward compatibility.', + }) .option('release', { type: 'string', describe: @@ -48,7 +52,7 @@ const argv = yargs(process.argv.slice(2)) default: true, }) .usage( - 'Usage: $0\n' + + 'Usage: npx @sentry/remix --upload-sourcemaps\n' + ' [--release RELEASE]\n' + ' [--org ORG]\n' + ' [--project PROJECT]\n' + @@ -64,6 +68,12 @@ const argv = yargs(process.argv.slice(2)) ) .wrap(120).argv; +if (!argv.uploadSourcemaps) { + process.stderr.write( + '[Sentry] Warning: Calling this script without --upload-sourcemaps is deprecated. Use: `npx @sentry/remix --upload-sourcemaps`\n', + ); +} + const buildPath = argv.buildPath || DEFAULT_BUILD_PATH; const urlPrefix = argv.urlPrefix || DEFAULT_URL_PREFIX; From fae6a7d184fc5981d96a7b770238731227aba05c Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:49:24 +0200 Subject: [PATCH 64/77] chore(skill): Add skill for writing unit and E2E tests (#20561) Add skill for writing tests. Created and evaluated with https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md Used sources for "how to write good tests": - https://vitest.dev/guide/learn/writing-tests-with-ai.html - https://github.com/andredesousa/javascript-unit-testing-best-practices - my knowledge from university - LOL --- .agents/skills/write-tests/SKILL.md | 407 ++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 .agents/skills/write-tests/SKILL.md diff --git a/.agents/skills/write-tests/SKILL.md b/.agents/skills/write-tests/SKILL.md new file mode 100644 index 000000000000..94fa4dee89df --- /dev/null +++ b/.agents/skills/write-tests/SKILL.md @@ -0,0 +1,407 @@ +--- +name: write-tests +description: > + Write high-quality unit tests (Vitest) and E2E tests (Playwright) following senior test-engineering + practices. Use this skill whenever asked to write tests, add test coverage, create test cases, + fix failing tests, add missing assertions, test a new feature, write specs, or cover edge cases. + Also trigger when the user says "write tests for", "add tests", "test this", "cover this", + "needs tests", "add E2E test", "add unit test", "test coverage", or when reviewing code and + noticing missing test coverage. +--- + +# Write Tests + +Tests are not production code. They are documentation — each one is a tiny executable spec that says +"this system does X." A reader should grasp the intent in seconds. A failure should point to exactly +one broken behavior, not leave you going through a 40-line test body. + +## Workflow + +Follow these steps in order before writing any test code. + +1. **Decide the framework.** Testing a function's return value, side effects, or module interactions + → Vitest (lives under `packages//test/`). Testing that a real HTTP request to a running app + produces the correct Sentry envelope → Playwright (lives under + `dev-packages/e2e-tests/test-applications//tests/`). + +2. **Read 2–3 existing test files** in the target `test/` directory. Specifically note: + - Which `vi.mock` style they use (string path or import form) + - What cleanup they do in `beforeEach` (`clearAllMocks` vs `restoreAllMocks`) + - How they import the module under test (`../../src/...` vs `@sentry/...`) + - The `describe`/`it` nesting depth and naming style + - What setup functions are called together — does the function under test require companion + initialization? (e.g., does `patchRoute` also need `patchAppUse` to work correctly?) + + Match what you find. Consistency within a package matters more than idealized best practice. + +3. **Check for existing test utilities** before writing helpers from scratch: + - `packages/core/test/mocks/` — `TestClient`, `getDefaultTestClientOptions`, fake transports + - `packages/core/test/testutils.ts` — `clearGlobalScope()`, version gating + - `dev-packages/test-utils/` — `waitForTransaction`, `waitForError`, `waitForSession`, + `getPlaywrightConfig`, mock Sentry server, event proxy + - `dev-packages/node-integration-tests/utils/` — `createEsmAndCjsTests`, assertion helpers + +4. **Identify the behaviors that matter most** — edge cases, error paths, boundary conditions. + Don't aim for quantity; aim for the tests that would catch real regressions. + +--- + +## Core principles + +### Fewer tests, better tests + +The goal is not to maximize test count. A large suite of shallow happy-path tests gives a false +sense of coverage — they pass on every change, including changes that introduce bugs. A smaller +suite that targets edge cases, error paths, and boundary conditions catches far more regressions. + +Before writing a test, ask: "If this test didn't exist, what bug could ship?" If you can't answer +that concretely, the test probably isn't worth writing. Prioritize: + +- **Edge cases and boundaries** — the off-by-one, the empty array, the `null` input +- **Error paths** — does the function fail gracefully or silently swallow the error? +- **Integration seams** — where two modules or systems interact (e.g., middleware calling `next()`) +- **Behavior that previously broke** — regression tests for known bugs + +Don't waste tests on: trivial getters/setters, pure delegation to well-tested libraries, +TypeScript type constraints (the compiler already checks those), or re-testing the same behavior +that a higher-level test already covers. + +### Arrange → Act → Assert + +Structure every test with the AAA pattern, separated by blank lines. The whitespace makes the +phases obvious — no labels or comments needed. + +```typescript +it('skips errors already captured by middleware', () => { + const error = new Error('already captured'); + Object.defineProperty(error, '__sentry_captured__', { value: true }); + + responseHandler(createMockContext(500, error)); + + expect(mockCaptureException).not.toHaveBeenCalled(); +}); +``` + +### One behavior, one reason to fail + +Each test makes exactly one behavioral claim. Multiple `expect` calls are fine when they assert on +different facets of the _same_ outcome. But if you're checking two unrelated behaviors, those are +two tests. No conditional logic, no branching, no try/catch — a test is a straight line. + +### Assert behavior, not implementation + +If someone refactored the internals but the function still returned the correct result, would this +test break? If yes, you're testing wiring, not behavior. + +```typescript +// Bad: asserts nothing meaningful +it('handles the request', async () => { + expect(() => handler(mockReq)).not.toThrow(); +}); + +// Good: asserts on the observable outcome +it('sets transaction name from route path', () => { + responseHandler(createMockContext(200)); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); +}); +``` + +### Precise assertions + +Default to exact matching. `toMatchObject`, `expect.objectContaining`, and `expect.arrayContaining` +silently ignore fields that matter. This has caused real bugs to ship in this codebase. + +**Use `toEqual` by default.** The same applies to `toHaveBeenCalledWith` — spell out every +argument rather than wrapping in `objectContaining`. This is the single most common place where +loose assertions creep in: + +```typescript +// Bad: silently ignores any missing or extra properties in the call +expect(startSpan).toHaveBeenCalledWith(expect.objectContaining({ name: 'middleware', op: 'middleware.hono' })); + +// Good: exact match on the full argument — if the shape changes, the test catches it +expect(startSpan).toHaveBeenCalledWith({ + name: 'middleware', + op: 'middleware.hono', + onlyIfParent: true, + parentSpan: fakeRootSpan, + attributes: { 'sentry.op': 'middleware.hono', 'sentry.origin': 'auto.middleware.hono' }, +}); +``` + +The only valid reasons to use `toMatchObject` or `objectContaining` are: **(1)** the object is +generated by a framework or third-party library and contains fields you don't control (timestamps, +random IDs, internal framework state), or **(2)** the object has 10+ fields and the test only +cares about 2–3 of them (in which case individual `.toBe()` checks on those fields are still +preferred). If you wrote the object being asserted, you can spell it out — use `toEqual`. + +When you do fall back, prefer individual `.toBe()` checks over `objectContaining`: + +```typescript +expect(event.transaction).toBe('GET /users/:id'); +expect(event.contexts?.trace?.op).toBe('http.server'); +``` + +**Every `toContain` / `toContainEqual` needs a `toHaveLength` companion.** Without it, the +assertion passes even if the array has unexpected extra items: + +```typescript +// Bad: doesn't notice extra unexpected spans +expect(spanNames).toContain('authMiddleware'); + +// Good: locks down both content and count +expect(spanNames).toHaveLength(1); +expect(spanNames).toContain('authMiddleware'); +``` + +**Use exported constants, not magic numbers.** If the code under test uses named constants like +`SPAN_STATUS_OK`, reference those same constants in assertions. If the constant's value ever +changes, tests using magic numbers silently pass with wrong expectations. + +### Naming + +Names should be concise, descriptive, and read as correct English. Lead with the verb. + +| Quality | Example | +| -------- | ----------------------------------------------------------------------------------------- | +| **Good** | `'captures error when context.error is set'` | +| **Good** | `'does not re-capture errors already captured by wrapMiddlewareWithSpan'` | +| **Good** | `'returns empty array when no items match'` | +| **Bad** | `'should correctly return the formatted price string when given a valid positive number'` | +| **Bad** | `'test error handling'` / `'works correctly'` | + +Drop "should" — it adds words without adding meaning. + +--- + +## Input quality + +### Use realistic data + +```typescript +// Weak +const url = 'http://test'; + +// Strong — exercises URL parsing, path handling, query strings +const url = 'https://api.example.com/users/42?include=profile&format=json'; +``` + +### Boundary Value Analysis + +If the valid range is 1–100, test -2, -1, 0, 1, 2, 99, 100, 101, Number.POSITIVE_INFINITY. Bugs cluster at boundaries — off-by-one +errors, inclusive/exclusive confusion, type coercion. + +### Test the unhappy path as hard as the happy path + +- **Empty inputs:** `''`, `[]`, `{}`, `undefined`, `null` +- **Falsy-but-valid:** `0`, `false`, `''`, `NaN` — these trip up loose truthiness checks +- **Error conditions:** network failure, malformed input, missing required fields, timeout +- **Concurrency:** what if called twice simultaneously? What if called after cleanup? + +Each edge case gets its own test with a descriptive name. + +--- + +## Writing Vitest tests + +### File structure + +- Name test files `*.test.ts`, mirroring the source path: `src/shared/patchRoute.ts` → + `test/shared/patchRoute.test.ts`. +- Import the module under test from its source path (`../../src/...`). But when importing from a _different_ package + (e.g., `@sentry/core` in a `@sentry/node` test), use the package name — that's a real dependency, not the code under test. +- For browser-environment tests: `/** @vitest-environment jsdom */` at top of file. + +### Mocking + +**Prefer spies and stubs over full module mocks.** A spy observes behavior without replacing the +system under test. A full mock replaces it — and now you're testing your mock, not your code. + +```typescript +const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); +sentry(app); +expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not initialized')); +``` + +**When you need `vi.mock`:** If the package's existing tests use string paths +(`vi.mock('../../src/utils')`), match that style. If you're creating the first test file for a +package, prefer the import form for type safety: + +```typescript +vi.mock(import('../../src/utils'), async importOriginal => { + const actual = await importOriginal(); + return { ...actual, helperFn: vi.fn() }; +}); +``` + +**Always restore mocks.** This repo does _not_ set `restoreMocks: true` globally — you are +responsible for cleanup. Leaked mocks cause mysterious failures in unrelated tests. Use whatever +cleanup the existing tests in your package use. If creating the first test file, use: + +```typescript +beforeEach(() => { + vi.restoreAllMocks(); +}); +``` + +### Error testing + +Use the library's built-in matchers. Never use try/catch in tests. + +```typescript +expect(() => parseConfig(null)).toThrow('config is required'); +await expect(asyncOp()).rejects.toThrow(TimeoutError); +``` + +For async callbacks where you need to verify an assertion actually ran, use `expect.assertions(n)`. + +### Parameterized tests (Vitest) + +Use `it.each` or `it.for` for data-driven cases — not raw `for` loops. `it.each` integrates with +the test runner, gives better output, and each case runs independently. + +```typescript +it.each([ + { input: 0, expected: 'zero' }, + { input: 1, expected: 'one' }, + { input: -1, expected: 'negative' }, +])('classifies $input as $expected', ({ input, expected }) => { + expect(classify(input)).toBe(expected); +}); +``` + +### Test isolation + +Tests must never depend on execution order or share mutable state. For this codebase, many tests +need to reset global Sentry state: + +```typescript +beforeEach(() => { + clearGlobalScope(); + getCurrentScope().clear(); + getIsolationScope().clear(); +}); +``` + +### Grouping + +1-2 levels of `describe` is usually enough. Deeper nesting makes tests harder to find and read. + +```typescript +describe('patchRoute', () => { + describe('sub-app middleware wrapping', () => { + it('wraps .use() middleware handlers', async () => { ... }); + it('does not wrap sole route handlers', async () => { ... }); + }); +}); +``` + +--- + +## Writing Playwright E2E tests + +### When to write E2E tests + +Write E2E tests when you need to verify that the SDK correctly instruments a real application. +Unit tests can't catch integration bugs between the SDK and a framework's request lifecycle. +Also use the `/e2e` skill for running E2E tests. + +### File structure + +- Tests live in `dev-packages/e2e-tests/test-applications//tests/*.test.ts`. +- Shared constants (like `APP_NAME`) go in `tests/constants.ts`. +- Each test app has a `playwright.config.ts` using `getPlaywrightConfig` from + `@sentry-internal/test-utils`. + +### The waitFor pattern + +Set up a promise for the expected Sentry event, trigger the action, then await and assert. + +```typescript +test('captures transaction for GET /users/:id', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /users/:id'; + }); + + const response = await fetch(`${baseURL}/users/42`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe('GET /users/:id'); +}); +``` + +**The predicate must be specific enough to match only your event.** A vague predicate can match an +unrelated event from a parallel test, causing flaky passes or hangs. + +### Asserting on spans + +Prefer asserting on the exact span count alongside individual field checks: + +```typescript +const spans = transaction.spans || []; +expect(spans).toHaveLength(2); + +const middlewareSpan = spans.find(s => s.description === 'middlewareA'); +expect(middlewareSpan?.op).toBe('middleware.hono'); +expect(middlewareSpan?.origin).toBe('auto.middleware.hono'); +expect(middlewareSpan?.status).toBe('ok'); +``` + +### Error event assertions + +Check both the exception value and the mechanism. The mechanism tells you _how_ the error was +captured — that's the SDK's actual responsibility: + +```typescript +const errorEvent = await errorPromise; +expect(errorEvent.exception?.values?.[0]?.value).toBe('connection refused'); + +const mechanism = errorEvent.exception?.values?.[0]?.mechanism; +expect(mechanism?.handled).toBe(false); +expect(mechanism?.type).toBe('auto.http.hono.context_error'); +``` + +### Parameterized E2E tests + +For Playwright tests (unlike Vitest), `for...of` loops are the established codebase convention. +Use `for...of` (not `.forEach()`) so Playwright's test registration works correctly: + +```typescript +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('captures named middleware span', async ({ baseURL }) => { + // ... + }); + }); +} +``` + +### Common pitfalls + +- **Proxy name mismatch:** `APP_NAME` must match `proxyServerName` in `start-event-proxy.mjs`. +- **Flaky predicates:** Add enough specificity (path, method, unique marker) to disambiguate. +- **Forgetting `await`:** The `waitFor*` helpers return a promise. Without `await`, the test passes + vacuously and the assertion never runs. + +--- + +## Checklist + +Before you're done, verify each test against these criteria: + +- [ ] Catches a real potential bug — not just confirming the happy path works +- [ ] Single, clear reason it could fail +- [ ] Description reads as a behavior specification (no "should", no "works correctly") +- [ ] No dependency on other tests' execution or state +- [ ] Mocks and spies are restored (via `beforeEach`) +- [ ] Edge cases covered: empty inputs, boundaries, error paths, null/undefined +- [ ] Realistic test data (not `"foo"`, `"test"`, `123`) +- [ ] No try/catch for error testing — `toThrow` / `rejects.toThrow` only +- [ ] Assertions use `toEqual` by default; `toHaveBeenCalledWith` spells out full arguments +- [ ] Array lookups (`toContain`, `toContainEqual`) paired with `toHaveLength` +- [ ] Uses exported constants (e.g., `SPAN_STATUS_OK`) instead of magic numbers +- [ ] Passes in isolation (`vitest run ` or single Playwright test) +- [ ] Matches the existing conventions of the package's test directory From d9a94ba40f3fb47807cbb4435ae9ba3fe4edd381 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 29 Apr 2026 09:59:40 +0200 Subject: [PATCH 65/77] feat(nextjs): Migrate edge event processors to span-first APIs (#20551) Basically https://github.com/getsentry/sentry-javascript/pull/20527 but for edge closes https://github.com/getsentry/sentry-javascript/issues/20368 --- .../src/edge/enhanceMiddlewareRootSpan.ts | 41 +++++++ packages/nextjs/src/edge/index.ts | 73 +++++------- .../edge/enhanceMiddlewareRootSpan.test.ts | 110 ++++++++++++++++++ packages/nextjs/test/edgeSdk.test.ts | 25 ++++ 4 files changed, 208 insertions(+), 41 deletions(-) create mode 100644 packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts create mode 100644 packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts diff --git a/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts new file mode 100644 index 000000000000..cd71928e9109 --- /dev/null +++ b/packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts @@ -0,0 +1,41 @@ +import { stripUrlQueryAndFragment } from '@sentry/core'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; + +export interface MutableMiddlewareRootSpan { + attributes: Record; + getName(): string | undefined; + setName(name: string): void; +} + +/** + * Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime. + * + * Older Next.js versions append the full URL to the middleware span name (e.g. `middleware GET /foo?bar=1`), + * producing high-cardinality transaction names. We collapse the name to `middleware {METHOD}` when possible, + * and strip query/fragment otherwise. + * + * Called from two places that operate on different shapes of the same underlying root span: + * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` + * holds the root span's attributes and whose `event.transaction` is the root span's name. + * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed + * counterpart of the legacy transaction root) directly. + */ +export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void { + const { attributes } = span; + + if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Middleware.execute') { + return; + } + + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; + if (typeof spanName !== 'string' || !spanName || !span.getName()) { + return; + } + + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + span.setName(`middleware ${match[1]}`); + } else { + span.setName(stripUrlQueryAndFragment(spanName)); + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 96a03541ab22..e92f919a8b57 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -4,7 +4,6 @@ import { context } from '@opentelemetry/api'; import { applySdkMetadata, - type EventProcessor, getCapturedScopesOnSpan, getCurrentScope, getGlobalScope, @@ -17,7 +16,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, spanToJSON, - stripUrlQueryAndFragment, } from '@sentry/core'; import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; @@ -31,6 +29,7 @@ import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceMiddlewareRootSpan } from './enhanceMiddlewareRootSpan'; export * from '@sentry/vercel-edge'; export * from '../common'; @@ -85,6 +84,12 @@ export function init(options: VercelEdgeOptions = {}): void { ...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }), }; + const nextjsIgnoreSpans: NonNullable = [ + // (set in `dropMiddlewareTunnelRequests` during `spanStart`) + { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + // Use appropriate SDK metadata based on the runtime environment if (isRunningOnCloudflare) { applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']); @@ -137,61 +142,47 @@ export function init(options: VercelEdgeOptions = {}): void { // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to // "custom", doesn't trigger. + // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; + // `enhanceMiddlewareRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. + // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { - // The otel auto inference will clobber the transaction name because the span has an http.target - if ( - event.type === 'transaction' && - event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' && - event.contexts?.trace?.data?.['next.span_name'] - ) { - if (event.transaction) { - // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. - // We want to remove the url from the name here. - const spanName = event.contexts.trace.data['next.span_name']; - - if (typeof spanName === 'string') { - const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); - if (match) { - const normalizedName = `middleware ${match[1]}`; - event.transaction = normalizedName; - } else { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); - } - } - } + if (event.type === 'transaction' && event.contexts?.trace?.data) { + enhanceMiddlewareRootSpan({ + attributes: event.contexts.trace.data, + getName: () => event.transaction, + setName: name => { + event.transaction = name; + }, + }); } setUrlProcessingMetadata(event); }); + // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become + // transaction events, so the same enhancement has to be applied here directly on the span JSON. + client?.on('processSegmentSpan', span => { + const attributes = (span.attributes ??= {}); + enhanceMiddlewareRootSpan({ + attributes, + getName: () => span.name, + setName: name => { + span.name = name; + }, + }); + }); + client?.on('spanEnd', span => { if (span === getRootSpan(span)) { waitUntil(flushSafelyWithTimeout()); } }); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - // Filter transactions that we explicitly want to drop. - if (event.type === 'transaction') { - if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { - return null; - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { getGlobalScope().setTag('turbopack', true); + getGlobalScope().setAttribute('turbopack', true); } } catch { // Noop diff --git a/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts new file mode 100644 index 000000000000..6308c6a75bab --- /dev/null +++ b/packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { enhanceMiddlewareRootSpan } from '../../src/edge/enhanceMiddlewareRootSpan'; + +function makeSpan(attributes: Record, name?: string) { + let currentName = name; + return { + span: { + attributes, + getName: () => currentName, + setName: (n: string) => { + currentName = n; + }, + }, + getName: () => currentName, + }; +} + +describe('enhanceMiddlewareRootSpan', () => { + it('does nothing for spans that are not Middleware.execute', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, + 'GET /foo', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('GET /foo'); + }); + + it('does nothing when next.span_name is missing', () => { + const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware'); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when next.span_name is an empty string', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' }, + 'middleware', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when next.span_name is not a string', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 }, + 'middleware', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware'); + }); + + it('does nothing when the current name is empty', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' }, + undefined, + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBeUndefined(); + }); + + it.each([ + ['middleware GET /foo', 'middleware GET'], + ['middleware POST /api/protected?token=abc', 'middleware POST'], + ['middleware DELETE /resources/[id]', 'middleware DELETE'], + ['middleware HEAD /', 'middleware HEAD'], + ])('collapses "%s" to "%s"', (spanName, expected) => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName }, + spanName, + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe(expected); + }); + + it('strips query and fragment from non-method-prefixed middleware names', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '/api/foo?token=abc#section' }, + '/api/foo?token=abc#section', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('/api/foo'); + }); + + it('does not collapse names that do not match the middleware-method prefix', () => { + // CONNECT and TRACE are not in the regex - they fall through to query/fragment stripping + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware CONNECT /foo?bar=1' }, + 'middleware CONNECT /foo?bar=1', + ); + + enhanceMiddlewareRootSpan(span); + + expect(getName()).toBe('middleware CONNECT /foo'); + }); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index de0dd041e972..8d4fee1f926e 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; // normally this is set as part of the build process, so mock it here @@ -74,6 +75,30 @@ describe('Edge init()', () => { }); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = vercelEdgeInit.mock.calls[0]?.[0] as SentryVercelEdge.VercelEdgeOptions; + return callArgs.ignoreSpans ?? []; + } + + it('appends the TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(patterns.some(p => p instanceof RegExp && p.source === 'custom-regex')).toBe(true); + }); + }); + describe('environment option', () => { const originalEnv = process.env.SENTRY_ENVIRONMENT; From a4e888fa9c70e20c961c3e6d44613b3ec7c662c7 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 10:21:35 +0200 Subject: [PATCH 66/77] fix(opentelemetry): Add conditional browser export to avoid node deps (#20556) We accidentally added a `node` dependency to an export from `opentelemetry` package, leading to problems for users using this in a browser environment. This PR adds conditional exports to the opentelemetry package, where for `browser` targets we have stubs for the node-only thing. This should generally work the same as before, but stop failing builds in browser envs. I had to adjust browser integration tests for this a bit, as they did some unnecessary aliasing which prevented webpack from using normal conditional exports. We are simply doing less now and doing regular dependency resolution which should work as expected (hopefully). --- .../browser-integration-tests/package.json | 2 + .../suites/opentelemetry/node-exports/init.js | 7 +++ .../opentelemetry/node-exports/subject.js | 13 +++++ .../suites/opentelemetry/node-exports/test.ts | 26 +++++++++ .../utils/generatePage.ts | 2 + .../utils/generatePlugin.ts | 9 ++- .../webpack.config.ts | 4 ++ packages/opentelemetry/package.json | 16 ++++++ packages/opentelemetry/rollup.npm.config.mjs | 2 +- packages/opentelemetry/src/exports.ts | 56 +++++++++++++++++++ packages/opentelemetry/src/index.browser.ts | 18 ++++++ packages/opentelemetry/src/index.ts | 56 +------------------ 12 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js create mode 100644 dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts create mode 100644 packages/opentelemetry/src/exports.ts create mode 100644 packages/opentelemetry/src/index.browser.ts diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 59e83fa7cca1..2eb1ae9ebfb5 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -61,6 +61,8 @@ "@playwright/test": "~1.56.0", "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.50.0", + "@sentry-internal/replay": "10.50.0", + "@sentry/opentelemetry": "10.50.0", "@supabase/supabase-js": "2.49.3", "axios": "1.15.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js new file mode 100644 index 000000000000..d8c94f36fdd0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/init.js @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js new file mode 100644 index 000000000000..8d51286de101 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/subject.js @@ -0,0 +1,13 @@ +import * as SentryOpenTelemetry from '@sentry/opentelemetry'; +import * as Sentry from '@sentry/browser'; + +// Verify that generally all imports can be resolved +// oxlint-disable-next-line no-console +for (const key in SentryOpenTelemetry) { + console.log(key, SentryOpenTelemetry[key]); +} + +// Verify that it console.errors if calling node-only thing +new SentryOpenTelemetry.SentryAsyncLocalStorageContextManager(); + +Sentry.captureException(new Error('test')); diff --git a/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts new file mode 100644 index 000000000000..79a45bd8a1ed --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/opentelemetry/node-exports/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../utils/helpers'; + +sentryTest('Should allow importing from @sentry/opentelemetry package', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE; + + if (bundle && bundle.includes('bundle')) { + sentryTest.skip(); + return; + } + + const consoleMessages: string[] = []; + page.on('console', msg => { + consoleMessages.push(msg.text()); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForErrorRequestOnUrl(page, url); + const eventData = envelopeRequestParser(req); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0].value).toBe('test'); + + expect(consoleMessages).toContainEqual('SentryAsyncLocalStorageContextManager is not supported in the browser'); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePage.ts b/dev-packages/browser-integration-tests/utils/generatePage.ts index 3bba565fd147..a74cd7dfe7bb 100644 --- a/dev-packages/browser-integration-tests/utils/generatePage.ts +++ b/dev-packages/browser-integration-tests/utils/generatePage.ts @@ -38,11 +38,13 @@ export async function generatePage( compiler.run(err => { if (err) { reject(err); + return; } compiler.close(err => { if (err) { reject(err); + return; } resolve(); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 72d93bdabf33..ff45418add73 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -147,6 +147,10 @@ export const LOADER_CONFIGS: Record; * so that the compiled versions aren't included */ function generateSentryAlias(): Record { + if (!useBundleOrLoader) { + return {}; + } + const rootPackageJson = JSON.parse(fs.readFileSync(ROOT_PACKAGE_JSON_PATH, 'utf8')) as { workspaces: string[] }; const packageNames = rootPackageJson.workspaces .filter(workspace => !workspace.startsWith('dev-packages/')) @@ -189,7 +193,10 @@ class SentryScenarioGenerationPlugin { } public apply(compiler: Compiler): void { - compiler.options.resolve.alias = generateSentryAlias(); + const sentryAlias = generateSentryAlias(); + if (Object.keys(sentryAlias).length > 0) { + compiler.options.resolve.alias = sentryAlias; + } compiler.options.externals = useBundleOrLoader ? { // To help Webpack resolve Sentry modules in `import` statements in cases where they're provided in bundles rather than in `node_modules` diff --git a/dev-packages/browser-integration-tests/webpack.config.ts b/dev-packages/browser-integration-tests/webpack.config.ts index ddf31bc897c4..6aa2f6cfb0cf 100644 --- a/dev-packages/browser-integration-tests/webpack.config.ts +++ b/dev-packages/browser-integration-tests/webpack.config.ts @@ -3,7 +3,11 @@ import type { Configuration } from 'webpack'; const config = function (userConfig: Record): Configuration { return { ...userConfig, + target: 'web', mode: 'none', + resolve: { + conditionNames: ['webpack', 'import', 'require', 'browser', 'default'], + }, module: { rules: [ { diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 5195c8b0638a..efa8a0cb6aff 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -19,10 +19,26 @@ "./package.json": "./package.json", ".": { "import": { + "node": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "browser": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.browser.js" + }, "types": "./build/types/index.d.ts", "default": "./build/esm/index.js" }, "require": { + "node": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + }, + "browser": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.browser.js" + }, "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } diff --git a/packages/opentelemetry/rollup.npm.config.mjs b/packages/opentelemetry/rollup.npm.config.mjs index e6f5ecdd4871..d0d33de1790f 100644 --- a/packages/opentelemetry/rollup.npm.config.mjs +++ b/packages/opentelemetry/rollup.npm.config.mjs @@ -4,7 +4,7 @@ export default makeNPMConfigVariants( makeBaseNPMConfig({ // `tracingChannel` is a Node.js-only subpath so `node:diagnostics_channel` // isn't pulled into the main bundle (breaks edge/browser builds). - entrypoints: ['src/index.ts', 'src/tracingChannel.ts'], + entrypoints: ['src/index.ts', 'src/tracingChannel.ts', 'src/index.browser.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/opentelemetry/src/exports.ts b/packages/opentelemetry/src/exports.ts new file mode 100644 index 000000000000..bdda20fd94ce --- /dev/null +++ b/packages/opentelemetry/src/exports.ts @@ -0,0 +1,56 @@ +export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes'; + +export { getRequestSpanData } from './utils/getRequestSpanData'; + +export type { OpenTelemetryClient } from './types'; +export { wrapClientClass } from './custom/client'; + +export { getSpanKind } from './utils/getSpanKind'; + +export { getScopesFromContext } from './utils/contextData'; + +export { + spanHasAttributes, + spanHasEvents, + spanHasKind, + spanHasName, + spanHasParentId, + spanHasStatus, +} from './utils/spanTypes'; + +// Re-export this for backwards compatibility (this used to be a different implementation) +export { getDynamicSamplingContextFromSpan } from '@sentry/core'; + +export { isSentryRequestSpan } from './utils/isSentryRequest'; + +export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; + +export { getActiveSpan } from './utils/getActiveSpan'; +export { + startSpan, + startSpanManual, + startInactiveSpan, + withActiveSpan, + continueTrace, + getTraceContextForScope, +} from './trace'; + +export { suppressTracing } from './utils/suppressTracing'; + +export { setupEventContextTrace } from './setupEventContextTrace'; + +export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; +export { wrapContextManagerClass } from './contextManager'; + +export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; +export { SentrySpanProcessor } from './spanProcessor'; +export { SentrySampler, wrapSamplingDecision } from './sampler'; + +export { openTelemetrySetupCheck } from './utils/setupCheck'; + +export { getSentryResource } from './resource'; + +export { withStreamedSpan } from '@sentry/core'; + +// Legacy +export { getClient } from '@sentry/core'; diff --git a/packages/opentelemetry/src/index.browser.ts b/packages/opentelemetry/src/index.browser.ts new file mode 100644 index 000000000000..4667379fc749 --- /dev/null +++ b/packages/opentelemetry/src/index.browser.ts @@ -0,0 +1,18 @@ +import { consoleSandbox } from '@sentry/core'; + +export * from './exports'; + +// Stubs for node-specific exports +export class SentryAsyncLocalStorageContextManager { + public constructor() { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.error('SentryAsyncLocalStorageContextManager is not supported in the browser'); + }); + } +} + +export type AsyncLocalStorageLookup = { + asyncLocalStorage: unknown; + contextSymbol: symbol; +}; diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index a49597f67fdf..66766f554327 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,57 +1,5 @@ -export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes'; +export * from './exports'; -export { getRequestSpanData } from './utils/getRequestSpanData'; - -export type { OpenTelemetryClient } from './types'; -export { wrapClientClass } from './custom/client'; - -export { getSpanKind } from './utils/getSpanKind'; - -export { getScopesFromContext } from './utils/contextData'; - -export { - spanHasAttributes, - spanHasEvents, - spanHasKind, - spanHasName, - spanHasParentId, - spanHasStatus, -} from './utils/spanTypes'; - -// Re-export this for backwards compatibility (this used to be a different implementation) -export { getDynamicSamplingContextFromSpan } from '@sentry/core'; - -export { isSentryRequestSpan } from './utils/isSentryRequest'; - -export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; - -export { getActiveSpan } from './utils/getActiveSpan'; -export { - startSpan, - startSpanManual, - startInactiveSpan, - withActiveSpan, - continueTrace, - getTraceContextForScope, -} from './trace'; - -export { suppressTracing } from './utils/suppressTracing'; - -export { setupEventContextTrace } from './setupEventContextTrace'; - -export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; -export { wrapContextManagerClass } from './contextManager'; +// Node-specific exports export { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; export type { AsyncLocalStorageLookup } from './contextManager'; -export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator'; -export { SentrySpanProcessor } from './spanProcessor'; -export { SentrySampler, wrapSamplingDecision } from './sampler'; - -export { openTelemetrySetupCheck } from './utils/setupCheck'; - -export { getSentryResource } from './resource'; - -export { withStreamedSpan } from '@sentry/core'; - -// Legacy -export { getClient } from '@sentry/core'; From 225751ac52626de6c50b00fd3e7a0f10ed2f465d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 10:33:30 +0200 Subject: [PATCH 67/77] chore: Update CODEOWNERS (#20559) This updates the CODEOWNERS config to accomodate our new groups. --- .github/CODEOWNERS | 53 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3bb7aa3860ff..b754ae45db84 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,48 @@ -packages/replay-internal @getsentry/replay-sdk-web -packages/replay-worker @getsentry/replay-sdk-web -packages/replay-canvas @getsentry/replay-sdk-web -packages/feedback @getsentry/feedback-sdk -dev-packages/browser-integration-tests/suites/replay @getsentry/replay-sdk-web +# Browser, replay, feedback, and related packages +/packages/browser/ @getsentry/team-javascript-sdks-browser +/packages/browser-utils/ @getsentry/team-javascript-sdks-browser +/packages/replay-internal/ @getsentry/team-javascript-sdks-browser +/packages/replay-worker/ @getsentry/team-javascript-sdks-browser +/packages/replay-canvas/ @getsentry/team-javascript-sdks-browser +/packages/feedback/ @getsentry/team-javascript-sdks-browser +/dev-packages/browser-integration-tests/ @getsentry/team-javascript-sdks-browser + +# Node/server runtimes and related packages +/packages/node/ @getsentry/team-javascript-sdks-server +/packages/node-core/ @getsentry/team-javascript-sdks-server +/packages/node-native/ @getsentry/team-javascript-sdks-server +/packages/profiling-node/ @getsentry/team-javascript-sdks-server +/packages/opentelemetry/ @getsentry/team-javascript-sdks-server +/packages/deno/ @getsentry/team-javascript-sdks-server +/packages/bun/ @getsentry/team-javascript-sdks-server +/packages/cloudflare/ @getsentry/team-javascript-sdks-server +/packages/aws-serverless/ @getsentry/team-javascript-sdks-server +/packages/google-cloud-serverless/ @getsentry/team-javascript-sdks-server +/packages/vercel-edge/ @getsentry/team-javascript-sdks-server +/dev-packages/node-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/node-core-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/cloudflare-integration-tests/ @getsentry/team-javascript-sdks-server +/dev-packages/bun-integration-tests/ @getsentry/team-javascript-sdks-server + +# Framework integration packages +/packages/angular/ @getsentry/team-javascript-sdks-framework +/packages/astro/ @getsentry/team-javascript-sdks-framework +/packages/effect/ @getsentry/team-javascript-sdks-framework +/packages/elysia/ @getsentry/team-javascript-sdks-framework +/packages/ember/ @getsentry/team-javascript-sdks-framework +/packages/gatsby/ @getsentry/team-javascript-sdks-framework +/packages/hono/ @getsentry/team-javascript-sdks-framework +/packages/nestjs/ @getsentry/team-javascript-sdks-framework +/packages/nextjs/ @getsentry/team-javascript-sdks-framework +/packages/nitro/ @getsentry/team-javascript-sdks-framework +/packages/nuxt/ @getsentry/team-javascript-sdks-framework +/packages/react/ @getsentry/team-javascript-sdks-framework +/packages/react-router/ @getsentry/team-javascript-sdks-framework +/packages/remix/ @getsentry/team-javascript-sdks-framework +/packages/solid/ @getsentry/team-javascript-sdks-framework +/packages/solidstart/ @getsentry/team-javascript-sdks-framework +/packages/svelte/ @getsentry/team-javascript-sdks-framework +/packages/sveltekit/ @getsentry/team-javascript-sdks-framework +/packages/tanstackstart/ @getsentry/team-javascript-sdks-framework +/packages/tanstackstart-react/ @getsentry/team-javascript-sdks-framework +/packages/vue/ @getsentry/team-javascript-sdks-framework From a84b2f10eba1b4b015e545518dac5ed62783e66b Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:40:49 +0900 Subject: [PATCH 68/77] feat(core): Instrument langgraph createReactAgent (#20344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds instrumentation for LangGraph's `createReactAgent` API. ## createReactAgent wrapping - Extracts agent name, LLM model, and tools from params - Wraps compiled graph's `invoke()` with `invoke_agent` span - Wraps tool `invoke()` with `execute_tool` spans (name, type, description, arguments, result) - Injects LangChain callback handler + `lc_agent_name` + `__sentry_langgraph__` metadata at invoke level for chat span creation and agent name propagation to all child spans - Suppresses `StateGraph.compile` instrumentation inside `createReactAgent` to avoid duplicate spans ## LangChain callback handler improvements - Reads `gen_ai.agent.name` from `metadata.lc_agent_name` (convention from newer LangGraph `createAgent`, adopted for our supported versions) - Suppresses chain and tool callback spans inside agent context (based on `metadata.__sentry_langgraph__` presence) to avoid duplicates with our direct instrumentation - Extracts tool definitions from `extraParams` in `handleChatModelStart` and sets `gen_ai.request.available_tools` on chat spans - Uses `runName` for tool name in `handleToolStart` (set by LangChain's `StructuredTool.call()`) — fixes `unknown_tool` issue - Adds `gen_ai.operation.name` to tool spans - Extracts `.content` from ToolMessage objects in `handleToolEnd` instead of serializing the full wrapper - `addToolCallsAttributes` now prefers `message.tool_calls` (LangChain's normalized format) over scanning `message.content` for Anthropic-style `tool_use` items, fixing duplicate tool calls on Anthropic chat spans. Falls back to `message.content` scanning for older LangChain versions. ## OTel module patching - Patches `@langchain/langgraph/prebuilt` for `createReactAgent` (ESM + CJS file patches for `dist/prebuilt/index.cjs`) ## Exports - `instrumentCreateReactAgent` from core, browser, cloudflare Closes: #19372 --- .../tracing/langgraph/agent-scenario.mjs | 65 +++++++ .../langgraph/agent-tools-scenario.mjs | 122 ++++++++++++ .../tracing/langgraph/instrument-agent.mjs | 17 ++ .../langgraph/scenario-stategraph-chat.mjs | 56 ++++++ .../suites/tracing/langgraph/test.ts | 108 +++++++++++ packages/browser/src/index.ts | 1 + packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 2 +- packages/core/src/tracing/langchain/index.ts | 53 +++++- packages/core/src/tracing/langchain/types.ts | 9 + packages/core/src/tracing/langchain/utils.ts | 53 +++++- packages/core/src/tracing/langgraph/index.ts | 118 +++++++++++- packages/core/src/tracing/langgraph/utils.ts | 179 +++++++++++++++++- .../core/test/lib/tracing/langgraph.test.ts | 18 ++ .../test/lib/utils/langgraph-utils.test.ts | 83 ++++++++ .../tracing/langgraph/instrumentation.ts | 102 +++++++--- 16 files changed, 933 insertions(+), 54 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs create mode 100644 packages/core/test/lib/tracing/langgraph.test.ts create mode 100644 packages/core/test/lib/utils/langgraph-utils.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs new file mode 100644 index 000000000000..78b0a0cba38c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-scenario.mjs @@ -0,0 +1,65 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + res.json({ + id: 'msg_react_agent_123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Paris is the capital of France.', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 10, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + const agent = createReactAgent({ llm, tools: [], name: 'helpful_assistant' }); + + await agent.invoke({ + messages: [new SystemMessage('You are a helpful assistant.'), new HumanMessage('What is the capital of France?')], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs new file mode 100644 index 000000000000..f499d9eff5f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/agent-tools-scenario.mjs @@ -0,0 +1,122 @@ +import { tool } from '@langchain/core/tools'; +import { ChatAnthropic } from '@langchain/anthropic'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { HumanMessage } from '@langchain/core/messages'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { z } from 'zod'; + +let callCount = 0; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + callCount++; + const model = req.body.model; + + if (callCount === 1) { + // First call: model decides to call the "add" tool + res.json({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_add_1', + name: 'add', + input: { a: 3, b: 5 }, + }, + ], + model: model, + stop_reason: 'tool_use', + usage: { input_tokens: 20, output_tokens: 10 }, + }); + } else if (callCount === 2) { + // Second call: model sees add result=8, calls "multiply" + res.json({ + id: 'msg_2', + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_mul_1', + name: 'multiply', + input: { a: 8, b: 4 }, + }, + ], + model: model, + stop_reason: 'tool_use', + usage: { input_tokens: 30, output_tokens: 10 }, + }); + } else { + // Third call: model returns final answer + res.json({ + id: 'msg_3', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'The result is 32.' }], + model: model, + stop_reason: 'end_turn', + usage: { input_tokens: 40, output_tokens: 10 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + const addTool = tool( + async ({ a, b }) => { + return String(a + b); + }, + { + name: 'add', + description: 'Add two numbers', + schema: z.object({ a: z.number(), b: z.number() }), + }, + ); + + const multiplyTool = tool( + async ({ a, b }) => { + return String(a * b); + }, + { + name: 'multiply', + description: 'Multiply two numbers', + schema: z.object({ a: z.number(), b: z.number() }), + }, + ); + + const agent = createReactAgent({ + llm, + tools: [addTool, multiplyTool], + name: 'math_assistant', + }); + + await agent.invoke({ + messages: [new HumanMessage('Calculate (3 + 5) * 4')], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs new file mode 100644 index 000000000000..dbd4e959020a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-agent.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction && event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs new file mode 100644 index 000000000000..d06c1fdd7d4a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-stategraph-chat.mjs @@ -0,0 +1,56 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_stategraph_chat_1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hello from mock.' }], + model: req.body.model, + stop_reason: 'end_turn', + usage: { input_tokens: 5, output_tokens: 3 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const llm = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + const callLlm = async state => { + const response = await llm.invoke(state.messages); + return { messages: [response] }; + }; + + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', callLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'plain_assistant' }); + + await graph.invoke({ messages: [{ role: 'user', content: 'Hi.' }] }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 0837efb63c2f..d17e789d73f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -13,6 +13,7 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, @@ -445,4 +446,111 @@ describe('LangGraph integration', () => { }); }, ); + + // createReactAgent tests + const EXPECTED_TRANSACTION_REACT_AGENT = { + transaction: 'main', + spans: [ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langgraph', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'helpful_assistant', + [GEN_AI_PIPELINE_NAME_ATTRIBUTE]: 'helpful_assistant', + }), + description: 'invoke_agent helpful_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'helpful_assistant', + }), + op: 'gen_ai.chat', + }), + ], + }; + + createEsmAndCjsTests(__dirname, 'agent-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('should instrument createReactAgent with agent and chat spans', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_REACT_AGENT }) + .start() + .completed(); + }); + }); + + // createReactAgent with tools - verifies tool execution spans + const EXPECTED_TRANSACTION_REACT_AGENT_TOOLS = { + transaction: 'main', + spans: [ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'math_assistant', + }), + op: 'gen_ai.invoke_agent', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: 'add', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool add', + op: 'gen_ai.execute_tool', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: 'multiply', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool multiply', + op: 'gen_ai.execute_tool', + status: 'ok', + }), + expect.objectContaining({ op: 'http.client' }), + expect.objectContaining({ op: 'gen_ai.chat' }), + ], + }; + + createEsmAndCjsTests(__dirname, 'agent-tools-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('should create tool execution spans for createReactAgent with tools', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_REACT_AGENT_TOOLS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-stategraph-chat.mjs', 'instrument-agent.mjs', (createRunner, test) => { + test('auto-injects langchain handler for plain StateGraph and emits chat spans', { timeout: 30000 }, async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: event => { + const spans = event.spans ?? []; + const chatSpans = spans.filter(s => s.op === 'gen_ai.chat'); + expect(chatSpans).toHaveLength(1); + expect(chatSpans[0]?.data).toMatchObject({ + [GEN_AI_AGENT_NAME_ATTRIBUTE]: 'plain_assistant', + }); + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 844f6a170090..4709e6167b3c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -72,6 +72,7 @@ export { instrumentOpenAiClient, instrumentGoogleGenAIClient, instrumentLangGraph, + instrumentCreateReactAgent, createLangChainCallbackHandler, instrumentLangChainEmbeddings, logger, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 961542e01446..eaa9b3ddb032 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,6 +110,7 @@ export { withStreamedSpan, spanStreamingIntegration, instrumentLangGraph, + instrumentCreateReactAgent, } from '@sentry/core'; export { withSentry } from './withSentry'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99aed84e4896..4d80ea02ed33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -179,7 +179,7 @@ export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/langgraph'; +export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 64e9058d8ce2..8ccce0f0a183 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -5,6 +6,7 @@ import { startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_TOOL_INPUT_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE, @@ -23,6 +25,8 @@ import { extractChatModelRequestAttributes, extractLLMRequestAttributes, extractLlmResponseAttributes, + extractToolDefinitions, + getAgentNameFromMetadata, getInvocationParams, } from './utils'; @@ -102,6 +106,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { + ...getAgentNameFromMetadata(metadata), ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, @@ -119,7 +124,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): messages: unknown, runId: string, _parentRunId?: string, - _extraParams?: Record, + extraParams?: Record, tags?: string[], metadata?: Record, _runName?: string, @@ -133,6 +138,12 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): invocationParams, metadata, ); + + const toolDefsJson = extractToolDefinitions(extraParams); + if (toolDefsJson) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = toolDefsJson; + } + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; @@ -141,6 +152,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): name: `${operationName} ${modelName}`, op: 'gen_ai.chat', attributes: { + ...getAgentNameFromMetadata(metadata), ...attributes, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, @@ -193,17 +205,23 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): runId: string, _parentRunId?: string, _tags?: string[], - _metadata?: Record, + metadata?: Record, _runType?: string, runName?: string, ) { + // Skip chain spans when inside an agent context (createReactAgent). + // The agent already creates an invoke_agent span; internal chain steps + // (ChannelWrite, Branch, prompt, etc.) are noise. + if (metadata?.__sentry_langgraph__) { + return; + } + const chainName = runName || chain.name || 'unknown_chain'; const attributes: Record = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', 'langchain.chain.name': chainName, }; - // Add inputs if recordInputs is enabled if (recordInputs) { attributes['langchain.chain.inputs'] = JSON.stringify(inputs); } @@ -255,14 +273,30 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Tool Start Handler - handleToolStart(tool: { name?: string }, input: string, runId: string, _parentRunId?: string) { - const toolName = tool.name || 'unknown_tool'; + handleToolStart( + tool: { name?: string }, + input: string, + runId: string, + _parentRunId?: string, + _tags?: string[], + metadata?: Record, + runName?: string, + ) { + // Skip tool spans when inside an agent context (createReactAgent). + // Tool spans are created by wrapToolsWithSpans with richer attributes. + if (metadata?.__sentry_langgraph__) { + return; + } + + // runName is set to tool.name by LangChain's StructuredTool.call() + const toolName = runName || tool.name || 'unknown_tool'; const attributes: Record = { + ...getAgentNameFromMetadata(metadata), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, }; - // Add input if recordInputs is enabled if (recordInputs) { attributes[GEN_AI_TOOL_INPUT_ATTRIBUTE] = input; } @@ -287,10 +321,13 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): handleToolEnd(output: unknown, runId: string) { const span = spanMap.get(runId); if (span?.isRecording()) { - // Add output if recordOutputs is enabled if (recordOutputs) { + // LangChain tools may return ToolMessage objects — extract the content + const outputObj = output as Record | undefined; + const content = + outputObj && typeof outputObj === 'object' && 'content' in outputObj ? outputObj.content : output; span.setAttributes({ - [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: JSON.stringify(output), + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: typeof content === 'string' ? content : JSON.stringify(content), }); } exitSpan(runId); diff --git a/packages/core/src/tracing/langchain/types.ts b/packages/core/src/tracing/langchain/types.ts index 1c066269aba5..542b80b7df74 100644 --- a/packages/core/src/tracing/langchain/types.ts +++ b/packages/core/src/tracing/langchain/types.ts @@ -36,6 +36,15 @@ export interface LangChainSerialized { kwargs?: Record; } +/** + * Subset of the 'llm' param passed to createReactAgent + */ +export interface BaseChatModel { + lc_namespace: string[]; + modelName?: string; + model?: string; +} + /** * LangChain message structure * Supports both regular messages and LangChain serialized format diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 1227889f210d..34be8ba753bb 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -1,6 +1,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { SpanAttributeValue } from '../../types-hoist/span'; import { + GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -350,22 +351,28 @@ export function extractChatModelRequestAttributes( } /** - * Scans generations for Anthropic-style `tool_use` items and records them. - * - * LangChain represents some provider messages (e.g., Anthropic) with a `message.content` - * array that may include objects `{ type: 'tool_use', ... }`. We collect and attach - * them as a JSON array on `gen_ai.response.tool_calls` for downstream consumers. + * Extracts tool calls from generations and records them on the span attributes. + * Prefers message.tool_calls (LangChain's normalized format). Falls back to + * scanning message.content for Anthropic-style tool_use items in older versions + * where tool_calls may not be populated. */ function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record): void { const toolCalls: unknown[] = []; const flatGenerations = generations.flat(); for (const gen of flatGenerations) { - const content = gen.message?.content; - if (Array.isArray(content)) { - for (const item of content) { - const t = item as { type: string }; - if (t.type === 'tool_use') toolCalls.push(t); + const msg = gen.message as Record | undefined; + const msgToolCalls = msg?.tool_calls as unknown[] | undefined; + if (Array.isArray(msgToolCalls) && msgToolCalls.length > 0) { + toolCalls.push(...msgToolCalls); + } else { + // Fallback for older LangChain versions: scan message.content for Anthropic-style tool_use + const content = gen.message?.content; + if (Array.isArray(content)) { + for (const item of content) { + const t = item as Record; + if (t.type === 'tool_use') toolCalls.push(t); + } } } } @@ -504,3 +511,29 @@ export function extractLlmResponseAttributes( return attrs; } + +export function getAgentNameFromMetadata(metadata?: Record): Record { + const attrs: Record = {}; + // lc_agent_name is injected by instrumentCompiledGraphInvoke (langgraph integration) + const agentName = metadata?.lc_agent_name; + if (typeof agentName === 'string') { + attrs[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName; + } + return attrs; +} + +export function extractToolDefinitions(extraParams?: Record): string | undefined { + const tools = + (extraParams?.invocation_params as Record)?.tools ?? + (extraParams?.options as Record)?.tools; + if (!Array.isArray(tools) || tools.length === 0) return undefined; + const toolDefs = tools.map((tool: Record) => { + const fn = tool.function as Record | undefined; + return { + type: 'function', + name: tool.name ?? fn?.name ?? '', + description: tool.description ?? fn?.description, + }; + }); + return JSON.stringify(toolDefs); +} diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d188fe90d97f..d43159a62ee1 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -10,6 +10,7 @@ import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { @@ -19,12 +20,24 @@ import { resolveAIRecordingOptions, shouldEnableTruncation, } from '../ai/utils'; -import type { LangChainMessage } from '../langchain/types'; +import { createLangChainCallbackHandler } from '../langchain'; +import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; import { startSpan } from '../trace'; import { LANGGRAPH_ORIGIN } from './constants'; import type { CompiledGraph, LangGraphOptions } from './types'; -import { extractToolsFromCompiledGraph, setResponseAttributes } from './utils'; +import { + extractAgentNameFromParams, + extractLLMFromParams, + extractToolsFromCompiledGraph, + mergeSentryCallback, + setResponseAttributes, + wrapToolsWithSpans, +} from './utils'; + +let _insideCreateReactAgent = false; + +const SENTRY_PATCHED = '__sentry_patched__'; /** * Instruments StateGraph's compile method to create spans for agent creation and invocation @@ -38,8 +51,19 @@ export function instrumentStateGraphCompile( originalCompile: (...args: unknown[]) => CompiledGraph, options: LangGraphOptions, ): (...args: unknown[]) => CompiledGraph { - return new Proxy(originalCompile, { + if (Object.prototype.hasOwnProperty.call(originalCompile, SENTRY_PATCHED)) { + return originalCompile; + } + + const sentryHandler = createLangChainCallbackHandler(options); + + const wrapped = new Proxy(originalCompile, { apply(target, thisArg, args: unknown[]): CompiledGraph { + // Skip when called from within createReactAgent to avoid duplicate instrumentation + if (_insideCreateReactAgent) { + return Reflect.apply(target, thisArg, args); + } + return startSpan( { op: 'gen_ai.create_agent', @@ -69,6 +93,8 @@ export function instrumentStateGraphCompile( compiledGraph, compileOptions, options, + undefined, + sentryHandler, ) as typeof originalInvoke; } @@ -87,6 +113,9 @@ export function instrumentStateGraphCompile( ); }, }) as (...args: unknown[]) => CompiledGraph; + + Object.defineProperty(wrapped, SENTRY_PATCHED, { value: true, enumerable: false }); + return wrapped; } /** @@ -99,9 +128,12 @@ function instrumentCompiledGraphInvoke( graphInstance: CompiledGraph, compileOptions: Record, options: LangGraphOptions, + llm?: BaseChatModel | null, + sentryCallbackHandler?: unknown, ): (...args: unknown[]) => Promise { return new Proxy(originalInvoke, { apply(target, thisArg, args: unknown[]): Promise { + const modelName = llm?.modelName ?? llm?.model; return startSpan( { op: 'gen_ai.invoke_agent', @@ -122,6 +154,10 @@ function instrumentCompiledGraphInvoke( span.updateName(`invoke_agent ${graphName}`); } + if (modelName) { + span.setAttribute(GEN_AI_REQUEST_MODEL_ATTRIBUTE, modelName); + } + // Extract thread_id from the config (second argument) // LangGraph uses config.configurable.thread_id for conversation/session linking const config = args.length > 1 ? (args[1] as Record | undefined) : undefined; @@ -131,6 +167,21 @@ function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, threadId); } + // Inject callback handler and agent name into invoke config + if (sentryCallbackHandler) { + const invokeConfig = (args[1] ?? {}) as Record; + args[1] = invokeConfig; + + const existingMetadata = (invokeConfig.metadata ?? {}) as Record; + invokeConfig.metadata = { + ...existingMetadata, + __sentry_langgraph__: true, + ...(typeof graphName === 'string' ? { lc_agent_name: graphName } : {}), + }; + + invokeConfig.callbacks = mergeSentryCallback(invokeConfig.callbacks, sentryCallbackHandler); + } + // Extract available tools from the graph instance const tools = extractToolsFromCompiledGraph(graphInstance); if (tools) { @@ -164,7 +215,6 @@ function instrumentCompiledGraphInvoke( // Call original invoke const result = await Reflect.apply(target, thisArg, args); - // Set response attributes if (recordOutputs) { setResponseAttributes(span, inputMessages ?? null, result); } @@ -186,6 +236,66 @@ function instrumentCompiledGraphInvoke( }) as (...args: unknown[]) => Promise; } +/** + * Instruments createReactAgent to create invoke_agent and execute_tool spans. + */ +export function instrumentCreateReactAgent( + originalCreateReactAgent: (...args: unknown[]) => CompiledGraph, + options?: LangGraphOptions, +): (...args: unknown[]) => CompiledGraph { + if (Object.prototype.hasOwnProperty.call(originalCreateReactAgent, SENTRY_PATCHED)) { + return originalCreateReactAgent; + } + + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); + + const wrapped = new Proxy(originalCreateReactAgent, { + apply(target, thisArg, args: unknown[]): CompiledGraph { + const llm = extractLLMFromParams(args); + const agentName = extractAgentNameFromParams(args); + + // Wrap tools with execute_tool spans (direct access gives us name, type, description) + const params = args[0] as Record | undefined; + if (params && Array.isArray(params.tools) && params.tools.length > 0) { + wrapToolsWithSpans(params.tools, resolvedOptions, agentName ?? undefined); + } + + // Suppress StateGraph.compile instrumentation inside createReactAgent + _insideCreateReactAgent = true; + let compiledGraph: CompiledGraph; + try { + compiledGraph = Reflect.apply(target, thisArg, args); + } finally { + _insideCreateReactAgent = false; + } + + // Wrap invoke() on the returned compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + const compileOptions: Record = {}; + if (agentName) { + compileOptions.name = agentName; + } + + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + resolvedOptions, + llm, + sentryHandler, + ) as typeof originalInvoke; + } + + return compiledGraph; + }, + }) as (...args: unknown[]) => CompiledGraph; + + Object.defineProperty(wrapped, SENTRY_PATCHED, { value: true, enumerable: false }); + return wrapped; +} + /** * Directly instruments a StateGraph instance to add tracing spans * diff --git a/packages/core/src/tracing/langgraph/utils.ts b/packages/core/src/tracing/langgraph/utils.ts index 4b1990058924..8770cbbd629b 100644 --- a/packages/core/src/tracing/langgraph/utils.ts +++ b/packages/core/src/tracing/langgraph/utils.ts @@ -1,16 +1,165 @@ -import type { Span } from '../../types-hoist/span'; +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span, SpanAttributes } from '../../types-hoist/span'; import { + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_TOOL_CALL_ID_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import type { LangChainMessage } from '../langchain/types'; +import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; -import type { CompiledGraph, LangGraphTool } from './types'; +import { startSpan } from '../trace'; +import { LANGGRAPH_ORIGIN } from './constants'; +import type { CompiledGraph, LangGraphOptions, LangGraphTool } from './types'; + +/** + * Extract LLM model object from createReactAgent params + */ +export function extractLLMFromParams(args: unknown[]): BaseChatModel | null { + const arg = args[0]; + if (typeof arg !== 'object' || !arg || !('llm' in arg) || !arg.llm || typeof arg.llm !== 'object') { + return null; + } + const llm = arg.llm as BaseChatModel; + if (typeof llm.modelName !== 'string' && typeof llm.model !== 'string') { + return null; + } + return llm; +} + +/** + * Extract agent name from createReactAgent params + */ +export function extractAgentNameFromParams(args: unknown[]): string | null { + const arg = args[0]; + if (typeof arg === 'object' && !!arg && 'name' in arg && typeof arg.name === 'string') { + return arg.name; + } + return null; +} + +/** + * Wraps an array of LangChain tools so each invocation creates a gen_ai.execute_tool span. + * + * Wraps each tool's invoke() method in place. A marker prevents double-wrapping. + */ +export function wrapToolsWithSpans(tools: unknown[], options: LangGraphOptions, agentName?: string): unknown[] { + const SENTRY_WRAPPED = '__sentry_tool_wrapped__'; + + for (const tool of tools) { + if (!tool || typeof tool !== 'object') { + continue; + } + + const t = tool as Record; + const originalInvoke = t.invoke; + if (typeof originalInvoke !== 'function' || Object.prototype.hasOwnProperty.call(t, SENTRY_WRAPPED)) { + continue; + } + + const toolName = typeof t.name === 'string' ? t.name : 'unknown_tool'; + const toolDescription = typeof t.description === 'string' ? t.description : undefined; + + const wrappedInvoke = new Proxy(originalInvoke as (...args: unknown[]) => unknown, { + apply(target, thisArg, args: unknown[]): unknown { + const spanAttributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: 'function', + }; + + // Read agent name from LangChain's propagated config metadata at call time, + // so shared tools get the correct agent name for each invocation + const callConfig = args[1] as Record | undefined; + const callAgentName = (callConfig?.metadata as Record)?.lc_agent_name ?? agentName; + if (typeof callAgentName === 'string') { + spanAttributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = callAgentName; + } + + if (toolDescription) { + spanAttributes[GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE] = toolDescription; + } + + // LangGraph ToolNode passes { name, args, id, type: "tool_call" } + const input = args[0] as Record | undefined; + if (typeof input === 'object' && !!input) { + if ('id' in input && typeof input.id === 'string') { + spanAttributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE] = input.id; + } + + if (options.recordInputs) { + const toolArgs = 'args' in input && typeof input.args === 'object' ? input.args : input; + try { + spanAttributes[GEN_AI_TOOL_INPUT_ATTRIBUTE] = JSON.stringify(toolArgs); + } catch { + // skip if not serializable + } + } + } + + return startSpan( + { + op: GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + name: `execute_tool ${toolName}`, + attributes: spanAttributes, + }, + async span => { + try { + const result = await Reflect.apply(target, thisArg, args); + + if (options.recordOutputs) { + try { + // ToolMessage objects wrap the result in .content + const resultObj = result as Record | undefined; + const content = + resultObj && typeof resultObj === 'object' && 'content' in resultObj ? resultObj.content : result; + span.setAttribute( + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + typeof content === 'string' ? content : JSON.stringify(content), + ); + } catch { + // skip if not serializable + } + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }, + ); + }, + }); + + t.invoke = wrappedInvoke; + Object.defineProperty(t, SENTRY_WRAPPED, { value: true, enumerable: false }); + } + + return tools; +} /** * Extract tool calls from messages @@ -185,3 +334,27 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, totalTokens); } } + +/** Merge `sentryHandler` into a langchain `callbacks` value (`BaseCallbackHandler[]` or `BaseCallbackManager`). */ +export function mergeSentryCallback(existing: unknown, sentryHandler: unknown): unknown { + if (!existing) { + return [sentryHandler]; + } + + if (Array.isArray(existing)) { + if (existing.includes(sentryHandler)) { + return existing; + } + return [...existing, sentryHandler]; + } + + const manager = existing as { addHandler?: (h: unknown) => void; handlers?: unknown[] }; + if (typeof manager.addHandler === 'function') { + const alreadyAdded = Array.isArray(manager.handlers) && manager.handlers.includes(sentryHandler); + if (!alreadyAdded) { + manager.addHandler(sentryHandler); + } + } + + return existing; +} diff --git a/packages/core/test/lib/tracing/langgraph.test.ts b/packages/core/test/lib/tracing/langgraph.test.ts new file mode 100644 index 000000000000..6cbd6ff2fdcb --- /dev/null +++ b/packages/core/test/lib/tracing/langgraph.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { instrumentCreateReactAgent, instrumentStateGraphCompile } from '../../../src/tracing/langgraph'; + +describe('langgraph double-patch guard', () => { + it('instrumentStateGraphCompile returns the same wrapper when applied twice', () => { + const original = (() => ({})) as unknown as Parameters[0]; + const first = instrumentStateGraphCompile(original, {}); + const second = instrumentStateGraphCompile(first, {}); + expect(second).toBe(first); + }); + + it('instrumentCreateReactAgent returns the same wrapper when applied twice', () => { + const original = (() => ({})) as unknown as Parameters[0]; + const first = instrumentCreateReactAgent(original); + const second = instrumentCreateReactAgent(first); + expect(second).toBe(first); + }); +}); diff --git a/packages/core/test/lib/utils/langgraph-utils.test.ts b/packages/core/test/lib/utils/langgraph-utils.test.ts new file mode 100644 index 000000000000..829317518622 --- /dev/null +++ b/packages/core/test/lib/utils/langgraph-utils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + extractAgentNameFromParams, + extractLLMFromParams, + mergeSentryCallback, +} from '../../../src/tracing/langgraph/utils'; + +describe('extractLLMFromParams', () => { + it('returns null for empty or invalid args', () => { + expect(extractLLMFromParams([])).toBe(null); + expect(extractLLMFromParams([null])).toBe(null); + expect(extractLLMFromParams([{}])).toBe(null); + expect(extractLLMFromParams([{ llm: false }])).toBe(null); + expect(extractLLMFromParams([{ llm: 123 }])).toBe(null); + expect(extractLLMFromParams([{ llm: {} }])).toBe(null); + }); + + it('extracts llm object with modelName', () => { + expect(extractLLMFromParams([{ llm: { modelName: 'gpt-4o-mini', lc_namespace: ['langchain'] } }])).toStrictEqual({ + modelName: 'gpt-4o-mini', + lc_namespace: ['langchain'], + }); + }); + + it('extracts llm object with model when modelName is absent', () => { + expect( + extractLLMFromParams([{ llm: { model: 'claude-3-5-sonnet-20241022', lc_namespace: ['langchain'] } }]), + ).toStrictEqual({ + model: 'claude-3-5-sonnet-20241022', + lc_namespace: ['langchain'], + }); + }); +}); + +describe('extractAgentNameFromParams', () => { + it('returns null for empty or invalid args', () => { + expect(extractAgentNameFromParams([])).toBe(null); + expect(extractAgentNameFromParams([null])).toBe(null); + expect(extractAgentNameFromParams([{}])).toBe(null); + expect(extractAgentNameFromParams([{ name: 123 }])).toBe(null); + }); + + it('extracts agent name from params', () => { + expect(extractAgentNameFromParams([{ name: 'my_agent' }])).toBe('my_agent'); + }); +}); + +describe('mergeSentryCallback', () => { + const sentryHandler = { _sentry: true }; + + it('returns a fresh array when no existing callbacks are present', () => { + expect(mergeSentryCallback(undefined, sentryHandler)).toStrictEqual([sentryHandler]); + expect(mergeSentryCallback(null, sentryHandler)).toStrictEqual([sentryHandler]); + }); + + it('appends to an existing callbacks array', () => { + const userA = { _user: 'A' }; + const userB = { _user: 'B' }; + expect(mergeSentryCallback([userA, userB], sentryHandler)).toStrictEqual([userA, userB, sentryHandler]); + }); + + it('does not duplicate when the sentry handler is already in the array', () => { + const userA = { _user: 'A' }; + const existing = [userA, sentryHandler]; + expect(mergeSentryCallback(existing, sentryHandler)).toBe(existing); + }); + + it('calls addHandler on a CallbackManager-like object', () => { + const addHandler = vi.fn(); + const manager = { addHandler, handlers: [] as unknown[] }; + const result = mergeSentryCallback(manager, sentryHandler); + expect(result).toBe(manager); + expect(addHandler).toHaveBeenCalledWith(sentryHandler); + expect(addHandler).toHaveBeenCalledTimes(1); + }); + + it('does not re-add when the manager already has the sentry handler', () => { + const addHandler = vi.fn(); + const manager = { addHandler, handlers: [sentryHandler] }; + mergeSentryCallback(manager, sentryHandler); + expect(addHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts index d43765206b61..f1e87e1c8c4c 100644 --- a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts @@ -5,8 +5,8 @@ import { InstrumentationNodeModuleDefinition, InstrumentationNodeModuleFile, } from '@opentelemetry/instrumentation'; -import type { LangGraphOptions } from '@sentry/core'; -import { instrumentLangGraph, SDK_VERSION } from '@sentry/core'; +import type { CompiledGraph, LangGraphOptions } from '@sentry/core'; +import { getClient, instrumentCreateReactAgent, instrumentLangGraph, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.0.0 <2.0.0']; @@ -18,6 +18,7 @@ type LangGraphInstrumentationOptions = InstrumentationConfig & LangGraphOptions; interface PatchedModuleExports { [key: string]: unknown; StateGraph?: abstract new (...args: unknown[]) => unknown; + createReactAgent?: (...args: unknown[]) => CompiledGraph; } /** @@ -31,40 +32,85 @@ export class SentryLangGraphInstrumentation extends InstrumentationBase exports, - [ - new InstrumentationNodeModuleFile( - /** - * In CJS, LangGraph packages re-export from dist/index.cjs files. - * Patching only the root module sometimes misses the real implementation or - * gets overwritten when that file is loaded. We add a file-level patch so that - * _patch runs again on the concrete implementation - */ - '@langchain/langgraph/dist/index.cjs', - supportedVersions, - this._patch.bind(this), - exports => exports, - ), - ], - ); - return module; + public init(): InstrumentationModuleDefinition[] { + return [ + new InstrumentationNodeModuleDefinition( + '@langchain/langgraph', + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + /** + * In CJS, LangGraph packages re-export from dist/index.cjs files. + * Patching only the root module sometimes misses the real implementation or + * gets overwritten when that file is loaded. We add a file-level patch so that + * _patch runs again on the concrete implementation + */ + '@langchain/langgraph/dist/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + new InstrumentationNodeModuleFile( + /** + * In CJS, the prebuilt submodule re-exports from dist/prebuilt/index.cjs. + * We add a file-level patch under the main module so that CJS require() + * of @langchain/langgraph/prebuilt gets patched. + */ + '@langchain/langgraph/dist/prebuilt/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + new InstrumentationNodeModuleDefinition( + '@langchain/langgraph/prebuilt', + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + /** + * In CJS, the prebuilt submodule re-exports from dist/prebuilt/index.cjs. + * We add file-level patches so _patch runs on the concrete implementation. + */ + '@langchain/langgraph/dist/prebuilt/index.cjs', + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + ]; } /** * Core patch logic applying instrumentation to the LangGraph module. */ private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const client = getClient(); + const options = { + ...this.getConfig(), + recordInputs: this.getConfig().recordInputs ?? client?.getOptions().sendDefaultPii, + recordOutputs: this.getConfig().recordOutputs ?? client?.getOptions().sendDefaultPii, + }; + // Patch StateGraph.compile to instrument both compile() and invoke() if (exports.StateGraph && typeof exports.StateGraph === 'function') { - instrumentLangGraph( - exports.StateGraph.prototype as { compile: (...args: unknown[]) => unknown }, - this.getConfig(), - ); + instrumentLangGraph(exports.StateGraph.prototype as { compile: (...args: unknown[]) => unknown }, options); + } + + // Patch createReactAgent to instrument agent creation and invocation + if (exports.createReactAgent && typeof exports.createReactAgent === 'function') { + const originalCreateReactAgent = exports.createReactAgent; + Object.defineProperty(exports, 'createReactAgent', { + value: instrumentCreateReactAgent(originalCreateReactAgent as (...args: unknown[]) => CompiledGraph, options), + writable: true, + enumerable: true, + configurable: true, + }); } return exports; From 971aade288b09cfb3fcf2f8e15ad557ea804dada Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 10:43:10 +0200 Subject: [PATCH 69/77] feat(aws-serverless): Validate extension tunnel DSN against `SENTRY_DSN` (#20528) If this is set (which should generally be the case when using the layer), we want to only allow this DSN to be forwarded. If not set, it does not validate but warn that this is not validated. --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../lambda-functions-layer/Tunnel/index.js | 38 +++++++++ .../TunnelNoDsn/index.js | 38 +++++++++ .../aws-serverless-layer/src/stack.ts | 5 +- .../aws-serverless-layer/tests/layer.test.ts | 79 ++++++++++++++++++- .../lambda-extension/aws-lambda-extension.ts | 58 ++++++++++++-- .../test/aws-lambda-extension.test.ts | 37 +++++++++ 6 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js create mode 100644 packages/aws-serverless/test/aws-lambda-extension.test.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js new file mode 100644 index 000000000000..5a25387cfe10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/Tunnel/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.SENTRY_DSN ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js new file mode 100644 index 000000000000..c4751d2aa1fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/lambda-functions-layer/TunnelNoDsn/index.js @@ -0,0 +1,38 @@ +function makeHex(length) { + return Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join(''); +} + +exports.handler = async event => { + const dsn = event?.dsn ?? process.env.TUNNEL_TEST_DSN; + + const envelopeHeader = event?.omitDsn + ? {} + : { + dsn, + }; + const envelopeItemHeader = { type: 'event' }; + const envelopeItemPayload = { + event_id: makeHex(32), + message: event?.marker ?? 'lambda-extension-tunnel-no-dsn-test', + level: 'info', + }; + const envelope = `${JSON.stringify(envelopeHeader)}\n${JSON.stringify(envelopeItemHeader)}\n${JSON.stringify( + envelopeItemPayload, + )}\n`; + + const response = await fetch('http://localhost:9000/envelope', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: envelope, + }); + + const responseBody = await response.text(); + + return { + attemptedDsn: dsn, + status: response.status, + responseBody, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index 8475ee0a328a..5d35a9f6fcc1 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -75,10 +75,13 @@ export class LocalLambdaStack extends Stack { Layers: [{ Ref: this.sentryLayer.logicalId }], Environment: { Variables: { - SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, + // We only set SENTRY_DSN if not running TunnelNoDsn, because there + // we want to test that the extension tunnel forwards requests when SENTRY_DSN is missing. + TUNNEL_TEST_DSN: dsn, + ...(lambdaDir !== 'TunnelNoDsn' ? { SENTRY_DSN: dsn } : {}), }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts index c32dbfea7435..560f676cfd07 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/layer.test.ts @@ -1,7 +1,21 @@ -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { waitForTransaction, waitForError, waitForRequest } from '@sentry-internal/test-utils'; import { InvokeCommand } from '@aws-sdk/client-lambda'; import { test, expect } from './lambda-fixtures'; +interface TunnelInvokeResult { + attemptedDsn?: string; + status: number; + responseBody: string; +} + +function parseLambdaPayload(payload: Uint8Array | undefined): TunnelInvokeResult { + if (!payload) { + throw new Error('Missing Lambda payload'); + } + + return JSON.parse(Buffer.from(payload).toString('utf8')) as TunnelInvokeResult; +} + test.describe('Lambda layer', () => { test('tracing in CJS works', async ({ lambdaClient }) => { const transactionEventPromise = waitForTransaction('aws-serverless-layer', transactionEvent => { @@ -242,4 +256,67 @@ test.describe('Lambda layer', () => { }), ); }); + + test('extension tunnel validates DSN allowlist and rejects invalid envelopes', async ({ lambdaClient }) => { + const matchingMarker = `extension-tunnel-matching-${Date.now()}`; + const matchingRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(matchingMarker); + }); + + const matchingResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + marker: matchingMarker, + }), + }), + ); + const matchingResult = parseLambdaPayload(matchingResponse.Payload); + expect(matchingResult.status).toBe(200); + await matchingRequestPromise; + + const mismatchedResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + // Keep host/project/port valid but change public key, so DSN stays valid and fails allowlist match. + dsn: String(matchingResult.attemptedDsn).replace('://public@', '://unauthorized@'), + }), + }), + ); + const mismatchedResult = parseLambdaPayload(mismatchedResponse.Payload); + expect(mismatchedResult.status).toBe(403); + expect(mismatchedResult.responseBody).toContain('DSN not allowed'); + + const missingDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnel', + Payload: JSON.stringify({ + omitDsn: true, + }), + }), + ); + const missingDsnResult = parseLambdaPayload(missingDsnResponse.Payload); + expect(missingDsnResult.status).toBe(400); + expect(missingDsnResult.responseBody).toContain('missing DSN'); + }); + + test('extension tunnel forwards requests when SENTRY_DSN is missing', async ({ lambdaClient }) => { + const marker = `extension-tunnel-no-sentry-dsn-${Date.now()}`; + const noDsnRequestPromise = waitForRequest('aws-serverless-layer', requestData => { + return requestData.rawProxyRequestBody.includes(marker); + }); + + const noDsnResponse = await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTunnelNoDsn', + Payload: JSON.stringify({ + marker, + }), + }), + ); + const noDsnResult = parseLambdaPayload(noDsnResponse.Payload); + expect(noDsnResult.status).toBe(200); + await noDsnRequestPromise; + }); }); diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts index ff2228fffabe..586027233ad5 100644 --- a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -1,6 +1,13 @@ import * as http from 'node:http'; import { buffer } from 'node:stream/consumers'; -import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core'; +import { + consoleSandbox, + debug, + type DsnComponents, + dsnToString, + getEnvelopeEndpointWithUrlEncodedAuth, + makeDsn, +} from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; /** @@ -94,6 +101,19 @@ export class AwsLambdaExtension { * Starts the Sentry tunnel. */ public startSentryTunnel(): void { + const allowedDsnComponents = getSentryDSNFromEnv(); + + if (!allowedDsnComponents) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + 'Sentry Lambda extension: SENTRY_DSN is not set or is invalid. The /envelope tunnel will forward ' + + 'any DSN in the envelope header without allowlist validation. Set SENTRY_DSN to the same DSN as ' + + 'your SDK to restrict outbound requests.', + ); + }); + } + const server = http.createServer(async (req, res) => { if (req.method === 'POST' && req.url?.startsWith('/envelope')) { try { @@ -104,12 +124,30 @@ export class AwsLambdaExtension { const envelope = new TextDecoder().decode(envelopeBytes); const piece = envelope.split('\n')[0]; const header = JSON.parse(piece || '{}') as { dsn?: string }; - if (!header.dsn) { - throw new Error('DSN is not set'); + const envelopeDsn = header.dsn; + if (!envelopeDsn) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid envelope: missing DSN' })); + return; } - const dsn = dsnFromString(header.dsn); + + // When SENTRY_DSN is set, same allowlist check as handleTunnelRequest in @sentry/core (SSRF protection). + // If not set, we allow any DSN (but warn about this once, above) + if (allowedDsnComponents) { + if (dsnToString(allowedDsnComponents) !== envelopeDsn) { + DEBUG_BUILD && + debug.warn(`Sentry Lambda extension tunnel: rejected request with unauthorized DSN (${envelopeDsn})`); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'DSN not allowed' })); + return; + } + } + + const dsn = allowedDsnComponents || makeDsn(envelopeDsn); if (!dsn) { - throw new Error('Invalid DSN'); + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid DSN' })); + return; } const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn); @@ -143,3 +181,13 @@ export class AwsLambdaExtension { }); } } + +/** + * DSN components allowed for the Lambda extension `/envelope` tunnel, derived from `SENTRY_DSN`. + * + * Exported only for testing purposes. + */ +export function getSentryDSNFromEnv(): DsnComponents | undefined { + const raw = process.env.SENTRY_DSN?.trim(); + return raw ? makeDsn(raw) : undefined; +} diff --git a/packages/aws-serverless/test/aws-lambda-extension.test.ts b/packages/aws-serverless/test/aws-lambda-extension.test.ts new file mode 100644 index 000000000000..4c3143eea442 --- /dev/null +++ b/packages/aws-serverless/test/aws-lambda-extension.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getSentryDSNFromEnv } from '../src/lambda-extension/aws-lambda-extension'; + +describe('getSentryDSNFromEnv', () => { + afterEach(() => { + delete process.env.SENTRY_DSN; + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test('returns undefined when SENTRY_DSN is unset', () => { + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); + + test('returns canonical dsn string when SENTRY_DSN is valid', () => { + process.env.SENTRY_DSN = 'https://public@o1.ingest.sentry.io/1'; + + expect(getSentryDSNFromEnv()).toEqual({ + protocol: 'https', + publicKey: 'public', + host: 'o1.ingest.sentry.io', + projectId: '1', + pass: '', + path: '', + port: '', + }); + }); + + test('returns undefined when SENTRY_DSN is invalid', () => { + process.env.SENTRY_DSN = 'not-a-dsn'; + + expect(getSentryDSNFromEnv()).toEqual(undefined); + }); +}); From c0005cd387f3a7ea6fbb2e85041562c7f32e0484 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 10:46:57 +0200 Subject: [PATCH 70/77] test(node): Update timeout for cron integration tests (#20586) Saw this flake once or twice, just increasing timeout for cron tests to allow for slow runner startup etc. --- .../node-integration-tests/suites/cron/cron/scenario.ts | 2 +- dev-packages/node-integration-tests/suites/cron/cron/test.ts | 2 +- .../suites/cron/node-cron/base/scenario.ts | 2 +- .../node-integration-tests/suites/cron/node-cron/base/test.ts | 2 +- .../suites/cron/node-cron/isolateTrace/scenario.ts | 2 +- .../suites/cron/node-cron/isolateTrace/test.ts | 2 +- .../suites/cron/node-schedule/scenario.ts | 2 +- .../node-integration-tests/suites/cron/node-schedule/test.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index 5c8cd915bc12..6fe6838844de 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -27,4 +27,4 @@ cron.start(); setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index a9febf9efbe4..078cc0997221 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('cron instrumentation', async () => { +test('cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts index 48107d0a4b1e..fb69a5de2482 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts @@ -33,4 +33,4 @@ const task = cronWithCheckIn.schedule( setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts index 990af6028235..61cfe7bf2943 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-cron instrumentation', async () => { +test('node-cron instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts index 5a670d9e6cf2..b64b2d814b67 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -53,4 +53,4 @@ const task2 = cronWithCheckIn.schedule( setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts index ea044ca22ec6..0f0ef2e268ad 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-cron instrumentation', async () => { +test('node-cron instrumentation', { timeout: 30_000 }, async () => { let firstErrorTraceId: string | undefined; await createRunner(__dirname, 'scenario.ts') diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts index f42675f25306..2bb67cc0047c 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule/scenario.ts @@ -25,4 +25,4 @@ const job = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * * *', () => setTimeout(() => { process.exit(); -}, 5000); +}, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts b/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts index 2b46e04d50a4..08905b77e45f 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-schedule/test.ts @@ -5,7 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -test('node-schedule instrumentation', async () => { +test('node-schedule instrumentation', { timeout: 30_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .expect({ check_in: { From c4e3902c9297147158e730f017aba96e83ef619e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 11:09:54 +0200 Subject: [PATCH 71/77] chore(ci): Do not report flaky test issues if we cannot find a test name (#20589) Today, we got a bunch of flaky CI issues for e.g. failing cache or playwright installation. While these are also kind of flakes, the issues are not super actionable and it gets a bit noisy. I'd opt to not create an issue if we cannot identify a concrete test that is flaking. We miss out on some things being auto-reported here but that's also OK I'd say. Closes https://github.com/getsentry/sentry-javascript/issues/20319 Closes https://github.com/getsentry/sentry-javascript/issues/20557 Closes https://github.com/getsentry/sentry-javascript/issues/20493 Closes https://github.com/getsentry/sentry-javascript/issues/20492 Closes https://github.com/getsentry/sentry-javascript/issues/20469 Closes https://github.com/getsentry/sentry-javascript/issues/20470 --- scripts/report-ci-failures.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/report-ci-failures.mjs b/scripts/report-ci-failures.mjs index 5a05e1144bec..b407eac157c0 100644 --- a/scripts/report-ci-failures.mjs +++ b/scripts/report-ci-failures.mjs @@ -68,9 +68,10 @@ export default async function run({ github, context, core }) { core.info(`Could not fetch annotations for ${jobName}: ${e.message}`); } - // If no test names found, fall back to one issue per job + // If no test names found, abort - this could mean something else, e.g. cache restoration or similar fails + // and also the issue is not super helpful in this case if (testNames.length === 0) { - testNames = ['Unknown test']; + continue; } // Create one issue per failing test for proper deduplication From 91ffb3fac90835ab160f8152527a54a5d64f3250 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 11:11:42 +0200 Subject: [PATCH 72/77] test(node): Fix flaky worker thread integration test (#20588) Fixes https://github.com/getsentry/sentry-javascript/issues/20571 --- .../suites/thread-blocked-native/test.ts | 55 +++++++++++-------- .../thread-blocked-native/worker-block.mjs | 2 +- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 8dd49d126b67..c6729e55c209 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -177,43 +177,50 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { .completed(); }); - test('worker thread', async () => { + test('worker thread', { timeout: 60_000 }, async () => { const instrument = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'worker-main.mjs') .withMockSentryServer() .withFlags('--import', instrument) .expect({ event: event => { - const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string; + const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string | undefined; expect(crashedThread).toBeDefined(); + const expectedEvent = ANR_EVENT(); expect(event).toMatchObject({ - ...ANR_EVENT(), + ...expectedEvent, + // We compare this separately below + threads: expect.any(Object), exception: { ...EXCEPTION(crashedThread), }, - threads: { - values: [ - { - id: '0', - name: 'main', - crashed: false, - current: true, - main: true, - stacktrace: { - frames: expect.any(Array), - }, - }, - { - id: crashedThread, - name: `worker-${crashedThread}`, - crashed: true, - current: true, - main: false, - }, - ], - }, }); + + const threadValues = event.threads?.values ?? []; + expect(threadValues).toHaveLength(2); + // Any order is fine, we just check that both are present + expect(threadValues).toContainEqual( + expect.objectContaining({ + id: '0', + name: 'main', + crashed: false, + current: true, + main: true, + stacktrace: { + frames: expect.any(Array), + }, + }), + ); + expect(threadValues).toContainEqual( + expect.objectContaining({ + id: crashedThread, + name: `worker-${crashedThread}`, + crashed: true, + current: true, + main: false, + }), + ); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs index dfd664fbf01f..a8927c19950c 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs @@ -2,4 +2,4 @@ import { longWork } from './long-work.js'; setTimeout(() => { longWork(); -}, 5000); +}, 10_000); From 217ad4a69554281806eccbfeac1b27c4f43f6ffa Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 11:23:51 +0200 Subject: [PATCH 73/77] test(node): Fix flaky ANR test (#20592) Closes https://github.com/getsentry/sentry-javascript/issues/20268 --- .../suites/anr/test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-core-integration-tests/suites/anr/test.ts b/dev-packages/node-core-integration-tests/suites/anr/test.ts index c9a81ccb5db0..406830c9b299 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-core-integration-tests/suites/anr/test.ts @@ -2,6 +2,17 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; +/** Avoid flakes on slow CI: fixed sleeps can fire before the child process has finished exiting. */ +async function waitForChildExit(childHasExited: () => boolean, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (!childHasExited()) { + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out waiting for child process to exit'); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + const ANR_EVENT = { // Ensure we have context contexts: { @@ -178,7 +189,7 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => test('should exit', async () => { const runner = createRunner(__dirname, 'should-exit.js').start(); - await new Promise(resolve => setTimeout(resolve, 5_000)); + await waitForChildExit(() => runner.childHasExited()); expect(runner.childHasExited()).toBe(true); }); @@ -186,7 +197,7 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => test('should exit forced', async () => { const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - await new Promise(resolve => setTimeout(resolve, 5_000)); + await waitForChildExit(() => runner.childHasExited()); expect(runner.childHasExited()).toBe(true); }); From 1166839112c4766f210124dc0486ebbfd6db104b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:16:49 +0200 Subject: [PATCH 74/77] fix(hono): Distinguish `.use()` middleware in sub-apps from `.all()` handlers (#20554) Previously, Hono `.route()` was patched by checking for an `'ALL'` handler (as those are used as middleware) to detect middleware in Hono sub-apps. But this incorrectly treated `.all()` final handlers as middleware. This broke error capture for `.all()` error routes (the wrapped handler caught and marked the error, preventing responseHandler from capturing it). Now we use an arity heuristic (arity = number of params): middleware accepts `(context, next)`, while route handlers accept only `(context)`. Also added more middleware-related E2E test scenarios and route-patterns tests. Basically adding test scenarios from the Node integration test to the E2E test: https://github.com/getsentry/sentry-javascript/blob/5d0d14531511fcd703438e072723ef98cd700ea3/dev-packages/node-integration-tests/suites/tracing/hono/test.ts Part of Project: https://github.com/getsentry/sentry-javascript/issues/15260 Builds onto: https://github.com/getsentry/sentry-javascript/issues/20449 --- .../src/route-groups/test-middleware.ts | 59 ++++++- .../src/route-groups/test-route-patterns.ts | 55 +++++++ .../test-applications/hono-4/src/routes.ts | 9 +- .../hono-4/tests/middleware.test.ts | 74 ++++++--- .../hono-4/tests/route-patterns.test.ts | 144 ++++++++++++++++++ packages/hono/src/shared/patchRoute.ts | 54 +++++-- packages/hono/test/node/middleware.test.ts | 89 ++++++++++- .../test/shared/middlewareHandlers.test.ts | 119 +++++++++++++++ packages/hono/test/shared/patchAppUse.test.ts | 88 ++++++++++- packages/hono/test/shared/patchRoute.test.ts | 141 +++++++++++++++++ 10 files changed, 789 insertions(+), 43 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts create mode 100644 packages/hono/test/shared/middlewareHandlers.test.ts create mode 100644 packages/hono/test/shared/patchRoute.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts index 23004976ef08..49ca50c591bf 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -8,7 +8,7 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); middlewareRoutes.get('/error', c => c.text('should not reach')); -// Self-contained sub-app registering its own middleware +// Self-contained sub-app registering its own middleware via .use() const subAppWithMiddleware = new Hono(); subAppWithMiddleware.use('/named/*', middlewareA); @@ -19,12 +19,63 @@ subAppWithMiddleware.use('/anonymous/*', async (c, next) => { subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); subAppWithMiddleware.use('/error/*', failingMiddleware); -// .all() produces the same method:'ALL' as .use() in Hono's route record. -// Wrapping it is harmless (onlyIfParent:true) — this route exists to prove that. +// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute. subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { return c.json({ handler: 'all' }); }); subAppWithMiddleware.route('/', middlewareRoutes); -export { middlewareRoutes, subAppWithMiddleware }; +// Sub-app with inline middleware for different registration styles. +// patchRoute wraps non-last handlers per method+path group as middleware. +const subAppWithInlineMiddleware = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware[method]( + '/direct', + async function inlineMiddleware(_c, next) { + await next(); + }, + c => c.text(`${method} direct response`), + ); + + subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) { + await next(); + }); + subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`)); +}); + +// .all(): .all('/path', mw, handler) +subAppWithInlineMiddleware.all( + '/all', + async function inlineMiddlewareAll(_c, next) { + await next(); + }, + c => c.text('all response'), +); +subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) { + await next(); +}); +subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response')); + +// .on() registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware.on( + method, + '/on', + async function inlineMiddlewareOn(_c, next) { + await next(); + }, + c => c.text(`${method} on response`), + ); + + subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) { + await next(); + }); + subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`)); +}); + +export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts new file mode 100644 index 000000000000..e32662fb3b18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts @@ -0,0 +1,55 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const routePatterns = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method (sync handlers) +METHODS.forEach(method => { + routePatterns[method]('/', c => c.text(`${method} response`)); +}); + +// Async handler +routePatterns.get('/async', async c => { + await new Promise(resolve => setTimeout(resolve, 10)); + return c.text('async response'); +}); + +// .all() registration +routePatterns.all('/all', c => c.text('all handler response')); + +// .on() registration +METHODS.forEach(method => { + routePatterns.on(method, '/on', c => c.text(`${method} on response`)); +}); + +// Error routes for direct method registration +METHODS.forEach(method => { + routePatterns[method]('/500', () => { + throw new HTTPException(500, { message: 'response 500' }); + }); + routePatterns[method]('/401', () => { + throw new HTTPException(401, { message: 'response 401' }); + }); + routePatterns[method]('/402', () => { + throw new HTTPException(402, { message: 'response 402' }); + }); + routePatterns[method]('/403', () => { + throw new HTTPException(403, { message: 'response 403' }); + }); +}); + +// Error routes for .all() +routePatterns.all('/all/500', () => { + throw new HTTPException(500, { message: 'response 500' }); +}); + +// Error routes for .on() +METHODS.forEach(method => { + routePatterns.on(method, '/on/500', () => { + throw new HTTPException(500, { message: 'response 500' }); + }); +}); + +export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index 0ff66a589b72..f6efc6dde03c 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,7 +1,8 @@ import type { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { failingMiddleware, middlewareA, middlewareB } from './middleware'; -import { middlewareRoutes, subAppWithMiddleware } from './route-groups/test-middleware'; +import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; +import { routePatterns } from './route-groups/test-route-patterns'; export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): void { app.get('/', c => { @@ -36,4 +37,10 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching app.route('/test-subapp-middleware', subAppWithMiddleware); + + // Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware + app.route('/test-inline-middleware', subAppWithInlineMiddleware); + + // Route patterns: HTTP methods, .all(), .on(), sync/async, errors + app.route('/test-routes', routePatterns); } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index e3b4556bfd18..e8431bed67ce 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -97,10 +97,7 @@ for (const { name, prefix } of SCENARIOS) { test('captures error thrown in middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return ( - event.exception?.values?.[0]?.value === 'Middleware error' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.hono' - ); + return event.exception?.values?.[0]?.value === 'Middleware error'; }); const response = await fetch(`${baseURL}${prefix}/error`); @@ -152,8 +149,8 @@ for (const { name, prefix } of SCENARIOS) { }); } -test.describe('patchRoute wraps .all() as middleware span (in sub-app)', () => { - test('patchRoute wraps .all() as middleware span', async ({ baseURL }) => { +test.describe('.all() handler in sub-app', () => { + test('does not create middleware span for .all() route handler', async ({ baseURL }) => { const transactionPromise = waitForTransaction(APP_NAME, event => { return ( event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler' @@ -169,20 +166,55 @@ test.describe('patchRoute wraps .all() as middleware span (in sub-app)', () => { const transaction = await transactionPromise; const spans = transaction.spans || []; - // On Bun/Cloudflare, patchRoute is the sole wrapper and sees the original - // function name. It wraps .all() handlers identically to .use() middleware - // because both produce method:'ALL' in Hono's route record. - const allHandlerSpan = spans.find( - (span: SpanJSON) => span.op === 'middleware.hono' && span.description === 'allCatchAll', - ); - - expect(allHandlerSpan).toEqual( - expect.objectContaining({ - description: 'allCatchAll', - op: 'middleware.hono', - origin: 'auto.middleware.hono', - status: 'ok', - }), - ); + // No middleware is called for this route, so there should be no spans. + expect(spans).toEqual([]); }); }); + +const INLINE_PREFIX = '/test-inline-middleware'; + +const REGISTRATION_STYLES = [ + { name: 'direct method (.get())', path: '/direct' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +const MIDDLEWARE_STYLES = [ + { name: 'inline', path: '' }, + { name: 'separately registered', path: '/separately' }, +] as const; + +test.describe('inline middleware spans (sub-app)', () => { + for (const { name: regName, path: regPath } of REGISTRATION_STYLES) { + for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) { + test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => { + const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; + + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`; + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + + const EXPECTED_DESCRIPTIONS: Record> = { + '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, + '/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' }, + '/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' }, + }; + const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + description: expectedDescription, + op: 'middleware.hono', + origin: 'auto.middleware.hono', + status: 'ok', + }), + ); + }); + } + } +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts new file mode 100644 index 000000000000..fd6579fe3b17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const PREFIX = '/test-routes'; + +const REGISTRATION_STYLES = [ + { name: 'direct method', path: '' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +test.describe('HTTP methods', () => { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + test(`sends transaction for ${method}`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}`, { method }); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe(`${method} ${PREFIX}`); + }); + } +}); + +test.describe('route registration styles', () => { + for (const { name, path } of REGISTRATION_STYLES) { + test(`${name} sends transaction`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}${path}`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`); + }); + } + + for (const { name, path } of [ + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, + ]) { + test(`${name} responds to POST`, async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`; + }); + + const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`); + }); + } +}); + +test('async handler sends transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction(APP_NAME, event => { + return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`; + }); + + const response = await fetch(`${baseURL}${PREFIX}/async`); + expect(response.status).toBe(200); + + const transaction = await transactionPromise; + expect(transaction.contexts?.trace?.op).toBe('http.server'); +}); + +test.describe('500 HTTPException capture', () => { + for (const { name, path } of REGISTRATION_STYLES) { + test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => { + const fullPath = `${PREFIX}${path}/500`; + + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath); + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); + } + + test('captures 500 error with POST method', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + event.exception?.values?.[0]?.value === 'response 500' && + !!event.request?.url?.includes(`${PREFIX}/500`) && + event.request?.method === 'POST' + ); + }); + + const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' }); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); +}); + +test.describe('4xx HTTPException capture', () => { + for (const code of [401, 402, 403]) { + test(`captures ${code} HTTPException`, async ({ baseURL }) => { + const fullPath = `${PREFIX}/${code}`; + + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath); + }); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(code); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + }); + } +}); diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts index 6e821d2af64a..d3f732e30793 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/hono/src/shared/patchRoute.ts @@ -1,8 +1,14 @@ import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Env, Hono } from 'hono'; +import type { Env, Hono, MiddlewareHandler } from 'hono'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; +interface HonoRoute { + method: string; + path: string; + handler: MiddlewareHandler; +} + interface HonoBaseProto { // oxlint-disable-next-line typescript/no-explicit-any route: (path: string, app: Hono) => Hono; @@ -29,15 +35,7 @@ export function patchRoute(app: Hono): void { // oxlint-disable-next-line typescript/no-explicit-any const patchedRoute = function (this: Hono, path: string, subApp: Hono): Hono { if (subApp && Array.isArray(subApp.routes)) { - for (const route of subApp.routes) { - /* Internally, `app.use()` always registers with `method: 'ALL'` (via the constant `METHOD_NAME_ALL`), - * while `app.get()` / `.post()` / etc. use their respective uppercase method name. - * https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 - */ - if (route.method === 'ALL' && typeof route.handler === 'function') { - route.handler = wrapMiddlewareWithSpan(route.handler); - } - } + wrapSubAppMiddleware(subApp.routes as HonoRoute[]); } return originalRoute.call(this, path, subApp); }; @@ -45,3 +43,39 @@ export function patchRoute(app: Hono): void { markFunctionWrapped(patchedRoute as unknown as WrappedFunction, originalRoute as unknown as WrappedFunction); honoBaseProto.route = patchedRoute; } + +/** + * Figures out which handlers in a sub-app's flat routes array are middleware (and should get a span), then wraps them. + * + * The challenge: Hono stores every handler as a plain { method, path, handler } entry. There is no "isMiddleware" flag. + * Two heuristics identify middleware: + * + * 1. Position within a group. `app.get('/path', mw, handler)` produces two entries with the same method+path. + * All but the last one must be middleware, because only middleware calls `next()` to pass control to the next handler. + * + * 2. Function arity (# of params) for method 'ALL'. Both `.use()` and `.all()` store their handlers under method 'ALL', + * so we can't use position alone to tell them apart when one is the last (or only) entry in its group. + * The deciding factor: Hono's `.use()` only accepts `(context, next)` (handlers with 2+ params). While `.all()` route + * handlers typically only accept `(context)`. + * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + */ +export function wrapSubAppMiddleware(routes: HonoRoute[]): void { + const lastIndexByKey = new Map(); + for (const [i, route] of routes.entries()) { + // \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path + lastIndexByKey.set(`${route.method}\0${route.path}`, i); + } + + for (const [i, route] of routes.entries()) { + if (typeof route.handler !== 'function') { + continue; + } + + const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i; + + const isMiddleware = !isLastForGroup || (route.method === 'ALL' && route.handler.length >= 2); + if (isMiddleware) { + route.handler = wrapMiddlewareWithSpan(route.handler); + } + } +} diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index 546350fd8377..745e924e7804 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -1,6 +1,6 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry } from '../../src/node/middleware'; import { init } from '../../src/node/sdk'; @@ -11,6 +11,18 @@ vi.mock('@sentry/node', () => ({ // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { init: initNodeMock } = await vi.importMock('@sentry/node'); +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + applySdkMetadata: vi.fn(actual.applySdkMetadata), + }; +}); + +const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; + describe('Hono Node Middleware', () => { beforeEach(() => { vi.clearAllMocks(); @@ -87,4 +99,79 @@ describe('Hono Node Middleware', () => { getClientSpy.mockRestore(); }); }); + + describe('sentry middleware without options (external init)', () => { + it('does not call init when no options are provided', () => { + const app = new Hono(); + sentry(app); + + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); + }); + + it('returns a middleware handler function', () => { + const app = new Hono(); + const middleware = sentry(app); + + expect(middleware).toBeDefined(); + expect(typeof middleware).toBe('function'); + expect(middleware).toHaveLength(2); + }); + + it('returns an async middleware handler', () => { + const app = new Hono(); + const middleware = sentry(app); + + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); + + it('emits a warning when Sentry is not initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + const app = new Hono(); + sentry(app); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Sentry is not initialized')); + }); + + it('does not emit a warning when Sentry is already initialized', () => { + const warnSpy = vi.spyOn(SentryCore.debug, 'warn'); + const fakeClient = { getOptions: () => ({ debug: false }) }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue(fakeClient as unknown as SentryCore.Client); + + const app = new Hono(); + const middleware = sentry(app); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); + }); + + describe('double-init guard', () => { + it('skips re-initialization when a client already exists', () => { + const fakeClient = { getOptions: () => ({}) }; + const getClientSpy = vi + .spyOn(SentryCore, 'getClient') + .mockReturnValue(fakeClient as unknown as SentryCore.Client); + + const result = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(result).toBe(fakeClient); + expect(initNodeMock).not.toHaveBeenCalled(); + expect(applySdkMetadataMock).not.toHaveBeenCalled(); + + getClientSpy.mockRestore(); + }); + + it('initializes normally when no client exists yet', () => { + const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + + getClientSpy.mockRestore(); + }); + }); }); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts new file mode 100644 index 000000000000..83099370320c --- /dev/null +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -0,0 +1,119 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { responseHandler } from '../../src/shared/middlewareHandlers'; + +vi.mock('hono/route', () => ({ + routePath: () => '/test', +})); + +vi.mock('../../src/utils/hono-context', () => ({ + hasFetchEvent: () => false, +})); + +const mockSetTransactionName = vi.fn(); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getActiveSpan: vi.fn(() => null), + getIsolationScope: vi.fn(() => ({ + setTransactionName: mockSetTransactionName, + })), + getClient: vi.fn(() => undefined), + }; +}); + +const getClientMock = SentryCore.getClient as ReturnType; + +function createMockContext(status: number, error?: Error): unknown { + return { + req: { method: 'GET', raw: new Request('http://localhost/test') }, + res: { status }, + error, + }; +} + +describe('responseHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('error capture', () => { + it('captures error when context.error is set', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('server error'); + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(500, error) as any); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + + it('captures error regardless of status code', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('not found'); + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(404, error) as any); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + + it('does not call captureException when there is no error', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(200) as any); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not throw when client is undefined', () => { + getClientMock.mockReturnValue(undefined); + + // oxlint-disable-next-line typescript/no-explicit-any + expect(() => responseHandler(createMockContext(500, new Error('boom')) as any)).not.toThrow(); + }); + + it('delegates deduplication to captureException — calls it even for errors with __sentry_captured__', () => { + const mockCaptureException = vi.fn(); + getClientMock.mockReturnValue({ + captureException: mockCaptureException, + }); + + const error = new Error('already captured'); + Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); + + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(500, error) as any); + + // captureException is called — it handles deduplication internally via checkOrSetAlreadyCaught + expect(mockCaptureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.hono.context_error' }, + }); + }); + }); + + describe('transaction name', () => { + it('sets transaction name on isolation scope', () => { + // oxlint-disable-next-line typescript/no-explicit-any + responseHandler(createMockContext(200) as any); + + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); + }); + }); +}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index 84dd510113e1..ee376127baaa 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -187,7 +187,7 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); }); - it('does not wrap route handlers (only method ALL from use())', async () => { + it('does not wrap sole route handlers on sub-apps', async () => { const app = new Hono(); patchAppUse(app); patchRoute(app); @@ -260,7 +260,7 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); }); - it('also wraps .all() handlers on sub-apps (same method: ALL in route record)', async () => { + it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { const app = new Hono(); patchAppUse(app); patchRoute(app); @@ -273,10 +273,10 @@ describe('patchAppUse (middleware spans)', () => { app.route('/api', subApp); await app.fetch(new Request('http://localhost/api/catch-all')); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'allHandler' })); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); }); - it('wraps mixed .use() and .all() handlers on the same sub-app', async () => { + it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { const app = new Hono(); patchAppUse(app); patchRoute(app); @@ -295,10 +295,10 @@ describe('patchAppUse (middleware spans)', () => { const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); expect(spanNames).toContain('mw'); - expect(spanNames).toContain('allRoute'); + expect(spanNames).not.toContain('allRoute'); }); - it('does not wrap .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { + it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps (they are final handlers, not middleware)', async () => { const app = new Hono(); patchAppUse(app); patchRoute(app); @@ -310,6 +310,12 @@ describe('patchAppUse (middleware spans)', () => { subApp.post('/resource', async function postHandler() { return new Response('post'); }); + subApp.put('/resource', async function postHandler() { + return new Response('put'); + }); + subApp.delete('/resource', async function postHandler() { + return new Response('delete'); + }); app.route('/api', subApp); await app.fetch(new Request('http://localhost/api/resource')); @@ -317,6 +323,76 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).not.toHaveBeenCalled(); }); + it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get( + '/resource', + async function inlineMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function getHandler() { + return new Response('get'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('inlineMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps separately registered middleware for .get() on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('separateMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps inline middleware registered via .on() on sub-apps', async () => { + const app = new Hono(); + patchAppUse(app); + patchRoute(app); + + const subApp = new Hono(); + subApp.on( + 'GET', + '/resource', + async function onMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function onHandler() { + return new Response('on'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('onMw'); + expect(spanNames).not.toContain('onHandler'); + }); + it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { const app = new Hono(); patchAppUse(app); diff --git a/packages/hono/test/shared/patchRoute.test.ts b/packages/hono/test/shared/patchRoute.test.ts new file mode 100644 index 000000000000..d9dd4d6795ad --- /dev/null +++ b/packages/hono/test/shared/patchRoute.test.ts @@ -0,0 +1,141 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { patchRoute } from '../../src/shared/patchRoute'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: vi.fn((_opts: unknown) => ({ + setStatus: vi.fn(), + end: vi.fn(), + })), + }; +}); + +const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; + +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + +describe('patchRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + honoBaseProto.route = originalRoute; + }); + + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + + it('is a no-op when honoBaseProto.route is not a function', () => { + const fakeApp = Object.create({ notRoute: () => {} }) as Hono; + // Should not throw even when the expected method shape is missing + expect(() => patchRoute(fakeApp)).not.toThrow(); + expect(honoBaseProto.route).toBe(originalRoute); + }); + + describe('wrapSubAppMiddleware', () => { + it('does nothing when a sub-app has an empty routes array', async () => { + const app = new Hono(); + patchRoute(app); + + const emptySubApp = new Hono(); + // routes is an empty array — nothing to wrap, nothing should throw + app.route('/empty', emptySubApp); + + const res = await app.fetch(new Request('http://localhost/empty')); + expect(res.status).toBe(404); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('skips route entries whose handler is not a function', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + subApp.get('/resource', () => new Response('ok')); + + // Corrupt one handler to a non-function to simulate unexpected route shapes + (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; + + // Should not throw when iterating over the corrupted routes + expect(() => app.route('/api', subApp)).not.toThrow(); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same path with different HTTP methods as separate groups', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + // Each of these is the sole (last) handler for its method+path group, + // so none should be wrapped as middleware. + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); + await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same HTTP method with different paths as separate groups', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + // Each is the sole handler for its own method+path group — neither is middleware. + subApp.get('/alpha', async function alphaHandler() { + return new Response('alpha'); + }); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { + const app = new Hono(); + patchRoute(app); + + const subApp = new Hono(); + subApp.get( + '/alpha', + async function alphaMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function alphaHandler() { + return new Response('alpha'); + }, + ); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toHaveLength(1); + expect(spanNames).toContain('alphaMw'); + expect(spanNames).not.toContain('alphaHandler'); + expect(spanNames).not.toContain('betaHandler'); + }); + }); +}); From 50aa0859b3a188d34d0317dab3ad57f2140f02fe Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 12:42:31 +0200 Subject: [PATCH 75/77] test(node): Unflake postgres tests (#20593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/getsentry/sentry-javascript/issues/20553 The clanker identified this as possible problem here: > docker compose up --wait waits on the container healthcheck (pg_isready inside the DB). On busy CI, localhost:5444 can still refuse connections briefly afterward, so the scenario could hit Postgres before it was reachable from the host and fail or behave inconsistently → flaky CJS (and the same class of failure for the other scenarios). --- .../postgresjs/scenario-requestHook.js | 2 ++ .../postgresjs/scenario-requestHook.mjs | 5 +++++ .../tracing/postgresjs/scenario-unsafe.cjs | 2 ++ .../tracing/postgresjs/scenario-unsafe.mjs | 5 +++++ .../tracing/postgresjs/scenario-url.cjs | 2 ++ .../tracing/postgresjs/scenario-url.mjs | 5 +++++ .../suites/tracing/postgresjs/scenario.js | 2 ++ .../suites/tracing/postgresjs/scenario.mjs | 5 +++++ .../tracing/postgresjs/wait-for-postgres.js | 22 +++++++++++++++++++ 9 files changed, 50 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js index a2b405d71f60..cd5303c3de8a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +15,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs index f6e69354ccbc..c54fe084a1f6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +18,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs index 0ee537052a4a..6ceab6f9ec3b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs @@ -10,6 +10,7 @@ Sentry.init({ // Import postgres AFTER Sentry.init() so instrumentation is set up const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -25,6 +26,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); // Test sql.unsafe() - this was not being instrumented before the fix await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs index 9d2e7de99e51..8f0d5070b829 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -15,6 +19,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); // Test sql.unsafe() - this was not being instrumented before the fix await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs index 1a5cc93e2261..fbda092cad28 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs @@ -10,6 +10,7 @@ Sentry.init({ // Import postgres AFTER Sentry.init() so instrumentation is set up const postgres = require('postgres'); +const { waitForPostgres } = require('./wait-for-postgres.js'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -25,6 +26,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs index 2694bca96569..7edc0a6590a7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -15,6 +19,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js index d9049353f6eb..373da6082d2e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js @@ -1,5 +1,6 @@ const { loggingTransport } = require('@sentry-internal/node-integration-tests'); const Sentry = require('@sentry/node'); +const { waitForPostgres } = require('./wait-for-postgres.js'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', @@ -23,6 +24,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs index 7d62c8d52dde..78536a82e3f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs @@ -1,6 +1,10 @@ import * as Sentry from '@sentry/node'; +import { createRequire } from 'node:module'; import postgres from 'postgres'; +const require = createRequire(import.meta.url); +const { waitForPostgres } = require('./wait-for-postgres.js'); + // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); @@ -14,6 +18,7 @@ async function run() { }, async () => { try { + await waitForPostgres(sql); await sql` CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js new file mode 100644 index 000000000000..c8c10c6eeb80 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/wait-for-postgres.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * Retries until Postgres accepts connections. `docker compose up --wait` can report healthy + * before the port forward on the host is ready (flaky on busy CI). + */ +async function waitForPostgres(sql, maxWaitMs = 60_000) { + const deadline = Date.now() + maxWaitMs; + for (;;) { + try { + await sql`SELECT 1`; + return; + } catch { + if (Date.now() > deadline) { + throw new Error('Timed out waiting for Postgres to accept connections'); + } + await new Promise(r => setTimeout(r, 250)); + } + } +} + +module.exports = { waitForPostgres }; From bea1aad42277db894d5a299bfec3cdd633d6baf0 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 29 Apr 2026 12:42:47 +0200 Subject: [PATCH 76/77] test(browser): Unflake some more tests (#20591) Found some other places in browser-integration-tests that could theoretically be flaky due to when we start listening for events vs. when we navigate. --- .../profiling/traceLifecycleMode_overlapping-spans/test.ts | 7 +++---- .../suites/public-api/startSpan/error-sync/test.ts | 4 ++-- .../suites/tracing/browserTracingIntegration/error/test.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index e05685ca4868..de4bddd69f57 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -39,16 +39,15 @@ sentryTest( } const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); - await page.goto(url); - const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( page, 1, - { envelopeType: 'profile_chunk' }, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, properFullEnvelopeRequestParser, ); - const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; const envelopeItemHeader = profileChunkEnvelopeItem[0]; const envelopeItemPayload = profileChunkEnvelopeItem[1]; diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts index dfb1098bc650..9880598b9294 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/error-sync/test.ts @@ -21,10 +21,10 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - const errorEventsPromise = getMultipleSentryEnvelopeRequests(page, 2); + await page.goto(url); + await runScriptInSandbox(page, { content: ` function run() { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts index fe48966ddd6e..bf54209f053a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/error/test.ts @@ -21,10 +21,10 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - const errorEventsPromise = getMultipleSentryEnvelopeRequests(page, 2); + await page.goto(url); + await runScriptInSandbox(page, { content: ` throw new Error('Error during pageload'); From 3be99a9afa77e49578e6839e4b32f97fb04fb0f8 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:51:34 +0200 Subject: [PATCH 77/77] meta(changelog): Update changelog for 10.51.0 --- CHANGELOG.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e8740b73cd..face72452b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,124 @@ ## Unreleased -- **feat(nitro): Add `@sentry/nitro` SDK** +- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott + +## 10.51.0 + +### Important Changes + +- **feat(cloudflare): Add trace propagation for RPC method calls ([#20343](https://github.com/getsentry/sentry-javascript/pull/20343))** + + Trace context is now propagated across Cloudflare Workers RPC calls, connecting traces between Workers and Durable Objects. + This feature is opt-in and requires setting `enableRpcTracePropagation: true` in your SDK configuration: + + ```ts + // Worker + export default Sentry.withSentry( + env => ({ + dsn: env.SENTRY_DSN, + enableRpcTracePropagation: true, + }), + handler, + ); + + // Durable Object + export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + env => ({ + dsn: env.SENTRY_DSN, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, + ); + ``` + +- **feat(hono)!: Change setup for `@sentry/hono/node` (`init` in external file) ([#20497](https://github.com/getsentry/sentry-javascript/pull/20497))** + + To improve Node.js instrumentation, the `sentry()` middleware exported from `@sentry/hono/node` no longer accepts configuration options. + Instead, you must configure the SDK by calling `Sentry.init()` in a dedicated instrumentation file that runs before your application code (read more in the [Hono SDK readme](https://github.com/getsentry/sentry-javascript/blob/develop/packages/hono/README.md): + + ```ts + // instrument.mjs (or instrument.ts) + import * as Sentry from '@sentry/hono/node'; + + Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, + }); + ``` + +- **feat(nitro): Add `@sentry/nitro` SDK ([#19224](https://github.com/getsentry/sentry-javascript/pull/19224))** A new `@sentry/nitro` package provides first-class Sentry support for [Nitro](https://nitro.build/) applications, with HTTP handler and error instrumentation, middleware tracing, request isolation, and build-time source map uploading via `withSentryConfig`. Read more in the [Nitro SDK docs](https://docs.sentry.io/platforms/javascript/guides/nitro/) and the [Nitro SDK readme](https://github.com/getsentry/sentry-javascript/blob/develop/packages/nitro/README.md). +### Other Changes + +- deps(minimatch): Upgrade patch version to use new `brace-expansion` peer-dep ([#20198](https://github.com/getsentry/sentry-javascript/pull/20198)) +- docs: Add deprecation notices to `bin` scripts ([#20570](https://github.com/getsentry/sentry-javascript/pull/20570)) +- feat(astro): Drop prerendered http.server filter via `ignoreSpans` ([#20513](https://github.com/getsentry/sentry-javascript/pull/20513)) +- feat(aws-serverless): Validate extension tunnel DSN against `SENTRY_DSN` ([#20528](https://github.com/getsentry/sentry-javascript/pull/20528)) +- feat(browser): Add `ingest_settings` to span v2 envelope payload ([#20411](https://github.com/getsentry/sentry-javascript/pull/20411)) +- feat(browser): Add support for streamed spans in `httpContextIntegration` ([#20464](https://github.com/getsentry/sentry-javascript/pull/20464)) +- feat(core): Backfill otel attributes on streamed spans ([#20439](https://github.com/getsentry/sentry-javascript/pull/20439)) +- feat(core): clear up integrations on dispose ([#20407](https://github.com/getsentry/sentry-javascript/pull/20407)) +- feat(core): Instrument langgraph createReactAgent ([#20344](https://github.com/getsentry/sentry-javascript/pull/20344)) +- feat(core): Support attribute matching in `ignoreSpans` ([#20512](https://github.com/getsentry/sentry-javascript/pull/20512)) +- feat(feedback): allow error messages to be customized ([#20474](https://github.com/getsentry/sentry-javascript/pull/20474)) +- feat(hono): Support middleware spans defined in app groups ([#20465](https://github.com/getsentry/sentry-javascript/pull/20465)) +- feat(nextjs): Filter unwanted segments when span streaming is enabled ([#20384](https://github.com/getsentry/sentry-javascript/pull/20384)) +- feat(nextjs): Migrate edge event processors to span-first APIs ([#20551](https://github.com/getsentry/sentry-javascript/pull/20551)) +- feat(nextjs): Migrate server event processors to span-first APIs ([#20527](https://github.com/getsentry/sentry-javascript/pull/20527)) +- feat(nextjs): Set global attribute for turbopack usage ([#20558](https://github.com/getsentry/sentry-javascript/pull/20558)) +- feat(nitro): Nitro SDK ([#19224](https://github.com/getsentry/sentry-javascript/pull/19224)) +- feat(react-router): Clean up bogus `*` http.route attribute on segment spans ([#20471](https://github.com/getsentry/sentry-javascript/pull/20471)) +- feat(react-router): Drop low-quality transactions via `ignoreSpans` ([#20514](https://github.com/getsentry/sentry-javascript/pull/20514)) +- feat(sveltekit): Support span streaming in `svelteKitSpansEnhancement` integration ([#20496](https://github.com/getsentry/sentry-javascript/pull/20496)) +- feat(tanstackstart-react): Add dynamic tunnel route helper and generator ([#20264](https://github.com/getsentry/sentry-javascript/pull/20264)) +- fix: update prisma v7 spans descriptions ([#20456](https://github.com/getsentry/sentry-javascript/pull/20456)) +- fix(core): Avoid parse-time SyntaxError on Safari <16.4 in postgresjs ([#20498](https://github.com/getsentry/sentry-javascript/pull/20498)) +- fix(core): Ensure `isSentryRequest` handles subdomains properly ([#20530](https://github.com/getsentry/sentry-javascript/pull/20530)) +- fix(core): Ensure ip address headers are stripped when lower case ([#20484](https://github.com/getsentry/sentry-javascript/pull/20484)) +- fix(core): Filter more cookie names for PII ([#20485](https://github.com/getsentry/sentry-javascript/pull/20485)) +- fix(core): Use symbol for normalization checks ([#20486](https://github.com/getsentry/sentry-javascript/pull/20486)) +- fix(hono): Distinguish `.use()` middleware in sub-apps from `.all()` handlers ([#20554](https://github.com/getsentry/sentry-javascript/pull/20554)) +- fix(nextjs): Ensure we do not match tunnel endpoints too broadly ([#20488](https://github.com/getsentry/sentry-javascript/pull/20488)) +- fix(opentelemetry): Add conditional browser export to avoid node deps ([#20556](https://github.com/getsentry/sentry-javascript/pull/20556)) +- fix(replay): Avoid main-thread blocking in WorkerHandler under event bursts ([#20548](https://github.com/getsentry/sentry-javascript/pull/20548)) +- fix(replay): Ensure `maskAttributes` works with `maskAllText=false` ([#20491](https://github.com/getsentry/sentry-javascript/pull/20491)) +- fix(supabase): Consider `sendDefaultPii` for supabase integration ([#20490](https://github.com/getsentry/sentry-javascript/pull/20490)) + +
+ Internal Changes + +- chore: Add size limit reports on PRs for Cloudflare ([#20055](https://github.com/getsentry/sentry-javascript/pull/20055)) +- chore: Update CODEOWNERS ([#20559](https://github.com/getsentry/sentry-javascript/pull/20559)) +- chore(build): Opt-out of nx analytics ([#20487](https://github.com/getsentry/sentry-javascript/pull/20487)) +- chore(ci): Automatically bump size limit every week ([#20531](https://github.com/getsentry/sentry-javascript/pull/20531)) +- chore(ci): Bump pnpm/action-setup to v5 and pin to commit SHA ([#20462](https://github.com/getsentry/sentry-javascript/pull/20462)) +- chore(ci): Do not report flaky test issues if we cannot find a test name ([#20589](https://github.com/getsentry/sentry-javascript/pull/20589)) +- chore(ci): Streamline CI setup to split bundle, layer, tarball generation ([#20396](https://github.com/getsentry/sentry-javascript/pull/20396)) +- chore(ci): Vendor nx-affected-list action, drop dkhunt27 dependency ([#20463](https://github.com/getsentry/sentry-javascript/pull/20463)) +- chore(e2e): Add vue and vue-router to nuxt-4 canary build step to fix rollup resolution ([#20519](https://github.com/getsentry/sentry-javascript/pull/20519)) +- chore(e2e): Remove @tanstack/start-plugin-core override ([#20518](https://github.com/getsentry/sentry-javascript/pull/20518)) +- chore(size-limit): weekly auto-bump ([#20572](https://github.com/getsentry/sentry-javascript/pull/20572)) +- chore(skill): Add skill for writing unit and E2E tests ([#20561](https://github.com/getsentry/sentry-javascript/pull/20561)) +- chore(test): Reduce unneeded `idleTimeout` test config ([#20467](https://github.com/getsentry/sentry-javascript/pull/20467)) +- ci(size-bump): Fix path in size-limit auto-bump workflow ([#20566](https://github.com/getsentry/sentry-javascript/pull/20566)) +- fix(e2e/tanstackstart-react): pin @tanstack/start-plugin-core to unblock CI ([#20482](https://github.com/getsentry/sentry-javascript/pull/20482)) +- fix(tests): Remove nitro canary test job ([#20473](https://github.com/getsentry/sentry-javascript/pull/20473)) +- ref(browser): Use `safeSetSpanJSONAttributes` in cultureContext integration ([#20481](https://github.com/getsentry/sentry-javascript/pull/20481)) +- test(browser): Unflake some more tests ([#20591](https://github.com/getsentry/sentry-javascript/pull/20591)) +- test(nextjs): Pin `eslint-config-next` package to major ([#20552](https://github.com/getsentry/sentry-javascript/pull/20552)) +- test(node): Fix flaky ANR test ([#20592](https://github.com/getsentry/sentry-javascript/pull/20592)) +- test(node): Fix flaky worker thread integration test ([#20588](https://github.com/getsentry/sentry-javascript/pull/20588)) +- test(node): Unflake postgres tests ([#20593](https://github.com/getsentry/sentry-javascript/pull/20593)) +- test(node): Update timeout for cron integration tests ([#20586](https://github.com/getsentry/sentry-javascript/pull/20586)) +- test(supabase): Stop supabase before initializing ([#20563](https://github.com/getsentry/sentry-javascript/pull/20563)) +- test(tanstack): Prefix test labels ([#20569](https://github.com/getsentry/sentry-javascript/pull/20569)) + +
+ ## 10.50.0 ### Important Changes