Skip to content

Commit 06a185e

Browse files
committed
Add a custom httpIntegration for Remix.
1 parent be79801 commit 06a185e

File tree

10 files changed

+108
-34
lines changed

10 files changed

+108
-34
lines changed

dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/behaviour-server.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
1010
const httpServerTransactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => {
1111
return (
1212
transactionEvent.type === 'transaction' &&
13-
transactionEvent.contexts?.trace?.op === 'http' &&
13+
transactionEvent.contexts?.trace?.op === 'http.server' &&
1414
transactionEvent.tags?.['sentry_test'] === testTag
1515
);
1616
});

dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ test('Sends form data with action error to Sentry', async ({ page }) => {
3535
);
3636

3737
expect(actionSpan).toBeDefined();
38-
expect(actionSpan.op).toBe('http');
38+
expect(actionSpan.op).toBe('action.remix');
3939
expect(actionSpan.data).toMatchObject({
4040
'formData.text': 'test',
4141
'formData.file': 'file.txt',
@@ -54,17 +54,17 @@ test('Sends a loader span to Sentry', async ({ page }) => {
5454
);
5555

5656
expect(loaderSpan).toBeDefined();
57-
expect(loaderSpan.op).toBe('http');
57+
expect(loaderSpan.op).toBe('loader.remix');
5858
});
5959

60-
test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
60+
test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
6161
// We use this to identify the transactions
6262
const testTag = uuid4();
6363

6464
const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
6565
return (
6666
transactionEvent.type === 'transaction' &&
67-
transactionEvent.contexts?.trace?.op === 'http' &&
67+
transactionEvent.contexts?.trace?.op === 'http.server' &&
6868
transactionEvent.tags?.['sentry_test'] === testTag
6969
);
7070
});
@@ -77,7 +77,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
7777
);
7878
});
7979

80-
page.goto(`/?tag=${testTag}`);
80+
page.goto(`/client-error?tag=${testTag}`);
8181

8282
const pageloadTransaction = await pageLoadTransactionPromise;
8383
const httpServerTransaction = await httpServerTransactionPromise;
@@ -95,8 +95,8 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
9595
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
9696
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
9797

98-
expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
99-
expect(pageloadTransaction.transaction).toBe('routes/_index');
98+
expect(httpServerTransaction.transaction).toBe('GET client-error');
99+
expect(pageloadTransaction.transaction).toBe('routes/client-error');
100100

101101
expect(httpServerTraceId).toBeDefined();
102102
expect(httpServerSpanId).toBeDefined();
@@ -106,14 +106,14 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
106106
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
107107
});
108108

109-
test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
109+
test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
110110
// We use this to identify the transactions
111111
const testTag = uuid4();
112112

113113
const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
114114
return (
115115
transactionEvent.type === 'transaction' &&
116-
transactionEvent.contexts?.trace?.op === 'http' &&
116+
transactionEvent.contexts?.trace?.op === 'http.server' &&
117117
transactionEvent.tags?.['sentry_test'] === testTag
118118
);
119119
});
@@ -126,7 +126,7 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
126126
);
127127
});
128128

129-
page.goto(`/client-error?tag=${testTag}`);
129+
page.goto(`/?tag=${testTag}`);
130130

131131
const pageloadTransaction = await pageLoadTransactionPromise;
132132
const httpServerTransaction = await httpServerTransactionPromise;
@@ -144,8 +144,8 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
144144
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
145145
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
146146

147-
expect(httpServerTransaction.transaction).toBe('GET client-error');
148-
expect(pageloadTransaction.transaction).toBe('routes/client-error');
147+
expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
148+
expect(pageloadTransaction.transaction).toBe('routes/_index');
149149

150150
expect(httpServerTraceId).toBeDefined();
151151
expect(httpServerSpanId).toBeDefined();

dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-server.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
1010
const httpServerTransactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
1111
return (
1212
transactionEvent.type === 'transaction' &&
13-
transactionEvent.contexts?.trace?.op === 'http' &&
13+
transactionEvent.contexts?.trace?.op === 'http.server' &&
1414
transactionEvent.tags?.['sentry_test'] === testTag
1515
);
1616
});

dev-packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-server.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
1010
const httpServerTransactionPromise = waitForTransaction('create-remix-app', transactionEvent => {
1111
return (
1212
transactionEvent.type === 'transaction' &&
13-
transactionEvent.contexts?.trace?.op === 'http' &&
13+
transactionEvent.contexts?.trace?.op === 'http.server' &&
1414
transactionEvent.tags?.['sentry_test'] === testTag
1515
);
1616
});

packages/remix/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"access": "public"
5858
},
5959
"dependencies": {
60+
"@opentelemetry/instrumentation-http": "0.51.1",
6061
"@remix-run/router": "1.x",
6162
"@sentry/cli": "^2.31.0",
6263
"@sentry/core": "8.4.0",

packages/remix/src/index.server.ts

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { logger } from '@sentry/utils';
1111

1212
import { DEBUG_BUILD } from './utils/debug-build';
1313
import { instrumentServer } from './utils/instrumentServer';
14+
import { httpIntegration } from './utils/integrations/http';
1415
import { remixIntegration } from './utils/integrations/opentelemetry';
1516
import type { RemixOptions } from './utils/remixOptions';
1617

@@ -135,6 +136,7 @@ export type { SentryMetaArgs } from './utils/types';
135136
export function getDefaultIntegrations(options: RemixOptions): Integration[] {
136137
return [
137138
...getDefaultNodeIntegrations(options).filter(integration => integration.name !== 'Http'),
139+
httpIntegration(),
138140
remixIntegration(options),
139141
];
140142
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// This integration is ported from the Next.JS SDK.
2+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
3+
import { httpIntegration as originalHttpIntegration } from '@sentry/node';
4+
import type { IntegrationFn } from '@sentry/types';
5+
6+
class RemixHttpIntegration extends HttpInstrumentation {
7+
// Instead of the default behavior, we just don't do any wrapping for incoming requests
8+
protected _getPatchIncomingRequestFunction(_component: 'http' | 'https') {
9+
return (
10+
original: (event: string, ...args: unknown[]) => boolean,
11+
): ((this: unknown, event: string, ...args: unknown[]) => boolean) => {
12+
return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean {
13+
return original.apply(this, [event, ...args]);
14+
};
15+
};
16+
}
17+
}
18+
19+
interface HttpOptions {
20+
/**
21+
* Whether breadcrumbs should be recorded for requests.
22+
* Defaults to true
23+
*/
24+
breadcrumbs?: boolean;
25+
26+
/**
27+
* Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
28+
* This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled.
29+
*/
30+
ignoreOutgoingRequests?: (url: string) => boolean;
31+
}
32+
33+
/**
34+
* The http integration instruments Node's internal http and https modules.
35+
* It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span.
36+
*/
37+
export const httpIntegration = ((options: HttpOptions = {}) => {
38+
return originalHttpIntegration({
39+
...options,
40+
_instrumentation: RemixHttpIntegration,
41+
});
42+
}) satisfies IntegrationFn;

packages/remix/src/utils/integrations/opentelemetry.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix';
22

33
import { defineIntegration } from '@sentry/core';
4-
import { addOpenTelemetryInstrumentation } from '@sentry/node';
5-
import type { IntegrationFn } from '@sentry/types';
4+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, addOpenTelemetryInstrumentation, spanToJSON } from '@sentry/node';
5+
import type { Client, IntegrationFn, Span } from '@sentry/types';
66
import type { RemixOptions } from '../remixOptions';
77

88
const _remixIntegration = ((options?: RemixOptions) => {
@@ -15,9 +15,38 @@ const _remixIntegration = ((options?: RemixOptions) => {
1515
}),
1616
);
1717
},
18+
19+
setup(client: Client) {
20+
client.on('spanStart', span => {
21+
addRemixSpanAttributes(span);
22+
});
23+
},
1824
};
1925
}) satisfies IntegrationFn;
2026

27+
const addRemixSpanAttributes = (span: Span): void => {
28+
const attributes = spanToJSON(span).data || {};
29+
30+
// this is one of: loader, action, requestHandler
31+
const type = attributes['code.function'];
32+
33+
// If this is already set, or we have no remix span, no need to process again...
34+
if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] || !type) {
35+
return;
36+
}
37+
38+
// `requestHandler` span from `opentelemetry-instrumentation-remix` is the main server span.
39+
// It should be marked as the `http.server` operation.
40+
// The incoming requests are skipped by the custom `RemixHttpIntegration` package.
41+
if (type === 'requestHandler') {
42+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server');
43+
return;
44+
}
45+
46+
// All other spans are marked as `remix` operations with their specific type [loader, action]
47+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.remix`);
48+
};
49+
2150
/**
2251
* Instrumentation for aws-sdk package
2352
*/

packages/remix/test/integration/test/server/action.test.ts

+11-11
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('Remix API Actions', () => {
2121
{
2222
data: {
2323
'code.function': 'action',
24-
'sentry.op': 'http',
24+
'sentry.op': 'action.remix',
2525
'otel.kind': 'INTERNAL',
2626
'match.route.id': `routes/action-json-response${useV2 ? '.' : '/'}$id`,
2727
'match.params.id': '123123',
@@ -30,7 +30,7 @@ describe('Remix API Actions', () => {
3030
{
3131
data: {
3232
'code.function': 'loader',
33-
'sentry.op': 'http',
33+
'sentry.op': 'loader.remix',
3434
'otel.kind': 'INTERNAL',
3535
'match.route.id': `routes/action-json-response${useV2 ? '.' : '/'}$id`,
3636
'match.params.id': '123123',
@@ -39,7 +39,7 @@ describe('Remix API Actions', () => {
3939
{
4040
data: {
4141
'code.function': 'loader',
42-
'sentry.op': 'http',
42+
'sentry.op': 'loader.remix',
4343
'otel.kind': 'INTERNAL',
4444
'match.route.id': 'root',
4545
'match.params.id': '123123',
@@ -168,7 +168,7 @@ describe('Remix API Actions', () => {
168168
assertSentryTransaction(transaction_1[2], {
169169
contexts: {
170170
trace: {
171-
op: 'http',
171+
op: 'http.server',
172172
status: 'ok',
173173
data: {
174174
'http.response.status_code': 302,
@@ -181,7 +181,7 @@ describe('Remix API Actions', () => {
181181
assertSentryTransaction(transaction_2[2], {
182182
contexts: {
183183
trace: {
184-
op: 'http',
184+
op: 'http.server',
185185
status: 'internal_error',
186186
data: {
187187
'http.response.status_code': 500,
@@ -228,7 +228,7 @@ describe('Remix API Actions', () => {
228228
assertSentryTransaction(transaction[2], {
229229
contexts: {
230230
trace: {
231-
op: 'http',
231+
op: 'http.server',
232232
status: 'internal_error',
233233
data: {
234234
'http.response.status_code': 500,
@@ -275,7 +275,7 @@ describe('Remix API Actions', () => {
275275
assertSentryTransaction(transaction[2], {
276276
contexts: {
277277
trace: {
278-
op: 'http',
278+
op: 'http.server',
279279
status: 'internal_error',
280280
data: {
281281
'http.response.status_code': 500,
@@ -322,7 +322,7 @@ describe('Remix API Actions', () => {
322322
assertSentryTransaction(transaction[2], {
323323
contexts: {
324324
trace: {
325-
op: 'http',
325+
op: 'http.server',
326326
status: 'internal_error',
327327
data: {
328328
'http.response.status_code': 500,
@@ -369,7 +369,7 @@ describe('Remix API Actions', () => {
369369
assertSentryTransaction(transaction[2], {
370370
contexts: {
371371
trace: {
372-
op: 'http',
372+
op: 'http.server',
373373
status: 'internal_error',
374374
data: {
375375
'http.response.status_code': 500,
@@ -416,7 +416,7 @@ describe('Remix API Actions', () => {
416416
assertSentryTransaction(transaction[2], {
417417
contexts: {
418418
trace: {
419-
op: 'http',
419+
op: 'http.server',
420420
status: 'internal_error',
421421
data: {
422422
'http.response.status_code': 500,
@@ -463,7 +463,7 @@ describe('Remix API Actions', () => {
463463
assertSentryTransaction(transaction[2], {
464464
contexts: {
465465
trace: {
466-
op: 'http',
466+
op: 'http.server',
467467
status: 'internal_error',
468468
data: {
469469
'http.response.status_code': 500,

packages/remix/test/integration/test/server/loader.test.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,15 @@ describe('Remix API Loaders', () => {
103103
data: {
104104
'code.function': 'loader',
105105
'otel.kind': 'INTERNAL',
106-
'sentry.op': 'http',
106+
'sentry.op': 'loader.remix',
107107
},
108108
origin: 'manual',
109109
},
110110
{
111111
data: {
112112
'code.function': 'loader',
113113
'otel.kind': 'INTERNAL',
114-
'sentry.op': 'http',
114+
'sentry.op': 'loader.remix',
115115
},
116116
origin: 'manual',
117117
},
@@ -135,7 +135,7 @@ describe('Remix API Loaders', () => {
135135
assertSentryTransaction(transaction_1[2], {
136136
contexts: {
137137
trace: {
138-
op: 'http',
138+
op: 'http.server',
139139
status: 'ok',
140140
data: {
141141
'http.response.status_code': 302,
@@ -148,7 +148,7 @@ describe('Remix API Loaders', () => {
148148
assertSentryTransaction(transaction_2[2], {
149149
contexts: {
150150
trace: {
151-
op: 'http',
151+
op: 'http.server',
152152
status: 'internal_error',
153153
data: {
154154
'http.response.status_code': 500,
@@ -243,15 +243,15 @@ describe('Remix API Loaders', () => {
243243
{
244244
data: {
245245
'code.function': 'loader',
246-
'sentry.op': 'http',
246+
'sentry.op': 'loader.remix',
247247
'otel.kind': 'INTERNAL',
248248
'match.route.id': `routes/loader-defer-response${useV2 ? '.' : '/'}$id`,
249249
},
250250
},
251251
{
252252
data: {
253253
'code.function': 'loader',
254-
'sentry.op': 'http',
254+
'sentry.op': 'loader.remix',
255255
'otel.kind': 'INTERNAL',
256256
'match.route.id': 'root',
257257
},

0 commit comments

Comments
 (0)