Skip to content

Commit 65e8dbf

Browse files
committed
feat(core): Send sentry.is_localhost on every streamed span
1 parent bcec99a commit 65e8dbf

14 files changed

Lines changed: 433 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/bun';
2+
3+
Sentry.init({
4+
dsn: process.env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
});
7+
8+
const server = Bun.serve({
9+
port: 0,
10+
fetch() {
11+
Sentry.startSpan({ name: 'child-span' }, () => {
12+
// noop
13+
});
14+
return new Response('Hello from Bun!');
15+
},
16+
});
17+
18+
process.send?.(JSON.stringify({ event: 'READY', port: server.port }));
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
function getSpans(envelope: Envelope): SerializedStreamedSpan[] {
6+
return (envelope[1][0][1] as SerializedStreamedSpanContainer).items;
7+
}
8+
9+
// The runner always requests `http://localhost:<port>`, so only the `true` case is reachable here.
10+
// The `false` case is covered by the unit tests for `isLocalhostRequest`.
11+
it('sets sentry.is_localhost on every streamed span', async ({ signal }) => {
12+
const runner = createRunner(__dirname)
13+
.expect(envelope => {
14+
const spans = getSpans(envelope);
15+
16+
expect(spans.some(span => span.is_segment)).toBe(true);
17+
expect(spans.some(span => span.name === 'child-span')).toBe(true);
18+
19+
for (const span of spans) {
20+
expect(span.attributes['sentry.is_localhost']).toEqual({ value: true, type: 'boolean' });
21+
}
22+
})
23+
.start(signal);
24+
25+
await runner.makeRequest('get', '/');
26+
await runner.completed();
27+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
export default Sentry.withSentry(
8+
(env: Env) => ({
9+
dsn: env.SENTRY_DSN,
10+
tracesSampleRate: 1,
11+
}),
12+
{
13+
async fetch(_request, _env, _ctx) {
14+
Sentry.startSpan({ name: 'child-span' }, () => {
15+
// noop
16+
});
17+
return Response.json({ status: 'ok' });
18+
},
19+
},
20+
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, it } from 'vitest';
2+
import { createRunner } from '../../../runner';
3+
4+
it('sets sentry.is_localhost on every streamed span', async ({ signal }) => {
5+
const runner = createRunner(__dirname)
6+
.expect(envelope => {
7+
const items = envelope[1].filter(item => item[0].type === 'span').flatMap(item => (item[1] as any).items);
8+
9+
expect(items.length).toBeGreaterThan(0);
10+
expect(items.some((s: any) => s.is_segment)).toBe(true);
11+
expect(items.some((s: any) => s.name === 'child-span')).toBe(true);
12+
13+
for (const span of items) {
14+
expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: true });
15+
}
16+
})
17+
.start(signal);
18+
19+
await runner.makeRequest('get', '/');
20+
await runner.completed();
21+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "is-localhost",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
import express from 'express';
4+
5+
const app = express();
6+
7+
app.get('/test', (_req, res) => {
8+
Sentry.startSpan({ name: 'child-span' }, () => {
9+
// noop
10+
});
11+
res.send({ response: 'ok' });
12+
});
13+
14+
startExpressServerAndSendPortToRunner(app);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
3+
4+
describe('sentry.is_localhost', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
10+
// The runner always requests `http://localhost:<port>`, so only the `true` case is reachable
11+
// here. The `false` case is covered by the unit tests for `isLocalhostRequest`.
12+
test('is set on every span of a request served from localhost', async () => {
13+
const runner = createRunner()
14+
.unordered()
15+
.expect({
16+
span: container => {
17+
const segmentSpan = container.items.find(s => !!s.is_segment);
18+
const childSpan = container.items.find(s => s.name === 'child-span');
19+
20+
expect(segmentSpan).toBeDefined();
21+
expect(childSpan).toBeDefined();
22+
23+
for (const span of container.items) {
24+
expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: true });
25+
}
26+
},
27+
})
28+
.start();
29+
30+
await runner.makeRequest('get', '/test');
31+
32+
await runner.completed();
33+
});
34+
});
35+
});

‎packages/browser/src/integrations/httpcontext.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,22 @@ export const httpContextIntegration = defineIntegration(() => {
5151
client.getDataCollectionOptions().httpHeaders.request,
5252
);
5353
const referer = headers['Referer'];
54+
const { hostname, protocol } = WINDOW.location || {};
5455

5556
safeSetSpanJSONAttributes(span, {
5657
// This attribute is used by the "Filter out events from legacy browsers and crawlers" features on the Sentry backend.
5758
// Therefore, it's set on every span.
5859
[USER_AGENT_ORIGINAL]: headers['User-Agent'],
5960

61+
// Likewise for the "Filter out localhost events" feature. Deliberately inlined rather than
62+
// sharing the server-side helper, which costs bundle size for request headers and IPs that
63+
// don't exist here. Mirrors Relay's localhost filter (relay-filter/src/localhost.rs).
64+
'sentry.is_localhost':
65+
protocol === 'file:' ||
66+
hostname === 'localhost' ||
67+
hostname === '127.0.0.1' ||
68+
!!hostname?.endsWith('.localhost'),
69+
6070
// These attributes, we only need on the segment span (analogous to the `request` context for events)
6171
...(span.is_segment && {
6272
// Coerce empty string to undefined so the helper's nullish check drops it,

‎packages/browser/test/integrations/httpcontext.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('httpContextIntegration', () => {
4242
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
4343
'http.request.header.referer': ['https://example.com'],
4444
'user_agent.original': USER_AGENT,
45+
'sentry.is_localhost': false,
4546
});
4647
});
4748

@@ -63,6 +64,7 @@ describe('httpContextIntegration', () => {
6364
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
6465
'http.request.header.referer': ['https://example.com'],
6566
'user_agent.original': USER_AGENT,
67+
'sentry.is_localhost': false,
6668
'url.full': 'https://example.com',
6769
});
6870
});
@@ -81,6 +83,7 @@ describe('httpContextIntegration', () => {
8183
expect(span.attributes).toEqual({
8284
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.click',
8385
'user_agent.original': USER_AGENT,
86+
'sentry.is_localhost': false,
8487
});
8588
});
8689

@@ -165,6 +168,7 @@ describe('httpContextIntegration', () => {
165168

166169
expect(span.attributes).toEqual({
167170
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
171+
'sentry.is_localhost': false,
168172
'url.full': 'https://example.com',
169173
});
170174
});
@@ -185,7 +189,45 @@ describe('httpContextIntegration', () => {
185189
'url.full': 'https://example.com',
186190
'http.request.header.referer': ['[Filtered]'],
187191
'user_agent.original': USER_AGENT,
192+
'sentry.is_localhost': false,
188193
});
189194
});
190195
});
196+
197+
describe('sentry.is_localhost', () => {
198+
function processSpanWithLocation(location: Partial<Location>): StreamedSpanJSON['attributes'] {
199+
const original = globalThis.location;
200+
globalThis.location = location as Location;
201+
202+
try {
203+
const span: Partial<StreamedSpanJSON> = { attributes: {} };
204+
httpContextIntegration().processSpan!(
205+
span as StreamedSpanJSON,
206+
new BrowserClient(getDefaultBrowserClientOptions()),
207+
);
208+
return span.attributes;
209+
} finally {
210+
globalThis.location = original;
211+
}
212+
}
213+
214+
it.each([
215+
{ protocol: 'http:', hostname: 'localhost' },
216+
{ protocol: 'http:', hostname: '127.0.0.1' },
217+
{ protocol: 'http:', hostname: 'foo.localhost' },
218+
{ protocol: 'http:', hostname: 'foo.bar.localhost' },
219+
{ protocol: 'file:', hostname: '' },
220+
])('is true for $protocol//$hostname', location => {
221+
expect(processSpanWithLocation(location)).toMatchObject({ 'sentry.is_localhost': true });
222+
});
223+
224+
it.each([
225+
{ protocol: 'https:', hostname: 'example.com' },
226+
{ protocol: 'https:', hostname: 'localhost.com' },
227+
{ protocol: 'https:', hostname: 'foolocalhost' },
228+
{ protocol: 'https:', hostname: '127.0.0.2' },
229+
])('is false for $protocol//$hostname', location => {
230+
expect(processSpanWithLocation(location)).toMatchObject({ 'sentry.is_localhost': false });
231+
});
232+
});
191233
});

0 commit comments

Comments
 (0)