From b5cbc9ca1800e1b4ee1de66e135a90891cecd570 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 13:12:59 +0200 Subject: [PATCH 1/5] chore(deps): Bump next from 15.5.15 to 15.5.18 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl (#20821) Bumps [next](https://github.com/vercel/next.js) from 15.5.15 to 15.5.18.
Release notes

Sourced from next's releases.

v15.5.18

This release contains security fixes for the following advisories:

High:

Moderate:

Low:

v15.5.16

This release contains security fixes for the following advisories:

High:

Moderate:

Low:

Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for next since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=15.5.15&new-version=15.5.18)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/nextjs-15-intl/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index fc3c7f813b5f..d935f67fa39e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.15", + "next": "15.5.18", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", From ad47c3c3de5b2bacfbbd08bcdf9cd90184ce64bc Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 12 May 2026 15:29:46 +0200 Subject: [PATCH 2/5] ref(hono): Consolidate route patching and add clarification comments (#20829) A little bit of refactoring to combine all tests into `applyPatches.test.ts` and make a better distinction between prototype patching and patching of class fields (also described better in the comments). This will be important for the upcoming changes for instrumenting `.request()` calls. Part of https://github.com/getsentry/sentry-javascript/issues/20807 --- packages/hono/src/shared/applyPatches.ts | 14 +- .../hono/src/shared/middlewareHandlers.ts | 2 + packages/hono/src/shared/patchAppUse.ts | 4 +- packages/hono/src/shared/patchRoute.ts | 34 +- .../hono/test/shared/applyPatches.test.ts | 394 ++++++++++++++++++ packages/hono/test/shared/patchAppUse.test.ts | 309 +------------- packages/hono/test/shared/patchRoute.test.ts | 141 ------- 7 files changed, 432 insertions(+), 466 deletions(-) create mode 100644 packages/hono/test/shared/applyPatches.test.ts delete mode 100644 packages/hono/test/shared/patchRoute.test.ts diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/hono/src/shared/applyPatches.ts index 1b694ca7cfa5..9ad70c1dd62a 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/hono/src/shared/applyPatches.ts @@ -1,14 +1,18 @@ import type { Env, Hono } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; -import { patchRoute } from '../shared/patchRoute'; +import { patchAppUse } from './patchAppUse'; +import { installRouteHookOnPrototype } from './patchRoute'; /** - * Applies necessary patches to the Hono app to ensure that Sentry can properly trace middleware and route handlers. + * Instruments a Hono app instance for Sentry tracing in middleware and route handlers. + * + * Two strategies are needed because Hono mixes instance fields and prototype methods: + * - `use` is a per-instance class field (instance own property) → must be patched on the instance + * - `route` is a prototype method → patched once globally, covers all instances */ 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); + // `route()` lives on the shared prototype and is patched once globally. + installRouteHookOnPrototype(); } diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 03bb6e16da58..7f961928df49 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -43,6 +43,8 @@ export function responseHandler(context: Context): void { function updateSpanRouteName(isolationScope: Scope, context: Context): void { const activeSpan = getActiveSpan(); + + // Final matched route: https://hono.dev/docs/helpers/route#using-with-index-parameter const lastMatchedRoute = routePath(context, -1); if (activeSpan) { diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts index c0d620692278..8a6c4f46c40a 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/hono/src/shared/patchAppUse.ts @@ -2,7 +2,9 @@ import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { Env, Hono, MiddlewareHandler } from 'hono'; /** - * Patches the Hono app so that middleware is automatically traced as Sentry spans. + * Patches `app.use` (instance own property) on a Hono instance to instrument middleware at registration time. + * + * Must be per-instance because `use` is a class field, not a prototype method. */ export function patchAppUse(app: Hono): void { app.use = new Proxy(app.use, { diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts index d3f732e30793..bdc1822dad4e 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/hono/src/shared/patchRoute.ts @@ -1,6 +1,7 @@ import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; +import type { Hono, MiddlewareHandler } from 'hono'; +import { Hono as HonoClass } from 'hono'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; interface HonoRoute { @@ -15,18 +16,20 @@ interface HonoBaseProto { } /** - * 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. + * Patches `route()` on the Hono base prototype once, globally. * - * `route` lives on the prototype (unlike `use` which is a class field) + * Wraps sub-app middleware at mount time so that `app.route('/prefix', subApp)` is traced. + * Idempotent: safe to call multiple times. */ -export function patchRoute(app: Hono): void { - const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(app)) as HonoBaseProto; +export function installRouteHookOnPrototype(): void { + // `route` is on the base prototype, not the concrete subclass, walk up one level + const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; if (!honoBaseProto || typeof honoBaseProto?.route !== 'function') { return; } - if (getOriginalFunction(honoBaseProto.route as WrappedFunction)) { + // Already patched: return + if (getOriginalFunction(honoBaseProto.route as unknown as WrappedFunction)) { return; } @@ -45,18 +48,13 @@ export function patchRoute(app: Hono): void { } /** - * Figures out which handlers in a sub-app's flat routes array are middleware (and should get a span), then wraps them. + * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. * - * 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)`. + * Heuristics (since Hono has no "isMiddleware" flag): + * 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path. + * All but the LAST are middleware (they call `next()`). + * 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next), + * while `.all()` route handlers typically have 1 (`context` only). * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts new file mode 100644 index 000000000000..f5ea5a27cbc6 --- /dev/null +++ b/packages/hono/test/shared/applyPatches.test.ts @@ -0,0 +1,394 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applyPatches } from '../../src/shared/applyPatches'; + +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('applyPatches', () => { + beforeEach(() => { + vi.clearAllMocks(); + honoBaseProto.route = originalRoute; + }); + + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + + describe('wrapSubAppMiddleware', () => { + it('does nothing when a sub-app has an empty routes array', async () => { + const app = new Hono(); + applyPatches(app); + + const emptySubApp = new Hono(); + 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(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', () => new Response('ok')); + + (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; + + 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(); + applyPatches(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', { 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(); + applyPatches(app); + + const subApp = new Hono(); + 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(); + applyPatches(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'); + }); + }); + + describe('route() patching', () => { + it('wraps middleware on sub-apps mounted via route()', async () => { + const app = new Hono(); + applyPatches(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' })); + }); + + it('does not wrap sole route handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(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(); + applyPatches(app); + + app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + app.get('/', () => new Response('ok')); + + 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 applyPatches is called multiple times', () => { + const app1 = new Hono(); + applyPatches(app1); + + const patchedRoute = honoBaseProto.route; + + const app2 = new Hono(); + applyPatches(app2); + + expect(honoBaseProto.route).toBe(patchedRoute); + }); + + it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { + const app = new Hono(); + applyPatches(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(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/admin/dashboard', () => new Response('dashboard')); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/admin/dashboard')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); + }); + + it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { + const app = new Hono(); + applyPatches(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).not.toHaveBeenCalled(); + }); + + it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { + const app = new Hono(); + applyPatches(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).not.toContain('allRoute'); + }); + + it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(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'); + }); + 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')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { + const app = new Hono(); + applyPatches(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(); + applyPatches(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(); + applyPatches(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(); + applyPatches(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(); + applyPatches(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); + + await app.fetch(new Request('http://localhost/sub/a/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); + + startInactiveSpanMock.mockClear(); + + await app.fetch(new Request('http://localhost/sub/b/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); + }); + }); +}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index ee376127baaa..a65c9c526a83 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,8 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { 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'); @@ -19,18 +18,11 @@ 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); @@ -84,7 +76,7 @@ describe('patchAppUse (middleware spans)', () => { await app.fetch(new Request('http://localhost/')); expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - const name = startInactiveSpanMock.mock.calls[0][0].name; + const name = startInactiveSpanMock.mock.calls[0]![0].name; expect(name).toMatch(''); }); }); @@ -135,12 +127,12 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).toHaveBeenCalledTimes(3); const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls; - expect(firstCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(secondCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(firstCall[0].name).toMatch(''); - expect(secondCall[0].name).toBe('namedMiddleware'); - expect(thirdCall[0].name).toBe(''); - expect(firstCall[0].name).not.toBe(secondCall[0].name); + expect(firstCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(secondCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(firstCall![0].name).toMatch(''); + expect(secondCall![0].name).toBe('namedMiddleware'); + expect(thirdCall![0].name).toBe(''); + expect(firstCall![0].name).not.toBe(secondCall![0].name); }); it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { @@ -163,289 +155,4 @@ describe('patchAppUse (middleware spans)', () => { expect(fakeApp._capturedThis).toBe(fakeApp); }); - - describe('route() patching (sub-app / route group support)', () => { - beforeEach(() => { - honoBaseProto.route = originalRoute; - }); - - 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' })); - }); - - it('does not wrap sole route handlers on sub-apps', 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')); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/admin/dashboard')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); - }); - - 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); - - 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).not.toHaveBeenCalled(); - }); - - it('wraps .use() middleware but not .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).not.toContain('allRoute'); - }); - - 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); - - const subApp = new Hono(); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - 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')); - - 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); - 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' })); - }); - }); }); diff --git a/packages/hono/test/shared/patchRoute.test.ts b/packages/hono/test/shared/patchRoute.test.ts deleted file mode 100644 index d9dd4d6795ad..000000000000 --- a/packages/hono/test/shared/patchRoute.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 6a7d179ad38c7591021c88e4bd3ec82b3c6cc606 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 12 May 2026 16:01:03 +0200 Subject: [PATCH 3/5] fix(core): Don't gate user data for streamed spans at scope read time (#20827) User data should be gated at write time where it is put on the scope. If data makes it onto the scope we should not gate anymore so that if a user explicitly calls for instance `Sentry.setUser()` the data is set on the span (which is expected since the user made an explicit decision to include this). Closes https://github.com/getsentry/sentry-javascript/issues/20825 --- packages/core/src/semanticAttributes.ts | 8 +- .../core/src/tracing/spans/captureSpan.ts | 14 +- .../lib/tracing/spans/captureSpan.test.ts | 273 +++++++----------- 3 files changed, 115 insertions(+), 180 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 02b6a4ec08a6..9eeed6378a5f 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -53,13 +53,13 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; /** The version of the Sentry SDK */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; -/** The user ID (gated by sendDefaultPii) */ +/** The user ID */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; -/** The user email (gated by sendDefaultPii) */ +/** The user email */ export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; -/** The user IP address (gated by sendDefaultPii) */ +/** The user IP address */ export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; -/** The user username (gated by sendDefaultPii) */ +/** The user username */ export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; /** diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index bed3f1790740..afbc8ad60358 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -125,7 +125,7 @@ function applyCommonSpanAttributes( scopeData: ScopeData, ): void { const sdk = client.getSdkMetadata(); - const { release, environment, sendDefaultPii } = client.getOptions(); + const { release, environment } = client.getOptions(); // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) safeSetSpanJSONAttributes(spanJSON, { @@ -135,14 +135,10 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, - ...(sendDefaultPii - ? { - [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - } - : {}), + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, ...scopeData.attributes, }); } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 186f7f23a536..524f3287e82c 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -25,179 +25,102 @@ import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../. import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { - it('captures user attributes iff sendDefaultPii is true', () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - release: '1.0.0', - environment: 'staging', - sendDefaultPii: true, - }), - ); - - const span = withScope(scope => { - scope.setClient(client); - scope.setUser({ - id: '123', - email: 'user@example.com', - username: 'testuser', - ip_address: '127.0.0.1', - }); - - const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); - span.end(); - - return span; - }); - - const serializedSpan = captureSpan(span, client); + it.each([true, false, undefined])( + 'always applies scope user attributes to spans (sendDefaultPii: %s)', + sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); - expect(serializedSpan).toStrictEqual({ - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - parent_span_id: undefined, - links: undefined, - start_timestamp: expect.any(Number), - name: 'my-span', - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'http.client', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { - type: 'string', - value: 'manual', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { - type: 'integer', - value: 1, - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { - value: 'my-span', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { - value: span.spanContext().spanId, - type: 'string', - }, - 'sentry.span.source': { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { - value: '1.0.0', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { - value: 'staging', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { - value: '123', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { - value: 'user@example.com', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { - value: 'testuser', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { - value: '127.0.0.1', - type: 'string', - }, - }, - _segmentSpan: span, - }); - }); + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); - it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - release: '1.0.0', - environment: 'staging', - sendDefaultPii, - }), - ); + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); - const span = withScope(scope => { - scope.setClient(client); - scope.setUser({ - id: '123', - email: 'user@example.com', - username: 'testuser', - ip_address: '127.0.0.1', + return span; }); - const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); - span.end(); - - return span; - }); - - expect(captureSpan(span, client)).toStrictEqual({ - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - parent_span_id: undefined, - links: undefined, - start_timestamp: expect.any(Number), - name: 'my-span', - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'http.client', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { - type: 'string', - value: 'manual', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { - type: 'integer', - value: 1, - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { - value: 'my-span', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { - value: span.spanContext().spanId, - type: 'string', - }, - 'sentry.span.source': { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { - value: '1.0.0', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { - value: 'staging', - type: 'string', + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, }, - }, - _segmentSpan: span, - }); - }); + _segmentSpan: span, + }); + }, + ); it('captures sdk name and version if available', () => { const client = new TestClient( @@ -286,6 +209,22 @@ describe('captureSpan', () => { value: '1.0.0', type: 'string', }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, }, _segmentSpan: span, }); From 588100986580e0f5c8c3204661e59e5103e7d269 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 12 May 2026 16:07:23 +0200 Subject: [PATCH 4/5] fix(core): Include subpath type shims in published package (#20835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `browser.d.ts` and `server.d.ts` to the `files` list in `@sentry/core` `package.json` so they are included in the published npm tarball - Adds `typesVersions` entries for `browser` and `server` subpaths to support TypeScript < 5.0 ## Context PR [#20435](https://github.com/getsentry/sentry-javascript/pull/20435) introduced `@sentry/core/browser` and `@sentry/core/server` subpath exports and added root-level `.d.ts` shim files for compatibility with TypeScript compilers that don't support the `exports` field (e.g. `moduleResolution: "node"`). However, the `.d.ts` shims were not added to the `files` list, so they were excluded from the published `10.53.0` tarball. This breaks downstream consumers like `@sentry/react-native` ([getsentry/sentry-react-native#6139](https://github.com/getsentry/sentry-react-native/pull/6139)) — their TS compiler can't resolve `@sentry/core/browser`, causing `BaseTransportOptions` to become unresolvable and `ReactNativeTransportOptions` to fail the type constraint check: ``` error TS2344: Type 'ReactNativeTransportOptions' does not satisfy the constraint 'BaseTransportOptions'. Type 'ReactNativeTransportOptions' is missing the following properties from type 'BaseTransportOptions': url, recordDroppedEvent ``` ## Test plan - [ ] Verify `npm pack --dry-run` includes `browser.d.ts` and `server.d.ts` at the package root - [ ] Verify `@sentry/react-native` builds successfully against a patched `@sentry/core` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 --- packages/core/browser.d.ts | 4 ++++ packages/core/package.json | 10 +++++++++- packages/core/server.d.ts | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/core/browser.d.ts create mode 100644 packages/core/server.d.ts diff --git a/packages/core/browser.d.ts b/packages/core/browser.d.ts new file mode 100644 index 000000000000..752c159c689d --- /dev/null +++ b/packages/core/browser.d.ts @@ -0,0 +1,4 @@ +// This file is a compatibility shim for TypeScript compilers that do not +// support the package.json `exports` field for resolving subpath exports. +// Note: `typesVersions` in package.json may redirect this to the downleveled variant. +export * from './build/types/browser'; diff --git a/packages/core/package.json b/packages/core/package.json index 2c24d45a3a1a..2aa3e941176a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,7 +12,9 @@ "files": [ "/build", "browser.js", - "server.js" + "browser.d.ts", + "server.js", + "server.d.ts" ], "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -54,6 +56,12 @@ "<5.0": { "build/types/index.d.ts": [ "build/types-ts3.8/index.d.ts" + ], + "browser": [ + "build/types-ts3.8/browser.d.ts" + ], + "server": [ + "build/types-ts3.8/server.d.ts" ] } }, diff --git a/packages/core/server.d.ts b/packages/core/server.d.ts new file mode 100644 index 000000000000..7be138fe726e --- /dev/null +++ b/packages/core/server.d.ts @@ -0,0 +1,4 @@ +// This file is a compatibility shim for TypeScript compilers that do not +// support the package.json `exports` field for resolving subpath exports. +// Note: `typesVersions` in package.json may redirect this to the downleveled variant. +export * from './build/types/server'; From df8fd3863043f143961a5d96e79a717d62eada31 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 12 May 2026 16:09:43 +0200 Subject: [PATCH 5/5] meta(changelog): Update changelog for 10.53.1 Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1bac4c474d..a0b161d35104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.53.1 + +- fix(core): Don't gate user data for streamed spans at scope read time ([#20827](https://github.com/getsentry/sentry-javascript/pull/20827)) +- fix(core): Include subpath type shims in published package ([#20835](https://github.com/getsentry/sentry-javascript/pull/20835)) +- ref(hono): Consolidate route patching and add clarification comments ([#20829](https://github.com/getsentry/sentry-javascript/pull/20829)) + +
+ Internal Changes + +- chore(deps): Bump next from 15.5.15 to 15.5.18 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl ([#20821](https://github.com/getsentry/sentry-javascript/pull/20821)) + +
+ ## 10.53.0 ### Important Changes