diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx index 567edfe4e032..97260755da21 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx @@ -1,68 +1,19 @@ -import { PassThrough } from 'node:stream'; - import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; -import { isbot } from 'isbot'; -import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; -import type { AppLoadContext, EntryContext } from 'react-router'; import { ServerRouter } from 'react-router'; -const ABORT_DELAY = 5_000; - -function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, - loadContext: AppLoadContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - let userAgent = request.headers.get('user-agent'); - - // Ensure requests from bots and SPA Mode renders wait for all content to load before responding - // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation - let readyOption: keyof RenderToPipeableStreamOptions = - (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; - - const { pipe, abort } = renderToPipeableStream(, { - [readyOption]() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }); +import { type HandleErrorFunction } from 'react-router'; - setTimeout(abort, ABORT_DELAY); - }); -} +const ABORT_DELAY = 5_000; -export default Sentry.sentryHandleRequest(handleRequest); +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); -import { type HandleErrorFunction } from 'react-router'; +export default handleRequest; export const handleError: HandleErrorFunction = (error, { request }) => { // React Router may abort some interrupted requests, don't log those diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..6a9623171236 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET *'; + }); + + const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + }); +}); diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx new file mode 100644 index 000000000000..662d0b14a93a --- /dev/null +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { getMetaTagTransformer, wrapSentryHandleRequest } from './wrapSentryHandleRequest'; +import type { createReadableStreamFromReadable } from '@react-router/node'; +import { PassThrough } from 'stream'; + +type RenderToPipeableStreamOptions = { + [key: string]: unknown; + onShellReady?: () => void; + onAllReady?: () => void; + onShellError?: (error: unknown) => void; + onError?: (error: unknown) => void; +}; + +type RenderToPipeableStreamResult = { + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; +}; + +type RenderToPipeableStreamFunction = ( + node: ReactNode, + options: RenderToPipeableStreamOptions, +) => RenderToPipeableStreamResult; + +export interface SentryHandleRequestOptions { + /** + * Timeout in milliseconds after which the rendering stream will be aborted + * @default 10000 + */ + streamTimeout?: number; + + /** + * React's renderToPipeableStream function from 'react-dom/server' + */ + renderToPipeableStream: RenderToPipeableStreamFunction; + + /** + * The component from '@react-router/server' + */ + ServerRouter: typeof ServerRouter; + + /** + * createReadableStreamFromReadable from '@react-router/node' + */ + createReadableStreamFromReadable: typeof createReadableStreamFromReadable; + + /** + * Regular expression to identify bot user agents + * @default /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i + */ + botRegex?: RegExp; +} + +/** + * A complete Sentry-instrumented handleRequest implementation that handles both + * route parametrization and trace meta tag injection. + * + * @param options Configuration options + * @returns A Sentry-instrumented handleRequest function + */ +export function createSentryHandleRequest( + options: SentryHandleRequestOptions, +): ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) => Promise { + const { + streamTimeout = 10000, + renderToPipeableStream, + ServerRouter, + createReadableStreamFromReadable, + botRegex = /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i, + } = options; + + const handleRequest = function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext, + ): Promise { + return new Promise((resolve, reject) => { + let shellRendered = false; + const userAgent = request.headers.get('user-agent'); + + // Determine if we should use onAllReady or onShellReady + const isBot = typeof userAgent === 'string' && botRegex.test(userAgent); + const isSpaMode = !!(routerContext as { isSpaMode?: boolean }).isSpaMode; + + const readyOption = isBot || isSpaMode ? 'onAllReady' : 'onShellReady'; + + const { pipe, abort } = renderToPipeableStream(, { + [readyOption]() { + shellRendered = true; + const body = new PassThrough(); + + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + // this injects trace data to the HTML head + pipe(getMetaTagTransformer(body)); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + // eslint-disable-next-line no-param-reassign + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + // eslint-disable-next-line no-console + console.error(error); + } + }, + }); + + // Abort the rendering stream after the `streamTimeout` + setTimeout(abort, streamTimeout); + }); + }; + + // Wrap the handle request function for request parametrization + return wrapSentryHandleRequest(handleRequest); +} diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 44acfec7d4f2..67436582aedd 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -1,4 +1,6 @@ export * from '@sentry/node'; export { init } from './sdk'; -export { sentryHandleRequest } from './sentryHandleRequest'; +// eslint-disable-next-line deprecation/deprecation +export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest'; +export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; diff --git a/packages/react-router/src/server/sentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts similarity index 61% rename from packages/react-router/src/server/sentryHandleRequest.ts rename to packages/react-router/src/server/wrapSentryHandleRequest.ts index 9c5f4abf72e8..bc6cc93122bb 100644 --- a/packages/react-router/src/server/sentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -1,8 +1,10 @@ import { context } from '@opentelemetry/api'; import { RPCType, getRPCMetadata } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core'; import type { AppLoadContext, EntryContext } from 'react-router'; +import type { PassThrough } from 'stream'; +import { Transform } from 'stream'; type OriginalHandleRequest = ( request: Request, @@ -18,7 +20,7 @@ type OriginalHandleRequest = ( * @param originalHandle - The original handleRequest function to wrap * @returns A wrapped version of the handle request function with Sentry instrumentation */ -export function sentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { +export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -47,6 +49,33 @@ export function sentryHandleRequest(originalHandle: OriginalHandleRequest): Orig }); } } + return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); }; } + +/** @deprecated Use `wrapSentryHandleRequest` instead. */ +export const sentryHandleRequest = wrapSentryHandleRequest; + +/** + * Injects Sentry trace meta tags into the HTML response by piping through a transform stream. + * This enables distributed tracing by adding trace context to the HTML document head. + * + * @param body - PassThrough stream containing the HTML response body to modify + */ +export function getMetaTagTransformer(body: PassThrough): Transform { + const headClosingTag = ''; + const htmlMetaTagTransformer = new Transform({ + transform(chunk, _encoding, callback) { + const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk); + if (html.includes(headClosingTag)) { + const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); + callback(null, modifiedHtml); + return; + } + callback(null, chunk); + }, + }); + htmlMetaTagTransformer.pipe(body); + return htmlMetaTagTransformer; +} diff --git a/packages/react-router/test/server/createSentryHandleRequest.test.ts b/packages/react-router/test/server/createSentryHandleRequest.test.ts new file mode 100644 index 000000000000..0db84d19ce16 --- /dev/null +++ b/packages/react-router/test/server/createSentryHandleRequest.test.ts @@ -0,0 +1,340 @@ +/* eslint-disable no-console */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PassThrough } from 'stream'; +import * as wrapSentryHandleRequestModule from '../../src/server/wrapSentryHandleRequest'; +import { createSentryHandleRequest } from '../../src/server/createSentryHandleRequest'; +import type { EntryContext } from 'react-router'; + +vi.mock('../../src/server/wrapSentryHandleRequest', () => ({ + wrapSentryHandleRequest: vi.fn(fn => fn), + getMetaTagTransformer: vi.fn(body => { + const transform = new PassThrough(); + transform.pipe(body); + return transform; + }), +})); + +describe('createSentryHandleRequest', () => { + const mockRenderToPipeableStream = vi.fn(); + const mockServerRouter = vi.fn(); + const mockCreateReadableStreamFromReadable = vi.fn(); + + const mockRequest = { + url: 'https://sentry-example.com/test', + headers: { + get: vi.fn(), + }, + } as unknown as Request; + + let mockResponseHeaders: Headers; + + const mockRouterContext: EntryContext = { + manifest: { + entry: { + imports: [], + module: 'test-module', + }, + routes: {}, + url: '/test', + version: '1.0.0', + }, + routeModules: {}, + future: {}, + isSpaMode: false, + staticHandlerContext: { + matches: [ + { + route: { + path: 'test', + id: 'test-route', + }, + params: {}, + pathname: '/test', + pathnameBase: '/test', + }, + ], + loaderData: {}, + actionData: null, + errors: null, + basename: '/', + location: { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }, + statusCode: 200, + loaderHeaders: {}, + actionHeaders: {}, + }, + }; + + const mockLoadContext = {}; + + const mockPipe = vi.fn(); + const mockAbort = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + mockResponseHeaders = new Headers(); + vi.spyOn(mockResponseHeaders, 'set'); + + mockRenderToPipeableStream.mockReturnValue({ + pipe: mockPipe, + abort: mockAbort, + }); + + mockCreateReadableStreamFromReadable.mockImplementation(body => body); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create a handleRequest function', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + expect(handleRequest).toBeDefined(); + expect(typeof handleRequest).toBe('function'); + expect(wrapSentryHandleRequestModule.wrapSentryHandleRequest).toHaveBeenCalled(); + }); + + it('should use the default stream timeout if not provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + vi.advanceTimersByTime(10000); + expect(mockAbort).toHaveBeenCalled(); + }); + + it('should use a custom stream timeout if provided', () => { + const customTimeout = 5000; + + const handleRequest = createSentryHandleRequest({ + streamTimeout: customTimeout, + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + vi.advanceTimersByTime(customTimeout - 1); + expect(mockAbort).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(mockAbort).toHaveBeenCalled(); + }); + + it('should use the default bot regex if not provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Googlebot/2.1'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use a custom bot regex if provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + botRegex: /custom-bot/i, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Googlebot/2.1'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onShellReady: expect.any(Function), + }), + ); + + vi.clearAllMocks(); + (mockRequest.headers.get as ReturnType).mockReturnValue('custom-bot/1.0'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use onAllReady for SPA mode', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Mozilla/5.0'); + const spaRouterContext = { ...mockRouterContext, isSpaMode: true }; + + handleRequest(mockRequest, 200, mockResponseHeaders, spaRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use onShellReady for regular browsers', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Mozilla/5.0'); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onShellReady: expect.any(Function), + }), + ); + }); + + it('should set Content-Type header when shell is ready', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + if (options.onShellReady) { + options.onShellReady(); + } + return { pipe: mockPipe, abort: mockAbort }; + }); + + await handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockResponseHeaders.set).toHaveBeenCalledWith('Content-Type', 'text/html'); + }); + + it('should pipe to the meta tag transformer', async () => { + const getMetaTagTransformerSpy = vi.spyOn(wrapSentryHandleRequestModule, 'getMetaTagTransformer'); + + const pipeSpy = vi.fn(); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + // Call the ready callback synchronously to trigger the code path we want to test + setTimeout(() => { + if (options.onShellReady) { + options.onShellReady(); + } + }, 0); + + return { + pipe: pipeSpy, + abort: mockAbort, + }; + }); + + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const promise = handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + // Advance timers to trigger the setTimeout in our mock + await vi.runAllTimersAsync(); + await promise; + + expect(getMetaTagTransformerSpy).toHaveBeenCalled(); + expect(getMetaTagTransformerSpy.mock.calls[0]?.[0]).toBeInstanceOf(PassThrough); + expect(pipeSpy).toHaveBeenCalled(); + }); + + it('should set status code to 500 on error after shell is rendered', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const originalConsoleError = console.error; + console.error = vi.fn(); + + let shellReadyCallback: (() => void) | undefined; + let errorCallback: ((error: Error) => void) | undefined; + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + shellReadyCallback = options.onShellReady; + errorCallback = options.onError; + return { pipe: mockPipe, abort: mockAbort }; + }); + + const responsePromise = handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + // First trigger shellReady to set shellRendered = true + // Then trigger onError to cause the error handling + if (shellReadyCallback) { + shellReadyCallback(); + } + + if (errorCallback) { + errorCallback(new Error('Test error')); + } + + await responsePromise; + expect(console.error).toHaveBeenCalled(); + console.error = originalConsoleError; + }); + + it('should reject the promise on shell error', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const testError = new Error('Shell error'); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + if (options.onShellError) { + options.onShellError(testError); + } + return { pipe: mockPipe, abort: mockAbort }; + }); + + await expect( + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext), + ).rejects.toThrow(testError); + }); +}); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts new file mode 100644 index 000000000000..e29e97f14c57 --- /dev/null +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { RPCType } from '@opentelemetry/core'; +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core'; +import { PassThrough } from 'stream'; +import { wrapSentryHandleRequest, getMetaTagTransformer } from '../../src/server/wrapSentryHandleRequest'; + +vi.mock('@opentelemetry/core', () => ({ + RPCType: { HTTP: 'http' }, + getRPCMetadata: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), +})); + +describe('wrapSentryHandleRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should call original handler with same parameters', async () => { + const originalHandler = vi.fn().mockResolvedValue('original response'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://taco.burrito'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + const result = await wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext); + + expect(originalHandler).toHaveBeenCalledWith( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + expect(result).toBe('original response'); + }); + + test('should set span attributes when parameterized path exists and active span exists', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const mockActiveSpan = { setAttribute: vi.fn() }; + const mockRootSpan = { setAttributes: vi.fn() }; + const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); + vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'some-path' } }], + }, + } as any; + + await wrappedHandler(new Request('https://nacho.queso'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).toHaveBeenCalled(); + expect(getRootSpan).toHaveBeenCalledWith(mockActiveSpan); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [ATTR_HTTP_ROUTE]: '/some-path', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + expect(mockRpcMetadata.route).toBe('/some-path'); + }); + + test('should not set span attributes when parameterized path does not exist', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + } as any; + + await wrappedHandler(new Request('https://guapo.chulo'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).not.toHaveBeenCalled(); + }); + + test('should not set span attributes when active span does not exist', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + (getActiveSpan as unknown as ReturnType).mockReturnValue(null); + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'some-path' } }], + }, + } as any; + + await wrappedHandler(new Request('https://tio.pepe'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).toHaveBeenCalled(); + expect(getRootSpan).not.toHaveBeenCalled(); + }); +}); + +describe('getMetaTagTransformer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('should inject meta tags before closing head tag', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write('Test'); + bodyStream.end(); + }); + + test('should not modify chunks without head closing tag', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toBe('Test'); + expect(getTraceMetaTags).toHaveBeenCalled(); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write('Test'); + bodyStream.end(); + }); + + test('should handle buffer input', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toContain(''); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write(Buffer.from('Test')); + bodyStream.end(); + }); +}); diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json index 5f80a125a0dc..aa2dc034c7c3 100644 --- a/packages/react-router/tsconfig.json +++ b/packages/react-router/tsconfig.json @@ -4,6 +4,7 @@ "include": ["src/**/*"], "compilerOptions": { - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "jsx": "react" } }