diff --git a/.size-limit.js b/.size-limit.js index 06e1f6e30b8a..3b96129de159 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -504,7 +504,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '500 KiB', + limit: '502 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index e2a09a658f18..62d8bd9c6366 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -76,6 +76,7 @@ sentryTest( expect(spans).toEqual([ { attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test-child', @@ -124,6 +125,7 @@ sentryTest( }, { attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual', @@ -168,6 +170,7 @@ sentryTest( }, { attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual', @@ -216,6 +219,7 @@ sentryTest( }, { attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'culture.calendar': { type: 'string', value: expect.any(String), diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index 912a72ea6819..b99e676afaae 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -77,6 +77,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow expect(navigationSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index 0a5434324908..680b868b4659 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -71,6 +71,7 @@ sentryTest( expect(pageloadSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'culture.calendar': { type: 'string', value: expect.any(String), diff --git a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts index 1e8f653607b9..1d9277ccc411 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts @@ -46,6 +46,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser expect(interactionSegmentSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', @@ -127,6 +128,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser const interactionSpan = interactionSpanTree.find(span => getSpanOp(span) === 'ui.interaction.click'); expect(interactionSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/dev-packages/browser-integration-tests/suites/tracing/is-localhost/init.js b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/init.js new file mode 100644 index 000000000000..83076460599f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/is-localhost/subject.js b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/subject.js new file mode 100644 index 000000000000..7b8a70940968 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/subject.js @@ -0,0 +1,3 @@ +Sentry.startSpan({ name: 'child-span' }, () => { + // noop +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/is-localhost/test.ts b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/test.ts new file mode 100644 index 000000000000..3ac425542fc3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/is-localhost/test.ts @@ -0,0 +1,53 @@ +import fs from 'fs'; +import path from 'path'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest('is false when the page is not served from localhost', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(span => span.name === 'child-span')); + + await page.goto(url); + + const spans = await spansPromise; + + expect(spans.length).toBeGreaterThan(1); + for (const span of spans) { + expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: false }); + } +}); + +sentryTest('is true when the page is served from localhost', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + // `getLocalTestUrl` serves from `http://sentry-test.io`, so the routes are wired up by hand here + // to serve the very same page from `localhost` instead. + const tmpDir = await getLocalTestUrl({ testDir: __dirname, skipRouteHandler: true }); + + await page.route(/^https:\/\/dsn\.ingest\.sentry\.io\//, route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }), + ); + + await page.route('http://localhost/*.*', route => { + const file = route.request().url().split('/').pop(); + const filePath = path.resolve(tmpDir, `./${file}`); + + return fs.existsSync(filePath) ? route.fulfill({ path: filePath }) : route.continue(); + }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(span => span.name === 'child-span')); + + await page.goto('http://localhost/index.html'); + + const spans = await spansPromise; + + expect(spans.length).toBeGreaterThan(1); + for (const span of spans) { + expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: true }); + } +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts index 6ebd77451657..e784ad07d2a3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts @@ -73,6 +73,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts index fc57a1f137ba..f289a32653fb 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -73,6 +73,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, @@ -159,6 +160,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts index 2687f411aeed..1a9697ab817d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts @@ -74,6 +74,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts index 880d8b5ca605..2141cb27ea4c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts @@ -74,6 +74,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts index c48ffe6feb10..91438b14120c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -77,6 +77,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, @@ -144,6 +145,7 @@ sentryTest( is_segment: false, status: 'ok', attributes: { + 'sentry.is_localhost': { value: false, type: 'boolean' }, 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, diff --git a/dev-packages/bun-integration-tests/suites/is-localhost/index.ts b/dev-packages/bun-integration-tests/suites/is-localhost/index.ts new file mode 100644 index 000000000000..0f02bee605ee --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/is-localhost/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, +}); + +const server = Bun.serve({ + port: 0, + fetch() { + Sentry.startSpan({ name: 'child-span' }, () => { + // noop + }); + return new Response('Hello from Bun!'); + }, +}); + +process.send?.(JSON.stringify({ event: 'READY', port: server.port })); diff --git a/dev-packages/bun-integration-tests/suites/is-localhost/test.ts b/dev-packages/bun-integration-tests/suites/is-localhost/test.ts new file mode 100644 index 000000000000..8250220b6891 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/is-localhost/test.ts @@ -0,0 +1,27 @@ +import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function getSpans(envelope: Envelope): SerializedStreamedSpan[] { + return (envelope[1][0][1] as SerializedStreamedSpanContainer).items; +} + +// The runner always requests `http://localhost:`, so only the `true` case is reachable here. +// The `false` case is covered by the unit tests for `isLocalhostRequest`. +it('sets sentry.is_localhost on every streamed span', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpans(envelope); + + expect(spans.some(span => span.is_segment)).toBe(true); + expect(spans.some(span => span.name === 'child-span')).toBe(true); + + for (const span of spans) { + expect(span.attributes['sentry.is_localhost']).toEqual({ value: true, type: 'boolean' }); + } + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts index 73af51173484..6065ce871c29 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -80,6 +80,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa expect(childSpan).toBeDefined(); expect(childSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', @@ -107,6 +108,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa expect(inactiveSpan).toBeDefined(); expect(inactiveSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, @@ -143,6 +145,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa expect(manualSpan).toBeDefined(); expect(manualSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, @@ -164,6 +167,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa expect(parentTestSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, @@ -186,6 +190,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa expect(segmentSpan).toEqual({ attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 96a5628da921..4dad579a374a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -174,6 +174,7 @@ describe('with span streaming enabled', () => { { name: 'CREATE TABLE users', attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, ...commonAttributes(segmentSpan), 'db.operation.name': { type: 'string', value: 'exec' }, 'db.query.text': { @@ -187,6 +188,7 @@ describe('with span streaming enabled', () => { { name: 'INSERT users', attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, ...commonAttributes(segmentSpan), 'db.operation.name': { type: 'string', value: 'run' }, 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, @@ -208,6 +210,7 @@ describe('with span streaming enabled', () => { { name: 'SELECT users', attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, ...commonAttributes(segmentSpan), 'db.operation.name': { type: 'string', value: 'first' }, 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/index.ts new file mode 100644 index 000000000000..493b18adacf3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/index.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(_request, _env, _ctx) { + Sentry.startSpan({ name: 'child-span' }, () => { + // noop + }); + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/test.ts new file mode 100644 index 000000000000..e161c68b35ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('sets sentry.is_localhost on every streamed span', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const items = envelope[1].filter(item => item[0].type === 'span').flatMap(item => (item[1] as any).items); + + expect(items.length).toBeGreaterThan(0); + expect(items.some((s: any) => s.is_segment)).toBe(true); + expect(items.some((s: any) => s.name === 'child-span')).toBe(true); + + for (const span of items) { + expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: true }); + } + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/wrangler.jsonc new file mode 100644 index 000000000000..c7070b7aa114 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/is-localhost/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "is-localhost", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/deno-integration-tests/src/index.ts b/dev-packages/deno-integration-tests/src/index.ts index cef98cfc8ee5..5268dfd42a8a 100644 --- a/dev-packages/deno-integration-tests/src/index.ts +++ b/dev-packages/deno-integration-tests/src/index.ts @@ -1,4 +1,11 @@ -import type { Event, TransactionEvent } from '@sentry/core'; +import type { + Envelope, + Event, + SerializedStreamedSpan, + SerializedStreamedSpanContainer, + TransactionEvent, + Transport, +} from '@sentry/core'; import { getAsyncContextStrategy, getMainCarrier, setAsyncContextStrategy } from '@sentry/core'; /** @@ -84,3 +91,32 @@ export function withTimeout(p: Promise, ms: number, what: string): Promise if (timer !== undefined) clearTimeout(timer); }); } + +/** + * A `transport` that records every streamed span instead of sending it, and lets a test `await` + * the first one matching a predicate. Unlike events, spans cannot be dropped from `beforeSendSpan` + * (returning null is disallowed), so intercepting them needs a transport rather than a hook. + */ +export function spanSink(): { + waitFor: (predicate: (span: SerializedStreamedSpan) => boolean) => Promise; + transport: () => Transport; +} { + const sink = eventSink(); + + return { + waitFor: sink.waitFor, + transport: () => ({ + send: (envelope: Envelope) => { + for (const [header, payload] of envelope[1]) { + if (header.type === 'span') { + for (const span of (payload as SerializedStreamedSpanContainer).items) { + sink.beforeSend(span); + } + } + } + return Promise.resolve({}); + }, + flush: () => Promise.resolve(true), + }), + }; +} diff --git a/dev-packages/deno-integration-tests/suites/is-localhost/test.ts b/dev-packages/deno-integration-tests/suites/is-localhost/test.ts new file mode 100644 index 000000000000..f76e7c071f9d --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/is-localhost/test.ts @@ -0,0 +1,59 @@ +// + +import * as Sentry from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; + +Deno.test('Deno.serve sets sentry.is_localhost on every streamed span of the request', async () => { + resetGlobals(); + const sink = spanSink(); + + Sentry.init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + transport: sink.transport, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + Sentry.startSpan({ name: 'child-span' }, () => { + // noop + }); + return new Response('OK'); + }); + await listening; + + const childPromise = withTimeout( + sink.waitFor(span => span.name === 'child-span'), + 5_000, + 'child span', + ); + const segmentPromise = withTimeout( + sink.waitFor(span => span.attributes['sentry.op']?.value === 'http.server'), + 5_000, + 'http.server span', + ); + // The outgoing span is its own segment and ends after the server's, so it flushes separately: + // waiting for it explicitly avoids depending on it having arrived by the time the others do. + const clientPromise = withTimeout( + sink.waitFor(span => span.attributes['sentry.op']?.value === 'http.client'), + 5_000, + 'http.client span', + ); + + const response = await fetch(`http://localhost:${server.addr.port}/test`); + assertEquals(await response.text(), 'OK'); + + const localhost = { value: true, type: 'boolean' }; + assertEquals((await segmentPromise).attributes['sentry.is_localhost'], localhost); + assertEquals((await childPromise).attributes['sentry.is_localhost'], localhost); + + // The test's own outgoing `fetch` runs outside any request scope, so there is no request to + // judge and it is correctly `false` — even though it happens to target localhost. + assertEquals((await clientPromise).attributes['sentry.is_localhost'], { value: false, type: 'boolean' }); + + abortController.abort(); + await server.finished; +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts index 5d46c1daa69c..cb9bb624f6bd 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts @@ -9,6 +9,11 @@ const SEGMENT_SPAN = { type: 'string', value: 'stream', }, + // The e2e app is served over localhost (`baseURL: http://localhost:`). + 'sentry.is_localhost': { + type: 'boolean', + value: true, + }, 'app.start_time': { type: 'string', value: expect.any(String), @@ -168,6 +173,10 @@ const CHILD_SPAN_ATTRIBUTES = { type: 'string', value: 'stream', }, + 'sentry.is_localhost': { + type: 'boolean', + value: true, + }, 'sentry.environment': { type: 'string', value: 'qa', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts index 576e75864ac4..8c3c62011f17 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/span-decorator.test.ts @@ -19,6 +19,7 @@ function tracedSpan(segmentSpan: SerializedStreamedSpan, name: string, op: strin status: 'ok', attributes: { 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.is_localhost': segmentSpan.attributes['sentry.is_localhost'], 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts index 1285ebc399fe..462c13f7ceee 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/tests/spans.test.ts @@ -18,6 +18,9 @@ function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStre function commonAttributes(segmentSpan: SerializedStreamedSpan): Record { return { 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + // Every span of a trace shares its segment's verdict: `true` for requests (the app is served + // over localhost), `false` for spans with no request, like the startup app_creation span. + 'sentry.is_localhost': segmentSpan.attributes['sentry.is_localhost'], 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/span-decorator.test.ts index 1e47226e92f2..3295834bcc0e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/span-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/span-decorator.test.ts @@ -19,6 +19,7 @@ function tracedSpan(segmentSpan: SerializedStreamedSpan, name: string, op: strin status: 'ok', attributes: { 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.is_localhost': segmentSpan.attributes['sentry.is_localhost'], 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/spans.test.ts index e94d8226d659..727fa004c298 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/spans.test.ts @@ -18,6 +18,9 @@ function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStre function commonAttributes(segmentSpan: SerializedStreamedSpan): Record { return { 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + // Every span of a trace shares its segment's verdict: `true` for requests (the app is served + // over localhost), `false` for spans with no request, like the startup app_creation span. + 'sentry.is_localhost': segmentSpan.attributes['sentry.is_localhost'], 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts index c3aee02d14d5..3e7cd8426158 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/spans.test.ts @@ -18,6 +18,9 @@ function findSpan(spans: SerializedStreamedSpan[], name: string): SerializedStre function commonAttributes(segmentSpan: SerializedStreamedSpan): Record { return { 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + // Every span of a trace shares its segment's verdict: `true` for requests (the app is served + // over localhost), `false` for spans with no request, like the startup app_creation span. + 'sentry.is_localhost': segmentSpan.attributes['sentry.is_localhost'], 'sentry.segment.name': { type: 'string', value: segmentSpan.name }, 'sentry.segment.id': { type: 'string', value: segmentSpan.span_id }, 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.nestjs' }, diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 5dd0357c1a3a..3aef47e9c9ba 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -58,6 +58,7 @@ test('sends a streamed span envelope with correct spans for a manually started s expect(childSpan).toEqual({ attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test-child', @@ -85,6 +86,7 @@ test('sends a streamed span envelope with correct spans for a manually started s expect(inactiveSpan).toEqual({ attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, @@ -121,6 +123,7 @@ test('sends a streamed span envelope with correct spans for a manually started s expect(manualSpan).toEqual({ attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, @@ -141,6 +144,7 @@ test('sends a streamed span envelope with correct spans for a manually started s const expectedAttributes: Record = { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts index 3bdcaea303be..e5332df32f76 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts @@ -144,6 +144,7 @@ describeWithDockerCompose( }), 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + 'sentry.is_localhost': { type: 'boolean', value: false }, }, end_timestamp: expect.any(Number), is_segment: false, diff --git a/dev-packages/node-integration-tests/suites/tracing/is-localhost/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/is-localhost/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/is-localhost/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/is-localhost/server.mjs b/dev-packages/node-integration-tests/suites/tracing/is-localhost/server.mjs new file mode 100644 index 000000000000..02ef3b6117ad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/is-localhost/server.mjs @@ -0,0 +1,14 @@ +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const app = express(); + +app.get('/test', (_req, res) => { + Sentry.startSpan({ name: 'child-span' }, () => { + // noop + }); + res.send({ response: 'ok' }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/is-localhost/test.ts b/dev-packages/node-integration-tests/suites/tracing/is-localhost/test.ts new file mode 100644 index 000000000000..3960b1edae8c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/is-localhost/test.ts @@ -0,0 +1,35 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('sentry.is_localhost', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + // The runner always requests `http://localhost:`, so only the `true` case is reachable + // here. The `false` case is covered by the unit tests for `isLocalhostRequest`. + test('is set on every span of a request served from localhost', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segmentSpan = container.items.find(s => !!s.is_segment); + const childSpan = container.items.find(s => s.name === 'child-span'); + + expect(segmentSpan).toBeDefined(); + expect(childSpan).toBeDefined(); + + for (const span of container.items) { + expect(span.attributes['sentry.is_localhost']).toEqual({ type: 'boolean', value: true }); + } + }, + }) + .start(); + + await runner.makeRequest('get', '/test'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index d0c7647bf782..f0f45e51f35c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -239,6 +239,7 @@ describe('MongoDB auto-instrumentation', () => { ...attributes, }), 'server.port': { type: 'integer', value: expect.any(Number) }, + 'sentry.is_localhost': { type: 'boolean', value: false }, }, end_timestamp: expect.any(Number), is_segment: false, diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 53619776fdea..2188dd49f356 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -183,6 +183,8 @@ describe('mysql auto instrumentation', () => { expect(dbSpans.length).toBe(2); const COMMON_ATTRIBUTES = { + // These spans belong to a script with no incoming request, so there is nothing to judge. + 'sentry.is_localhost': { type: 'boolean', value: false }, 'db.connection_string': { type: 'string', value: expect.stringMatching(/^jdbc:mysql:\/\/localhost:.*/), diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 03a3fe70fd1e..645fe2ff8d12 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -12,6 +12,10 @@ import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose const QUERY_ORIGIN = 'auto.db.postgres'; const COMMON_DB_ATTRIBUTES = { + 'sentry.is_localhost': { + type: 'boolean', + value: false, + }, 'db.connection_string': { type: 'string', value: expect.stringMatching(/^postgresql:\/\/localhost:\d+\/tests$/), diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index 87416e6893c4..a6c77cd01225 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -710,6 +710,7 @@ describeWithDockerCompose('redis cache auto instrumentation', { workingDirectory [SENTRY_TRACE_LIFECYCLE]: 'stream', }), 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.is_localhost': { type: 'boolean', value: false }, 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts index db965e173046..77cf91663ed0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-dc/test.ts @@ -192,6 +192,7 @@ describeWithDockerCompose( }), 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + 'sentry.is_localhost': { type: 'boolean', value: false }, }, end_timestamp: expect.any(Number), is_segment: false, diff --git a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts index ee0d3cd6c9c3..8d5b9339ec33 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis/test.ts @@ -76,6 +76,7 @@ describeWithDockerCompose('redis auto instrumentation', { workingDirectory: [__d // statement is reported through `db.query.text` alone and the name becomes // `{db.operation.name} {server.address}:{server.port}`. const COMMON_ATTRIBUTES = { + 'sentry.is_localhost': { type: 'boolean', value: false }, 'db.system.name': { type: 'string', value: 'redis' }, 'server.address': { type: 'string', value: 'localhost' }, 'server.port': { type: 'integer', value: 6380 }, diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 227c4c375d3c..a00494c5a237 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -51,12 +51,24 @@ export const httpContextIntegration = defineIntegration(() => { client.getDataCollectionOptions().httpHeaders.request, ); const referer = headers['Referer']; + const { hostname, protocol } = WINDOW.location || {}; safeSetSpanJSONAttributes(span, { // This attribute is used by the "Filter out events from legacy browsers and crawlers" features on the Sentry backend. // Therefore, it's set on every span. [USER_AGENT_ORIGINAL]: headers['User-Agent'], + // Likewise for the "Filter out localhost events" feature. Deliberately inlined rather than + // sharing the server-side helper, which costs bundle size for request headers and IPs that + // don't exist here. Mirrors Relay's localhost filter (relay-filter/src/localhost.rs). + 'sentry.is_localhost': + protocol === 'file:' || + hostname === 'localhost' || + hostname === '127.0.0.1' || + // `location.hostname` keeps the brackets for IPv6 hosts, so this is `[::1]`, not `::1`. + hostname === '[::1]' || + !!hostname?.endsWith('.localhost'), + // These attributes, we only need on the segment span (analogous to the `request` context for events) ...(span.is_segment && { // Coerce empty string to undefined so the helper's nullish check drops it, diff --git a/packages/browser/test/integrations/httpcontext.test.ts b/packages/browser/test/integrations/httpcontext.test.ts index ff1894c4f521..e02221301ea1 100644 --- a/packages/browser/test/integrations/httpcontext.test.ts +++ b/packages/browser/test/integrations/httpcontext.test.ts @@ -42,6 +42,7 @@ describe('httpContextIntegration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', 'http.request.header.referer': ['https://example.com'], 'user_agent.original': USER_AGENT, + 'sentry.is_localhost': false, }); }); @@ -63,6 +64,7 @@ describe('httpContextIntegration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', 'http.request.header.referer': ['https://example.com'], 'user_agent.original': USER_AGENT, + 'sentry.is_localhost': false, 'url.full': 'https://example.com', }); }); @@ -81,6 +83,7 @@ describe('httpContextIntegration', () => { expect(span.attributes).toEqual({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.click', 'user_agent.original': USER_AGENT, + 'sentry.is_localhost': false, }); }); @@ -165,6 +168,7 @@ describe('httpContextIntegration', () => { expect(span.attributes).toEqual({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + 'sentry.is_localhost': false, 'url.full': 'https://example.com', }); }); @@ -185,7 +189,48 @@ describe('httpContextIntegration', () => { 'url.full': 'https://example.com', 'http.request.header.referer': ['[Filtered]'], 'user_agent.original': USER_AGENT, + 'sentry.is_localhost': false, }); }); }); + + describe('sentry.is_localhost', () => { + function processSpanWithLocation(location: Partial): StreamedSpanJSON['attributes'] { + const original = globalThis.location; + globalThis.location = location as Location; + + try { + const span: Partial = { attributes: {} }; + httpContextIntegration().processSpan!( + span as StreamedSpanJSON, + new BrowserClient(getDefaultBrowserClientOptions()), + ); + return span.attributes; + } finally { + globalThis.location = original; + } + } + + it.each([ + { protocol: 'http:', hostname: 'localhost' }, + { protocol: 'http:', hostname: '127.0.0.1' }, + { protocol: 'http:', hostname: 'foo.localhost' }, + { protocol: 'http:', hostname: 'foo.bar.localhost' }, + { protocol: 'file:', hostname: '' }, + // `location.hostname` keeps the brackets for IPv6 hosts. + { protocol: 'http:', hostname: '[::1]' }, + ])('is true for $protocol//$hostname', location => { + expect(processSpanWithLocation(location)).toMatchObject({ 'sentry.is_localhost': true }); + }); + + it.each([ + { protocol: 'https:', hostname: 'example.com' }, + { protocol: 'https:', hostname: 'localhost.com' }, + { protocol: 'https:', hostname: 'foolocalhost' }, + { protocol: 'https:', hostname: '127.0.0.2' }, + { protocol: 'http:', hostname: '[::2]' }, + ])('is false for $protocol//$hostname', location => { + expect(processSpanWithLocation(location)).toMatchObject({ 'sentry.is_localhost': false }); + }); + }); }); diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 7f129857857d..331c2598e389 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -2,6 +2,7 @@ import type { Client } from '../client'; import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; +import type { SdkProcessingMetadata } from '../scope'; import type { CollectBehavior, ResolvedDataCollection } from '../types/datacollection'; import type { Event } from '../types/event'; import type { IntegrationFn } from '../types/integration'; @@ -10,6 +11,7 @@ import type { StreamedSpanJSON } from '../types/span'; import { cookiePairsToRecord, parseCookieHeader } from '../utils/cookie'; import { SENSITIVE_COOKIE_NAME_SNIPPETS } from '../utils/data-collection/filtering-snippets'; import { filterKeyValueData } from '../utils/data-collection/filterKeyValueData'; +import { isLocalhostRequest } from '../utils/localhost'; import { filterQueryParams } from '../utils/data-collection/filterQueryParams'; import { filterUrlQuery } from '../utils/data-collection/filterUrlQuery'; import { filterCookiePairs, httpHeadersToSpanAttributes } from '../utils/request'; @@ -92,6 +94,15 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return event; }, + processSpan(span) { + const { user, sdkProcessingMetadata } = getIsolationScope().getScopeData(); + + // This attribute is used by the "Filter out localhost events" feature on the Sentry backend. + // Therefore, it's set on every span, not just the segment span. + safeSetSpanJSONAttributes(span, { + 'sentry.is_localhost': isLocalhostSpan(sdkProcessingMetadata, user.ip_address), + }); + }, processSegmentSpan(span, client) { const { sdkProcessingMetadata = {} } = getIsolationScope().getScopeData(); const { normalizedRequest, ipAddress } = sdkProcessingMetadata; @@ -107,6 +118,40 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = }; }) satisfies IntegrationFn; +// Resolving the client IP walks a dozen forwarding headers, so the verdict is computed once per +// request and shared by every span of that request. +const localhostByRequest = new WeakMap(); + +/** + * Whether a span belongs to a request served from the developer's own machine. + * + * The client IP is resolved exactly as {@link addNormalizedRequestDataToEvent} resolves the IP it + * writes to `user.ip_address`, so a span and the event for the same request always agree. Crucially + * a forwarding header wins over `ipAddress`, which is the raw socket address: a reverse proxy on the + * same host connects over loopback, so trusting the socket would mark genuine production traffic as + * localhost and let the backend filter silently drop it. + */ +function isLocalhostSpan(sdkProcessingMetadata: SdkProcessingMetadata, scopeUserIpAddress?: string | null): boolean { + const { normalizedRequest, ipAddress } = sdkProcessingMetadata; + + if (!normalizedRequest) { + return isLocalhostRequest(undefined, ipAddress || scopeUserIpAddress); + } + + const cached = localhostByRequest.get(normalizedRequest); + if (cached !== undefined) { + return cached; + } + + const headers = normalizedRequest.headers; + const clientIpAddress = (headers && getClientIPAddress(headers)) || ipAddress || scopeUserIpAddress; + const isLocalhost = isLocalhostRequest(normalizedRequest, clientIpAddress); + + localhostByRequest.set(normalizedRequest, isLocalhost); + + return isLocalhost; +} + /** * Add data about a request to an event. Primarily for use in Node-based SDKs, but included in `@sentry/core` * so it can be used in cross-platform SDKs like `@sentry/nextjs`. diff --git a/packages/core/src/utils/localhost.ts b/packages/core/src/utils/localhost.ts new file mode 100644 index 000000000000..82d002d24ae6 --- /dev/null +++ b/packages/core/src/utils/localhost.ts @@ -0,0 +1,57 @@ +import type { RequestEventData } from '../types/request'; +import { isURLObjectRelative, parseStringToURLObject } from './url'; + +// These mirror Relay's inbound localhost filter (relay-filter/src/localhost.rs) exactly. Any +// divergence would make the SDK and Relay classify the same traffic differently, so do not blindly +// "improve" this list (no 0.0.0.0, no .local/.test, no private LAN ranges, no 127.0.0.0/8). + +const LOCAL_IPS = ['127.0.0.1', '::1']; +const LOCAL_DOMAINS = ['127.0.0.1', 'localhost']; +const HOST_HEADERS = ['host', 'x-forwarded-host']; + +/** + * Whether a request looks like it was served from the developer's own machine. + * + * Ported from Relay's localhost inbound filter so that spans and events are judged by the same rule. + */ +export function isLocalhostRequest(request: RequestEventData | undefined, ipAddress?: string | null): boolean { + if (ipAddress && LOCAL_IPS.includes(ipAddress)) { + return true; + } + + const url = request?.url ? parseStringToURLObject(request.url) : undefined; + if (url && !isURLObjectRelative(url)) { + // Checked before the hostname, which is empty for `file:///path/to/index.html`. + if (url.protocol === 'file:') { + return true; + } + + if (LOCAL_DOMAINS.some(domain => hostMatchesOrIsSubdomainOf(url.hostname, domain))) { + return true; + } + } + + const headers = request?.headers; + if (headers) { + // HTTP header names are case-insensitive. Node and the Fetch API lower-case them, but + // `normalizedRequest` can also be built by hand, so the lookup does not rely on that. + for (const [name, value] of Object.entries(headers)) { + if (!HOST_HEADERS.includes(name.toLowerCase())) { + continue; + } + + // Host headers usually look like "localhost:3000", so drop the port. Relay compares the result + // exactly rather than via `hostMatchesOrIsSubdomainOf`, so "foo.localhost" here does not match. + const host = value?.split(':')[0]; + if (host && LOCAL_DOMAINS.includes(host)) { + return true; + } + } + } + + return false; +} + +function hostMatchesOrIsSubdomainOf(host: string, domain: string): boolean { + return host === domain || host.endsWith(`.${domain}`); +} diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index 549e8aa156b5..dd3f024899b0 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -1321,3 +1321,137 @@ describe('requestDataIntegration userInfo collection', () => { }); }); }); + +describe('requestDataIntegration processSpan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'GET /test', + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes: {}, + ...overrides, + }; + } + + function mockIsolationScope( + normalizedRequest?: Record, + user: Record = {}, + ipAddress?: string, + ): void { + vi.spyOn(currentScopes, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ user, sdkProcessingMetadata: { normalizedRequest, ipAddress } }), + } as ReturnType); + } + + it.each([true, false])('sets sentry.is_localhost on spans with is_segment: %s', isSegment => { + const integration = requestDataIntegration(); + const span = makeSpan({ is_segment: isSegment }); + + mockIsolationScope({ url: 'http://localhost:3000/api' }); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); + + it('sets sentry.is_localhost to false for a remote host', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com/api', headers: { host: 'example.com' } }); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': false }); + }); + + it('falls back to the host header when the request has no url', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + // Next.js server components set `normalizedRequest` to headers only, without a url. + mockIsolationScope({ headers: { host: 'localhost:3000' } }); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); + + it('uses the scope user ip address', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope(undefined, { ip_address: '127.0.0.1' }); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); + + // `sdkProcessingMetadata.ipAddress` is the socket address the Node HTTP instrumentation records + // (`server-subscription.ts`); nothing sets `user.ip_address` on the scope for incoming requests. + it('uses the request ip address recorded by the http instrumentation', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ url: 'http://dev.example/api' }, {}, '127.0.0.1'); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); + + // A reverse proxy on the same host connects over loopback, so the socket address is local even + // though the real client is remote. Trusting it would mark production traffic as localhost. + it('prefers a forwarded client ip over the loopback socket address', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope( + { url: 'https://example.com/api', headers: { host: 'example.com', 'x-forwarded-for': '203.0.113.50' } }, + {}, + '127.0.0.1', + ); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': false }); + }); + + it('reuses the verdict across every span of the same request', () => { + const integration = requestDataIntegration(); + const normalizedRequest = { url: 'http://dev.example/api' }; + + mockIsolationScope(normalizedRequest, {}, '127.0.0.1'); + + const segment = makeSpan({ is_segment: true }); + const child = makeSpan({ is_segment: false }); + integration.processSpan!(segment, mockClient()); + integration.processSpan!(child, mockClient()); + + expect(segment.attributes).toMatchObject({ 'sentry.is_localhost': true }); + expect(child.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); + + it('sets sentry.is_localhost to false when there is no request on the scope', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope(undefined); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': false }); + }); + + it('does not overwrite an existing sentry.is_localhost attribute', () => { + const integration = requestDataIntegration(); + const span = makeSpan({ attributes: { 'sentry.is_localhost': true } }); + + mockIsolationScope({ url: 'https://example.com/api' }); + integration.processSpan!(span, mockClient()); + + expect(span.attributes).toMatchObject({ 'sentry.is_localhost': true }); + }); +}); diff --git a/packages/core/test/lib/utils/localhost.test.ts b/packages/core/test/lib/utils/localhost.test.ts new file mode 100644 index 000000000000..d9eb52be7696 --- /dev/null +++ b/packages/core/test/lib/utils/localhost.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { isLocalhostRequest } from '../../../src/utils/localhost'; + +// These mirror the test cases of Relay's localhost inbound filter +// (relay-filter/src/localhost.rs), so that both stay in sync. +describe('isLocalhostRequest', () => { + it('does not match without any request data', () => { + expect(isLocalhostRequest(undefined)).toBe(false); + expect(isLocalhostRequest({})).toBe(false); + expect(isLocalhostRequest({ headers: {} })).toBe(false); + }); + + describe('ip address', () => { + it.each(['127.0.0.1', '::1'])('matches %s', ip => { + expect(isLocalhostRequest(undefined, ip)).toBe(true); + }); + + it.each(['133.12.12.1', '2001:db8:85a3:8d3:1319:8a2e:370:7348', '', '127.0.0.2'])('does not match %s', ip => { + expect(isLocalhostRequest(undefined, ip)).toBe(false); + }); + }); + + describe('url', () => { + it.each(['localhost', '127.0.0.1', 'foo.localhost', 'foo.bar.localhost'])('matches %s', host => { + expect(isLocalhostRequest({ url: `http://${host}:8080/` })).toBe(true); + }); + + it.each(['localhost.com', 'foolocalhost', 'alocalhostgoesintoabar', 'sentry.io', 'notlocalhost.io', '127.0.0.2'])( + 'does not match %s', + host => { + expect(isLocalhostRequest({ url: `http://${host}:8080/` })).toBe(false); + }, + ); + + it('matches a file:// url', () => { + expect(isLocalhostRequest({ url: 'file:///Users/someone/index.html' })).toBe(true); + }); + + it('does not match a relative url', () => { + expect(isLocalhostRequest({ url: '/api/users' })).toBe(false); + }); + + it('does not match an unparseable url', () => { + expect(isLocalhostRequest({ url: 'not a url' })).toBe(false); + }); + }); + + describe('host headers', () => { + it.each(['host', 'x-forwarded-host'])('matches localhost in the %s header', header => { + expect(isLocalhostRequest({ headers: { [header]: 'localhost' } })).toBe(true); + expect(isLocalhostRequest({ headers: { [header]: 'localhost:3000' } })).toBe(true); + expect(isLocalhostRequest({ headers: { [header]: '127.0.0.1' } })).toBe(true); + expect(isLocalhostRequest({ headers: { [header]: '127.0.0.1:8080' } })).toBe(true); + }); + + // Relay compares host headers exactly, without the subdomain check it applies to URLs. + it.each(['host', 'x-forwarded-host'])('does not match a subdomain in the %s header', header => { + expect(isLocalhostRequest({ headers: { [header]: 'foo.localhost:3000' } })).toBe(false); + }); + + it.each(['host', 'x-forwarded-host'])('does not match a remote host in the %s header', header => { + expect(isLocalhostRequest({ headers: { [header]: 'sentry.io' } })).toBe(false); + expect(isLocalhostRequest({ headers: { [header]: 'localhost.com:3000' } })).toBe(false); + expect(isLocalhostRequest({ headers: { [header]: '' } })).toBe(false); + }); + + it('matches if either host header is local', () => { + expect(isLocalhostRequest({ headers: { host: 'localhost:3000', 'x-forwarded-host': 'example.com' } })).toBe(true); + expect(isLocalhostRequest({ headers: { host: 'example.com', 'x-forwarded-host': 'localhost:3000' } })).toBe(true); + }); + + it('falls back to the headers when the url is remote', () => { + expect(isLocalhostRequest({ url: 'https://example.com/api', headers: { host: 'localhost:3000' } })).toBe(true); + }); + }); +}); + +// Header names are matched case-insensitively, which Relay gets for free from its header map. +describe('isLocalhostRequest host header casing', () => { + it.each(['host', 'Host', 'HOST', 'hOsT'])('matches the %s header', header => { + expect(isLocalhostRequest({ headers: { [header]: 'localhost:3000' } })).toBe(true); + }); + + it.each(['x-forwarded-host', 'X-Forwarded-Host', 'X-FORWARDED-HOST', 'x-Forwarded-host'])( + 'matches the %s header', + header => { + expect(isLocalhostRequest({ headers: { [header]: 'localhost:3000' } })).toBe(true); + }, + ); + + it('still does not match unrelated headers whose name lower-cases differently', () => { + expect(isLocalhostRequest({ headers: { 'X-Original-Host': 'localhost:3000' } })).toBe(false); + expect(isLocalhostRequest({ headers: { Origin: 'localhost' } })).toBe(false); + }); +});