Skip to content

Commit c0f9783

Browse files
committed
Use startSpanManual in wrapServerFunction to preserve redirect span status
1 parent 1892cb0 commit c0f9783

File tree

2 files changed

+33
-15
lines changed

2 files changed

+33
-15
lines changed

packages/react-router/src/server/rsc/wrapServerFunction.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1010
SPAN_STATUS_ERROR,
1111
SPAN_STATUS_OK,
12-
startSpan,
12+
startSpanManual,
1313
} from '@sentry/core';
1414
import { DEBUG_BUILD } from '../../common/debug-build';
1515
import { isNotFoundResponse, isRedirectResponse } from './responseUtils';
@@ -58,7 +58,9 @@ export function wrapServerFunction<T extends (...args: any[]) => Promise<any>>(
5858

5959
const hasActiveSpan = !!getActiveSpan();
6060

61-
return startSpan(
61+
// startSpanManual is used instead of startSpan because startSpan's error handler
62+
// would overwrite the intentional SPAN_STATUS_OK set for redirect responses.
63+
return startSpanManual(
6264
{
6365
name: spanName,
6466
forceTransaction: !hasActiveSpan,
@@ -73,15 +75,18 @@ export function wrapServerFunction<T extends (...args: any[]) => Promise<any>>(
7375
async span => {
7476
try {
7577
const result = await originalFunction.apply(thisArg, args);
78+
span.end();
7679
return result;
7780
} catch (error) {
7881
if (isRedirectResponse(error)) {
7982
span.setStatus({ code: SPAN_STATUS_OK });
83+
span.end();
8084
throw error;
8185
}
8286

8387
if (isNotFoundResponse(error)) {
8488
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
89+
span.end();
8590
throw error;
8691
}
8792

@@ -97,6 +102,7 @@ export function wrapServerFunction<T extends (...args: any[]) => Promise<any>>(
97102
},
98103
},
99104
});
105+
span.end();
100106
throw error;
101107
} finally {
102108
await flushIfServerless();

packages/react-router/test/server/rsc/wrapServerFunction.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ vi.mock('@sentry/core', async () => {
66
const actual = await vi.importActual('@sentry/core');
77
return {
88
...actual,
9-
startSpan: vi.fn(),
9+
startSpanManual: vi.fn(),
1010
getIsolationScope: vi.fn(),
1111
captureException: vi.fn(),
1212
flushIfServerless: vi.fn().mockResolvedValue(undefined),
@@ -25,7 +25,7 @@ describe('wrapServerFunction', () => {
2525
const mockSetTransactionName = vi.fn();
2626

2727
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
28-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
28+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() }));
2929

3030
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
3131
const result = await wrappedFn('arg1', 'arg2');
@@ -34,7 +34,7 @@ describe('wrapServerFunction', () => {
3434
expect(mockServerFn).toHaveBeenCalledWith('arg1', 'arg2');
3535
expect(core.getIsolationScope).toHaveBeenCalled();
3636
expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/testFunction');
37-
expect(core.startSpan).toHaveBeenCalledWith(
37+
expect(core.startSpanManual).toHaveBeenCalledWith(
3838
expect.objectContaining({
3939
name: 'serverFunction/testFunction',
4040
forceTransaction: true,
@@ -56,12 +56,12 @@ describe('wrapServerFunction', () => {
5656

5757
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
5858
(core.getActiveSpan as any).mockReturnValue({ spanId: 'existing-span' });
59-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
59+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() }));
6060

6161
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
6262
await wrappedFn();
6363

64-
expect(core.startSpan).toHaveBeenCalledWith(
64+
expect(core.startSpanManual).toHaveBeenCalledWith(
6565
expect.objectContaining({
6666
forceTransaction: false,
6767
}),
@@ -74,15 +74,15 @@ describe('wrapServerFunction', () => {
7474
const mockSetTransactionName = vi.fn();
7575

7676
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
77-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
77+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() }));
7878

7979
const wrappedFn = wrapServerFunction('testFunction', mockServerFn, {
8080
name: 'Custom Span Name',
8181
});
8282
await wrappedFn();
8383

8484
expect(mockSetTransactionName).toHaveBeenCalledWith('Custom Span Name');
85-
expect(core.startSpan).toHaveBeenCalledWith(
85+
expect(core.startSpanManual).toHaveBeenCalledWith(
8686
expect.objectContaining({
8787
name: 'Custom Span Name',
8888
}),
@@ -95,14 +95,14 @@ describe('wrapServerFunction', () => {
9595
const mockSetTransactionName = vi.fn();
9696

9797
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
98-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
98+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() }));
9999

100100
const wrappedFn = wrapServerFunction('testFunction', mockServerFn, {
101101
attributes: { 'custom.attr': 'value' },
102102
});
103103
await wrappedFn();
104104

105-
expect(core.startSpan).toHaveBeenCalledWith(
105+
expect(core.startSpanManual).toHaveBeenCalledWith(
106106
expect.objectContaining({
107107
attributes: expect.objectContaining({
108108
[core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function',
@@ -117,10 +117,13 @@ describe('wrapServerFunction', () => {
117117
const mockError = new Error('Server function failed');
118118
const mockServerFn = vi.fn().mockRejectedValue(mockError);
119119
const mockSetStatus = vi.fn();
120+
const mockEnd = vi.fn();
120121
const mockSetTransactionName = vi.fn();
121122

122123
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
123-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
124+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) =>
125+
fn({ setStatus: mockSetStatus, end: mockEnd }),
126+
);
124127

125128
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
126129

@@ -136,6 +139,7 @@ describe('wrapServerFunction', () => {
136139
},
137140
},
138141
});
142+
expect(mockEnd).toHaveBeenCalled();
139143
expect(core.flushIfServerless).toHaveBeenCalled();
140144
});
141145

@@ -146,31 +150,39 @@ describe('wrapServerFunction', () => {
146150
});
147151
const mockServerFn = vi.fn().mockRejectedValue(redirectResponse);
148152
const mockSetStatus = vi.fn();
153+
const mockEnd = vi.fn();
149154
const mockSetTransactionName = vi.fn();
150155

151156
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
152-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
157+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) =>
158+
fn({ setStatus: mockSetStatus, end: mockEnd }),
159+
);
153160

154161
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
155162

156163
await expect(wrappedFn()).rejects.toBe(redirectResponse);
157164
expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK });
165+
expect(mockEnd).toHaveBeenCalled();
158166
expect(core.captureException).not.toHaveBeenCalled();
159167
});
160168

161169
it('should not capture not-found errors as exceptions', async () => {
162170
const notFoundResponse = new Response(null, { status: 404 });
163171
const mockServerFn = vi.fn().mockRejectedValue(notFoundResponse);
164172
const mockSetStatus = vi.fn();
173+
const mockEnd = vi.fn();
165174
const mockSetTransactionName = vi.fn();
166175

167176
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
168-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
177+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) =>
178+
fn({ setStatus: mockSetStatus, end: mockEnd }),
179+
);
169180

170181
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
171182

172183
await expect(wrappedFn()).rejects.toBe(notFoundResponse);
173184
expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'not_found' });
185+
expect(mockEnd).toHaveBeenCalled();
174186
expect(core.captureException).not.toHaveBeenCalled();
175187
});
176188

@@ -180,7 +192,7 @@ describe('wrapServerFunction', () => {
180192
const mockSetTransactionName = vi.fn();
181193

182194
(core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
183-
(core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
195+
(core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() }));
184196

185197
const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
186198

0 commit comments

Comments
 (0)