Skip to content

Commit 89b437f

Browse files
authored
fix(tanstackstart-react): Flush events in server entry point for serverless environments (#19513)
As of today server-side Sentry does not work for Tanstack Start if run in serverless environments. Deployment works fine, but no data is actually sent to Sentry. After some experimentation I figured out that we can make it work if we make the following two changes: - Import `instrument.server.mjs` in the `server.ts` file that has the server entry point. Eventually we can do this automatically during the build, but for now I'll update the documentation to do that. - Events need to be explicitly `flushed()`, so that data is sent off before the serverless function dies. This PR adds a `flushIfServerless()` call to the `wrapFetchWithSentry` wrapper. With these modifications errors, logs and basic tracing work (tested on Netlify and Vercel). For cloudflare we'll likely need a slightly modified approach. Tests: - Added basic unit tests that check that `flush()` is called. - Tried to add a netlify e2e test so we can properly test this behavior, but couldn't get anything server-side to work with that. With manual deployments it works fine. Closes #19507
1 parent 5bbba8a commit 89b437f

File tree

2 files changed

+98
-25
lines changed

2 files changed

+98
-25
lines changed

packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { flushIfServerless } from '@sentry/core';
12
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';
23
import { extractServerFunctionSha256 } from './utils';
34

@@ -32,35 +33,41 @@ export type ServerEntry = {
3233
export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
3334
if (serverEntry.fetch) {
3435
serverEntry.fetch = new Proxy<typeof serverEntry.fetch>(serverEntry.fetch, {
35-
apply: (target, thisArg, args) => {
36-
const request: Request = args[0];
37-
const url = new URL(request.url);
38-
const method = request.method || 'GET';
36+
async apply(target, thisArg, args) {
37+
try {
38+
const request: Request = args[0];
39+
const url = new URL(request.url);
40+
const method = request.method || 'GET';
3941

40-
// instrument server functions
41-
if (url.pathname.includes('_serverFn') || url.pathname.includes('createServerFn')) {
42-
const functionSha256 = extractServerFunctionSha256(url.pathname);
43-
const op = 'function.tanstackstart';
42+
// instrument server functions
43+
if (url.pathname.includes('_serverFn') || url.pathname.includes('createServerFn')) {
44+
const functionSha256 = extractServerFunctionSha256(url.pathname);
45+
const op = 'function.tanstackstart';
4446

45-
const serverFunctionSpanAttributes = {
46-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server',
47-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
48-
'tanstackstart.function.hash.sha256': functionSha256,
49-
};
47+
const serverFunctionSpanAttributes = {
48+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server',
49+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
50+
'tanstackstart.function.hash.sha256': functionSha256,
51+
};
5052

51-
return startSpan(
52-
{
53-
op: op,
54-
name: `${method} ${url.pathname}`,
55-
attributes: serverFunctionSpanAttributes,
56-
},
57-
() => {
58-
return target.apply(thisArg, args);
59-
},
60-
);
61-
}
53+
// eslint-disable-next-line no-return-await
54+
return await startSpan(
55+
{
56+
op: op,
57+
name: `${method} ${url.pathname}`,
58+
attributes: serverFunctionSpanAttributes,
59+
},
60+
async () => {
61+
return target.apply(thisArg, args);
62+
},
63+
);
64+
}
6265

63-
return target.apply(thisArg, args);
66+
// eslint-disable-next-line no-return-await
67+
return await target.apply(thisArg, args);
68+
} finally {
69+
await flushIfServerless();
70+
}
6471
},
6572
});
6673
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
const startSpanSpy = vi.fn((_, callback) => callback());
4+
const flushIfServerlessSpy = vi.fn().mockResolvedValue(undefined);
5+
6+
vi.mock('@sentry/node', async importOriginal => {
7+
const original = await importOriginal();
8+
return {
9+
...original,
10+
startSpan: (...args: unknown[]) => startSpanSpy(...args),
11+
};
12+
});
13+
14+
vi.mock('@sentry/core', async importOriginal => {
15+
const original = await importOriginal();
16+
return {
17+
...original,
18+
flushIfServerless: (...args: unknown[]) => flushIfServerlessSpy(...args),
19+
};
20+
});
21+
22+
// Import after mocks are set up
23+
const { wrapFetchWithSentry } = await import('../../src/server/wrapFetchWithSentry');
24+
25+
describe('wrapFetchWithSentry', () => {
26+
afterEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
it('calls flushIfServerless after a regular request', async () => {
31+
const mockResponse = new Response('ok');
32+
const fetchFn = vi.fn().mockResolvedValue(mockResponse);
33+
34+
const serverEntry = wrapFetchWithSentry({ fetch: fetchFn });
35+
const request = new Request('http://localhost:3000/page');
36+
37+
await serverEntry.fetch(request);
38+
39+
expect(fetchFn).toHaveBeenCalled();
40+
expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('calls flushIfServerless after a server function request', async () => {
44+
const mockResponse = new Response('ok');
45+
const fetchFn = vi.fn().mockResolvedValue(mockResponse);
46+
47+
const serverEntry = wrapFetchWithSentry({ fetch: fetchFn });
48+
const request = new Request('http://localhost:3000/_serverFn/abc123');
49+
50+
await serverEntry.fetch(request);
51+
52+
expect(startSpanSpy).toHaveBeenCalled();
53+
expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it('calls flushIfServerless even if the handler throws', async () => {
57+
const fetchFn = vi.fn().mockRejectedValue(new Error('handler error'));
58+
59+
const serverEntry = wrapFetchWithSentry({ fetch: fetchFn });
60+
const request = new Request('http://localhost:3000/page');
61+
62+
await expect(serverEntry.fetch(request)).rejects.toThrow('handler error');
63+
64+
expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1);
65+
});
66+
});

0 commit comments

Comments
 (0)