diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index df7d65109338..e5e10f2c05b2 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -2,6 +2,7 @@ import { context } from '@opentelemetry/api'; import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { + flushIfServerless, getActiveSpan, getRootSpan, getTraceMetaTags, @@ -58,10 +59,15 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): }); } - return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + try { + return await originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + } finally { + await flushIfServerless(); + } }; } +// todo(v11): remove this /** @deprecated Use `wrapSentryHandleRequest` instead. */ export const sentryHandleRequest = wrapSentryHandleRequest; diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index 7dc8851e2171..9cb0a7ddd067 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + flushIfServerless, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -59,17 +60,21 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: } } - return startSpan( - { - name, - ...options, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', - ...options.attributes, + try { + return await startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + ...options.attributes, + }, }, - }, - () => actionFn(args), - ); + () => actionFn(args), + ); + } finally { + await flushIfServerless(); + } }; } diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index 3d32f0c9d159..981b2085d4b7 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + flushIfServerless, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -59,17 +60,21 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: } } } - return startSpan( - { - name, - ...options, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', - ...options.attributes, + try { + return await startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + ...options.attributes, + }, }, - }, - () => loaderFn(args), - ); + () => loaderFn(args), + ); + } finally { + await flushIfServerless(); + } }; } diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 40dce7c83702..f66a4822555e 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -1,6 +1,7 @@ import { RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { + flushIfServerless, getActiveSpan, getRootSpan, getTraceMetaTags, @@ -15,13 +16,13 @@ vi.mock('@opentelemetry/core', () => ({ RPCType: { HTTP: 'http' }, getRPCMetadata: vi.fn(), })); - vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', getActiveSpan: vi.fn(), getRootSpan: vi.fn(), getTraceMetaTags: vi.fn(), + flushIfServerless: vi.fn(), })); describe('wrapSentryHandleRequest', () => { @@ -62,7 +63,8 @@ describe('wrapSentryHandleRequest', () => { (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; + (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = + getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -110,7 +112,8 @@ describe('wrapSentryHandleRequest', () => { (getActiveSpan as unknown as ReturnType).mockReturnValue(null); const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = + getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -122,6 +125,55 @@ describe('wrapSentryHandleRequest', () => { expect(getRPCMetadata).not.toHaveBeenCalled(); }); + + test('should call flushIfServerless on successful execution', async () => { + const originalHandler = vi.fn().mockResolvedValue('success response'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext); + + expect(flushIfServerless).toHaveBeenCalled(); + }); + + test('should call flushIfServerless even when original handler throws an error', async () => { + const mockError = new Error('Handler failed'); + const originalHandler = vi.fn().mockRejectedValue(mockError); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await expect( + wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext), + ).rejects.toThrow('Handler failed'); + + expect(flushIfServerless).toHaveBeenCalled(); + }); + + test('should propagate errors from original handler', async () => { + const mockError = new Error('Test error'); + const originalHandler = vi.fn().mockRejectedValue(mockError); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 500; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await expect(wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext)).rejects.toBe( + mockError, + ); + }); }); describe('getMetaTagTransformer', () => { @@ -132,68 +184,64 @@ describe('getMetaTagTransformer', () => { ); }); - test('should inject meta tags before closing head tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should inject meta tags before closing head tag', () => { + return new Promise(resolve => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - outputStream.on('end', () => { - expect(outputData).toContain(''); - expect(outputData).not.toContain(''); - done(); - }); + bodyStream.on('end', () => { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + resolve(); + }); - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); + transformer.write('Test'); + transformer.end(); + }); }); - test('should not modify chunks without head closing tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should not modify chunks without head closing tag', () => { + return new Promise(resolve => { + 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(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - transformer.pipe(outputStream); + bodyStream.on('end', () => { + expect(outputData).toBe('Test'); + resolve(); + }); - bodyStream.write('Test'); - bodyStream.end(); + transformer.write('Test'); + transformer.end(); + }); }); - test('should handle buffer input', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should handle buffer input', () => { + return new Promise(resolve => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - outputStream.on('end', () => { - expect(outputData).toContain(''); - done(); - }); + bodyStream.on('end', () => { + expect(outputData).toContain(''); + resolve(); + }); - transformer.pipe(outputStream); - - bodyStream.write(Buffer.from('Test')); - bodyStream.end(); + transformer.write(Buffer.from('Test')); + transformer.end(); + }); }); }); diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 14933fe87e4f..5b707eb33547 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -3,6 +3,15 @@ import type { ActionFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerAction } from '../../src/server/wrapServerAction'; +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + flushIfServerless: vi.fn(), + }; +}); + describe('wrapServerAction', () => { beforeEach(() => { vi.clearAllMocks(); @@ -12,11 +21,12 @@ describe('wrapServerAction', () => { const mockActionFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedAction = wrapServerAction({}, mockActionFn); await wrappedAction(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Executing Server Action', attributes: { @@ -27,6 +37,7 @@ describe('wrapServerAction', () => { expect.any(Function), ); expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); }); it('should wrap an action function with custom options', async () => { @@ -40,11 +51,12 @@ describe('wrapServerAction', () => { const mockActionFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedAction = wrapServerAction(customOptions, mockActionFn); await wrappedAction(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Custom Action', attributes: { @@ -56,5 +68,43 @@ describe('wrapServerAction', () => { expect.any(Function), ); expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless on successful execution', async () => { + const mockActionFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + await wrappedAction(mockArgs); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless even when action throws an error', async () => { + const mockError = new Error('Action failed'); + const mockActionFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + + await expect(wrappedAction(mockArgs)).rejects.toThrow('Action failed'); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should propagate errors from action function', async () => { + const mockError = new Error('Test error'); + const mockActionFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + + await expect(wrappedAction(mockArgs)).rejects.toBe(mockError); }); }); diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index 67b7d512bcbe..0838643ff7de 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -3,6 +3,15 @@ import type { LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerLoader } from '../../src/server/wrapServerLoader'; +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + flushIfServerless: vi.fn(), + }; +}); + describe('wrapServerLoader', () => { beforeEach(() => { vi.clearAllMocks(); @@ -12,11 +21,12 @@ describe('wrapServerLoader', () => { const mockLoaderFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); await wrappedLoader(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Executing Server Loader', attributes: { @@ -27,6 +37,7 @@ describe('wrapServerLoader', () => { expect.any(Function), ); expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); }); it('should wrap a loader function with custom options', async () => { @@ -40,11 +51,12 @@ describe('wrapServerLoader', () => { const mockLoaderFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedLoader = wrapServerLoader(customOptions, mockLoaderFn); await wrappedLoader(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Custom Loader', attributes: { @@ -56,5 +68,43 @@ describe('wrapServerLoader', () => { expect.any(Function), ); expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless on successful execution', async () => { + const mockLoaderFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + await wrappedLoader(mockArgs); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless even when loader throws an error', async () => { + const mockError = new Error('Loader failed'); + const mockLoaderFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + + await expect(wrappedLoader(mockArgs)).rejects.toThrow('Loader failed'); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should propagate errors from loader function', async () => { + const mockError = new Error('Test error'); + const mockLoaderFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + + await expect(wrappedLoader(mockArgs)).rejects.toBe(mockError); }); });