diff --git a/.size-limit.js b/.size-limit.js index d53eaae56712..ffaa919acebe 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -224,7 +224,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '116 KB', + limit: '51 KB', }, // Node SDK (ESM) { @@ -233,14 +233,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '147 KB', + limit: '150 KB', }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '110 KB', + limit: '95 KB', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -263,7 +263,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '105 KB', }, ]; diff --git a/CHANGELOG.md b/CHANGELOG.md index 094690568d91..0d611034ccb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -## Unreleased - ### Important Changes - **fix(browser): Ensure IP address is only inferred by Relay if `sendDefaultPii` is `true`** @@ -22,6 +20,13 @@ user IP addresses, if you set `sendDefaultPii: true` in your `Sentry.init` optio We apologize for any inconvenience caused! +- **feat(node)**: Update `httpIntegration` handling of incoming requests + +This version updates the handling of the Node SDK of incoming requests. Instead of relying on @opentelemetry/instrumentation-http for this, we now handle this internally, ensuring that we can optimize performance as much as possible and avoid interop problems. + +This change should not affect users, unless they are relying on very in-depth implementation details. Importantly, this also drops the `_experimentalConfig` option of the integration - this will no longer do anything. +Finally, you can still pass `instrumentation.{requestHook,responseHook,applyCustomAttributesOnSpan}` options, but they are deprecated and will be removed in v11. Instead, you can use the new `incomingRequestSpanHook` configuration option if you want to adjust the incoming request span. + ## 10.3.0 - feat(core): MCP Server - Capture prompt results from prompt function calls (#17284) diff --git a/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts index 227cf6042c44..350ee9714752 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts @@ -29,11 +29,11 @@ describe('express tracing', () => { // Also calling `updateName` AND setting a source doesn't change anything - Otel has no concept of source, this is sentry-internal. // Therefore, only the source is updated but the name is still overwritten by Otel. - test("calling `span.updateName` and setting attribute source doesn't update the final name in express but it updates the source", async () => { + test('calling `span.updateName` and setting attribute source updates the final name in express', async () => { const runner = createRunner(__dirname, 'server.js') .expect({ transaction: { - transaction: 'GET /test/:id/span-updateName-source', + transaction: 'new-name', transaction_info: { source: 'custom', }, diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs new file mode 100644 index 000000000000..5d838dc0b131 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -0,0 +1,48 @@ +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, + + integrations: [ + Sentry.httpIntegration({ + incomingRequestSpanHook: (span, req, res) => { + span.setAttribute('incomingRequestSpanHook', 'yes'); + Sentry.setExtra('incomingRequestSpanHookCalled', { + reqUrl: req.url, + reqMethod: req.method, + resUrl: res.req.url, + resMethod: res.req.method, + }); + }, + instrumentation: { + requestHook: (span, req) => { + span.setAttribute('attr1', 'yes'); + Sentry.setExtra('requestHookCalled', { + url: req.url, + method: req.method, + }); + }, + responseHook: (span, res) => { + span.setAttribute('attr2', 'yes'); + Sentry.setExtra('responseHookCalled', { + url: res.req.url, + method: res.req.method, + }); + }, + applyCustomAttributesOnSpan: (span, req, res) => { + span.setAttribute('attr3', 'yes'); + Sentry.setExtra('applyCustomAttributesOnSpanCalled', { + reqUrl: req.url, + reqMethod: req.method, + resUrl: res.req.url, + resMethod: res.req.method, + }); + }, + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs index 8cf2e8a5248f..46a27dd03b74 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs @@ -4,38 +4,6 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], tracesSampleRate: 1.0, transport: loggingTransport, - - integrations: [ - Sentry.httpIntegration({ - instrumentation: { - requestHook: (span, req) => { - span.setAttribute('attr1', 'yes'); - Sentry.setExtra('requestHookCalled', { - url: req.url, - method: req.method, - }); - }, - responseHook: (span, res) => { - span.setAttribute('attr2', 'yes'); - Sentry.setExtra('responseHookCalled', { - url: res.req.url, - method: res.req.method, - }); - }, - applyCustomAttributesOnSpan: (span, req, res) => { - span.setAttribute('attr3', 'yes'); - Sentry.setExtra('applyCustomAttributesOnSpanCalled', { - reqUrl: req.url, - reqMethod: req.method, - resUrl: res.req.url, - resMethod: res.req.method, - }); - }, - }, - }), - ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js deleted file mode 100644 index 9b4e62766f4e..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-experimental.js +++ /dev/null @@ -1,38 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, - - integrations: [ - Sentry.httpIntegration({ - instrumentation: { - _experimentalConfig: { - serverName: 'sentry-test-server-name', - }, - }, - }), - ], -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test', (_req, res) => { - res.send({ response: 'response 1' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs index 44122f375857..37e629758828 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs @@ -11,6 +11,10 @@ app.get('/test', (_req, res) => { res.send({ response: 'response 1' }); }); +app.post('/test', (_req, res) => { + res.send({ response: 'response 2' }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 97043c998814..fd1e189461b1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -7,72 +7,167 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { - test('allows to pass instrumentation options to integration', async () => { - const runner = createRunner() - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { + describe('instrumentation options', () => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { + test('allows to pass instrumentation options to integration', async () => { + const runner = createRunner() + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + attr1: 'yes', + attr2: 'yes', + attr3: 'yes', + }, + op: 'http.server', + status: 'ok', + }, + }, + extra: { + requestHookCalled: { url: expect.stringMatching(/\/test$/), - 'http.response.status_code': 200, - attr1: 'yes', - attr2: 'yes', - attr3: 'yes', + method: 'GET', + }, + responseHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + applyCustomAttributesOnSpanCalled: { + reqUrl: expect.stringMatching(/\/test$/), + reqMethod: 'GET', + resUrl: expect.stringMatching(/\/test$/), + resMethod: 'GET', }, - op: 'http.server', - status: 'ok', }, }, - extra: { - requestHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - responseHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - applyCustomAttributesOnSpanCalled: { - reqUrl: expect.stringMatching(/\/test$/), - reqMethod: 'GET', - resUrl: expect.stringMatching(/\/test$/), - resMethod: 'GET', + }) + .start(); + runner.makeRequest('get', '/test'); + await runner.completed(); + }); + + test('allows to configure incomingRequestSpanHook', async () => { + const runner = createRunner() + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + incomingRequestSpanHook: 'yes', + }, + op: 'http.server', + status: 'ok', + }, }, + extra: expect.objectContaining({ + incomingRequestSpanHookCalled: { + reqUrl: expect.stringMatching(/\/test$/), + reqMethod: 'GET', + resUrl: expect.stringMatching(/\/test$/), + resMethod: 'GET', + }, + }), }, - }, - }) - .start(); - runner.makeRequest('get', '/test'); - await runner.completed(); + }) + .start(); + runner.makeRequest('get', '/test'); + await runner.completed(); + }); }); }); - test('allows to pass experimental config through to integration', async () => { - const runner = createRunner(__dirname, 'server-experimental.js') - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - url: expect.stringMatching(/\/test$/), + describe('http.server spans', () => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures correct attributes for GET requests', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const port = runner.getPort(); + expect(transaction.transaction).toBe('GET /test'); + expect(transaction.contexts?.trace?.data).toEqual({ + 'http.flavor': '1.1', + 'http.host': `localhost:${port}`, + 'http.method': 'GET', + 'http.query': 'a=1&b=2', 'http.response.status_code': 200, - 'http.server_name': 'sentry-test-server-name', - }, - op: 'http.server', - status: 'ok', + 'http.route': '/test', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test?a=1&b=2', + 'http.url': `http://localhost:${port}/test?a=1&b=2`, + 'http.user_agent': 'node', + 'net.host.ip': '::1', + 'net.host.name': 'localhost', + 'net.host.port': port, + 'net.peer.ip': '::1', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: `http://localhost:${port}/test`, + }); }, - }, - }, - }) - .start(); - runner.makeRequest('get', '/test'); - await runner.completed(); + }) + .start(); + + runner.makeRequest('get', '/test?a=1&b=2#hash'); + await runner.completed(); + }); + + test('captures correct attributes for POST requests', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const port = runner.getPort(); + expect(transaction.transaction).toBe('POST /test'); + expect(transaction.contexts?.trace?.data).toEqual({ + 'http.flavor': '1.1', + 'http.host': `localhost:${port}`, + 'http.method': 'POST', + 'http.query': 'a=1&b=2', + 'http.request_content_length_uncompressed': 9, + 'http.response.status_code': 200, + 'http.route': '/test', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test?a=1&b=2', + 'http.url': `http://localhost:${port}/test?a=1&b=2`, + 'http.user_agent': 'node', + 'net.host.ip': '::1', + 'net.host.name': 'localhost', + 'net.host.port': port, + 'net.peer.ip': '::1', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: `http://localhost:${port}/test`, + }); + }, + }) + .start(); + + runner.makeRequest('post', '/test?a=1&b=2#hash', { data: 'test body' }); + await runner.completed(); + }); + }); }); describe("doesn't create a root span for incoming requests ignored via `ignoreIncomingRequests`", () => { diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index f4a176688280..164af83518bc 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -158,6 +158,7 @@ type StartResult = { completed(): Promise; childHasExited(): boolean; getLogs(): string[]; + getPort(): number | undefined; makeRequest( method: 'get' | 'post', path: string, @@ -617,6 +618,9 @@ export function createRunner(...paths: string[]) { getLogs(): string[] { return logs; }, + getPort(): number | undefined { + return scenarioServerPort; + }, makeRequest: async function ( method: 'get' | 'post', path: string, diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 805fb275047c..72aabfaa11e5 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -2,6 +2,7 @@ import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; import type * as https from 'node:https'; +import type { Span } from '@opentelemetry/api'; import { context } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; @@ -28,12 +29,22 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ breadcrumbs?: boolean; + /** + * Whether to create spans for requests or not. + * As of now, creates spans for incoming requests, but not outgoing requests. + * + * @default `true` + */ + spans?: boolean; + /** * Whether to extract the trace ID from the `sentry-trace` header for incoming requests. * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled, ...) * then this instrumentation can take over. * - * @default `false` + * @deprecated This is always true and the option will be removed in the future. + * + * @default `true` */ extractIncomingTraceFromHeader?: boolean; @@ -46,6 +57,20 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ propagateTraceInOutgoingRequests?: boolean; + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * This helps reduce noise in your transactions. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * If true, do not generate spans for incoming requests at all. + * This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans. + */ + disableIncomingRequestSpans?: boolean; + /** * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. * For the scope of this instrumentation, this callback only controls breadcrumb creation. @@ -57,6 +82,14 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * + * @param urlPath Contains the URL path and query string (if any) of the incoming request. + * @param request Contains the {@type IncomingMessage} object of the incoming request. + */ + ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean; + /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. @@ -66,6 +99,12 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, but before it is recorded. + */ + incomingRequestSpanHook?: (span: Span, request: http.IncomingMessage, response: http.ServerResponse) => void; + /** * Controls the maximum size of incoming HTTP request bodies attached to events. * @@ -90,6 +129,19 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ trackIncomingRequestsAsSessions?: boolean; + /** + * @deprecated This is deprecated in favor of `incomingRequestSpanHook`. + */ + instrumentation?: { + requestHook?: (span: Span, req: http.ClientRequest | http.IncomingMessage) => void; + responseHook?: (span: Span, response: http.IncomingMessage | http.ServerResponse) => void; + applyCustomAttributesOnSpan?: ( + span: Span, + request: http.ClientRequest | http.IncomingMessage, + response: http.IncomingMessage | http.ServerResponse, + ) => void; + }; + /** * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. * @@ -128,13 +180,21 @@ export class SentryHttpInstrumentation extends InstrumentationBase { const data = _data as { server: http.Server }; instrumentServer(data.server, { + // eslint-disable-next-line deprecation/deprecation + instrumentation: this.getConfig().instrumentation, ignoreIncomingRequestBody: this.getConfig().ignoreIncomingRequestBody, + ignoreSpansForIncomingRequests: this.getConfig().ignoreSpansForIncomingRequests, + incomingRequestSpanHook: this.getConfig().incomingRequestSpanHook, maxIncomingRequestBodySize: this.getConfig().maxIncomingRequestBodySize, trackIncomingRequestsAsSessions: this.getConfig().trackIncomingRequestsAsSessions, sessionFlushingDelayMS: this.getConfig().sessionFlushingDelayMS ?? 60_000, + ignoreStaticAssets: this.getConfig().ignoreStaticAssets, + spans: spansEnabled && !this.getConfig().disableIncomingRequestSpans, }); }) satisfies ChannelListener; diff --git a/packages/node-core/src/integrations/http/incoming-requests.ts b/packages/node-core/src/integrations/http/incoming-requests.ts index 2d18d1c064c4..c906c284b86b 100644 --- a/packages/node-core/src/integrations/http/incoming-requests.ts +++ b/packages/node-core/src/integrations/http/incoming-requests.ts @@ -1,5 +1,17 @@ -import { context, propagation } from '@opentelemetry/api'; -import type { AggregationCounts, Client, Scope } from '@sentry/core'; +/* eslint-disable max-lines */ +import type { Span } from '@opentelemetry/api'; +import { context, propagation, SpanKind, trace } from '@opentelemetry/api'; +import type { RPCMetadata } from '@opentelemetry/core'; +import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core'; +import { + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_ROUTE, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_NET_HOST_IP, + SEMATTRS_NET_HOST_PORT, + SEMATTRS_NET_PEER_IP, +} from '@opentelemetry/semantic-conventions'; +import type { AggregationCounts, Client, Scope, SpanAttributes } from '@sentry/core'; import { addNonEnumerableProperty, debug, @@ -7,13 +19,21 @@ import { getClient, getCurrentScope, getIsolationScope, + getSpanStatusFromHttpCode, httpRequestToRequestData, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; import type EventEmitter from 'events'; -import type { IncomingMessage, OutgoingMessage, Server } from 'http'; +import { errorMonitor } from 'events'; +import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from 'http'; +import type { Socket } from 'net'; import { DEBUG_BUILD } from '../../debug-build'; +import type { NodeClient } from '../../sdk/client'; import { INSTRUMENTATION_NAME, MAX_BODY_BYTE_LENGTH } from './constants'; const clientToRequestSessionAggregatesMap = new Map< @@ -29,14 +49,34 @@ export function instrumentServer( server: Server, { ignoreIncomingRequestBody, + ignoreSpansForIncomingRequests, maxIncomingRequestBodySize = 'medium', trackIncomingRequestsAsSessions = true, + spans, + ignoreStaticAssets = true, sessionFlushingDelayMS, + // eslint-disable-next-line deprecation/deprecation + instrumentation, + incomingRequestSpanHook, }: { ignoreIncomingRequestBody?: (url: string, request: IncomingMessage) => boolean; + ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; maxIncomingRequestBodySize?: 'small' | 'medium' | 'always' | 'none'; trackIncomingRequestsAsSessions?: boolean; sessionFlushingDelayMS: number; + spans: boolean; + ignoreStaticAssets?: boolean; + incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + /** @deprecated Use `incomingRequestSpanHook` instead. */ + instrumentation?: { + requestHook?: (span: Span, req: IncomingMessage | ClientRequest) => void; + responseHook?: (span: Span, response: ServerResponse | IncomingMessage) => void; + applyCustomAttributesOnSpan?: ( + span: Span, + request: IncomingMessage | ClientRequest, + response: ServerResponse | IncomingMessage, + ) => void; + }; }, ): void { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -47,6 +87,35 @@ export function instrumentServer( return; } + const { requestHook, responseHook, applyCustomAttributesOnSpan } = instrumentation ?? {}; + + function shouldIgnoreSpansForIncomingRequest(request: IncomingMessage): boolean { + if (isTracingSuppressed(context.active())) { + return true; + } + + // request.url is the only property that holds any information about the url + // it only consists of the URL path and query string (if any) + const urlPath = request.url; + + const method = request.method?.toUpperCase(); + // We do not capture OPTIONS/HEAD requests as spans + if (method === 'OPTIONS' || method === 'HEAD' || !urlPath) { + return true; + } + + // Default static asset filtering + if (ignoreStaticAssets && method === 'GET' && isStaticAssetRequest(urlPath)) { + return true; + } + + if (ignoreSpansForIncomingRequests?.(urlPath, request)) { + return true; + } + + return false; + } + const newEmit = new Proxy(originalEmit, { apply(target, thisArg, args: [event: string, ...args: unknown[]]) { // Only traces request events @@ -56,9 +125,10 @@ export function instrumentServer( DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling incoming request'); + const client = getClient(); const isolationScope = getIsolationScope().clone(); const request = args[1] as IncomingMessage; - const response = args[2] as OutgoingMessage; + const response = args[2] as ServerResponse & { socket: Socket }; const normalizedRequest = httpRequestToRequestData(request); @@ -66,7 +136,7 @@ export function instrumentServer( const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress; const url = request.url || '/'; - if (!ignoreIncomingRequestBody?.(url, request) && maxIncomingRequestBodySize !== 'none') { + if (maxIncomingRequestBodySize !== 'none' && !ignoreIncomingRequestBody?.(url, request)) { patchRequestToCaptureBody(request, isolationScope, maxIncomingRequestBodySize); } @@ -77,9 +147,9 @@ export function instrumentServer( // Ideally, framework instrumentations coming after the HttpInstrumentation // update the transactionName once we get a parameterized route. const httpMethod = (request.method || 'GET').toUpperCase(); - const httpTarget = stripUrlQueryAndFragment(url); + const httpTargetWithoutQueryFragment = stripUrlQueryAndFragment(url); - const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; + const bestEffortTransactionName = `${httpMethod} ${httpTargetWithoutQueryFragment}`; isolationScope.setTransactionName(bestEffortTransactionName); @@ -98,8 +168,95 @@ export function instrumentServer( getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); const ctx = propagation.extract(context.active(), normalizedRequest.headers); + return context.with(ctx, () => { - return target.apply(thisArg, args); + // if opting out of span creation, we can end here + if (!spans || shouldIgnoreSpansForIncomingRequest(request) || !client) { + return target.apply(thisArg, args); + } + + const fullUrl = normalizedRequest.url || url; + const urlObj = parseStringToURLObject(fullUrl); + + const headers = request.headers; + const userAgent = headers['user-agent']; + const ips = headers['x-forwarded-for']; + const httpVersion = request.httpVersion; + const host = headers.host; + const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; + + const tracer = client.tracer; + const scheme = fullUrl.startsWith('https') ? 'https' : 'http'; + + // We use the plain tracer.startSpan here so we can pass the span kind + const span = tracer.startSpan(bestEffortTransactionName, { + kind: SpanKind.SERVER, + attributes: { + // Sentry specific attributes + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + 'sentry.http.prefetch': isKnownPrefetchRequest(request) || undefined, + // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before + 'http.url': fullUrl, + 'http.method': httpMethod, + 'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, + 'http.host': host, + 'net.host.name': hostname, + 'http.client_ip': typeof ips === 'string' ? ips.split(',')[0] : undefined, + 'http.user_agent': userAgent, + 'http.scheme': scheme, + 'http.flavor': httpVersion, + 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', + ...getRequestContentLengthAttribute(request), + }, + }); + + // TODO v11: Remove the following three hooks, only incomingRequestSpanHook should remain + requestHook?.(span, request); + responseHook?.(span, response); + applyCustomAttributesOnSpan?.(span, request, response); + incomingRequestSpanHook?.(span, request, response); + + const rpcMetadata: RPCMetadata = { + type: RPCType.HTTP, + span, + }; + + context.with(setRPCMetadata(trace.setSpan(context.active(), span), rpcMetadata), () => { + context.bind(context.active(), request); + context.bind(context.active(), response); + + // After 'error', no further events other than 'close' should be emitted. + let isEnded = false; + response.on('close', () => { + if (isEnded) { + return; + } + + isEnded = true; + const newAttributes = getIncomingRequestAttributesOnResponse(request, response); + span.setAttributes(newAttributes); + span.setStatus(getSpanStatusFromHttpCode(response.statusCode)); + + span.end(); + }); + response.on(errorMonitor, () => { + if (isEnded) { + return; + } + + isEnded = true; + const newAttributes = getIncomingRequestAttributesOnResponse(request, response); + span.setAttributes(newAttributes); + + const status = getSpanStatusFromHttpCode(response.statusCode); + + span.setStatus(status.code === SPAN_STATUS_ERROR ? status : { code: SPAN_STATUS_ERROR }); + span.end(); + }); + + return target.apply(thisArg, args); + }); }); }); }, @@ -302,3 +459,96 @@ function patchRequestToCaptureBody( } } } + +function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes { + const length = getContentLength(request.headers); + if (length == null) { + return {}; + } + + if (isCompressed(request.headers)) { + return { + ['http.request_content_length']: length, + }; + } else { + return { + ['http.request_content_length_uncompressed']: length, + }; + } +} + +function getContentLength(headers: IncomingHttpHeaders): number | null { + const contentLengthHeader = headers['content-length']; + if (contentLengthHeader === undefined) return null; + + const contentLength = parseInt(contentLengthHeader as string, 10); + if (isNaN(contentLength)) return null; + + return contentLength; +} + +function isCompressed(headers: IncomingHttpHeaders): boolean { + const encoding = headers['content-encoding']; + + return !!encoding && encoding !== 'identity'; +} + +function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes { + // take socket from the request, + // since it may be detached from the response object in keep-alive mode + const { socket } = request; + const { statusCode, statusMessage } = response; + + const newAttributes: SpanAttributes = { + [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode, + // eslint-disable-next-line deprecation/deprecation + [SEMATTRS_HTTP_STATUS_CODE]: statusCode, + 'http.status_text': statusMessage?.toUpperCase(), + }; + + const rpcMetadata = getRPCMetadata(context.active()); + if (socket) { + const { localAddress, localPort, remoteAddress, remotePort } = socket; + // eslint-disable-next-line deprecation/deprecation + newAttributes[SEMATTRS_NET_HOST_IP] = localAddress; + // eslint-disable-next-line deprecation/deprecation + newAttributes[SEMATTRS_NET_HOST_PORT] = localPort; + // eslint-disable-next-line deprecation/deprecation + newAttributes[SEMATTRS_NET_PEER_IP] = remoteAddress; + newAttributes['net.peer.port'] = remotePort; + } + // eslint-disable-next-line deprecation/deprecation + newAttributes[SEMATTRS_HTTP_STATUS_CODE] = statusCode; + newAttributes['http.status_text'] = (statusMessage || '').toUpperCase(); + + if (rpcMetadata?.type === RPCType.HTTP && rpcMetadata.route !== undefined) { + newAttributes[ATTR_HTTP_ROUTE] = rpcMetadata.route; + } + + return newAttributes; +} + +function isKnownPrefetchRequest(req: IncomingMessage): boolean { + // Currently only handles Next.js prefetch requests but may check other frameworks in the future. + return req.headers['next-router-prefetch'] === '1'; +} + +/** + * Check if a request is for a common static asset that should be ignored by default. + * + * Only exported for tests. + */ +export function isStaticAssetRequest(urlPath: string): boolean { + const path = stripUrlQueryAndFragment(urlPath); + // Common static file extensions + if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) { + return true; + } + + // Common metadata files + if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) { + return true; + } + + return false; +} diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 7e574712990d..9020030da2ad 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -13,6 +13,14 @@ interface HttpOptions { */ breadcrumbs?: boolean; + /** + * Whether to create spans for requests or not. + * As of now, creates spans for incoming requests, but not outgoing requests. + * + * @default `true` + */ + spans?: boolean; + /** * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. * Read more about Release Health: https://docs.sentry.io/product/releases/health/ @@ -115,7 +123,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => setupOnce() { instrumentSentryHttp({ ...options, - extractIncomingTraceFromHeader: true, + ignoreSpansForIncomingRequests: options.ignoreIncomingRequests, + // TODO(v11): Rethink this, for now this is for backwards compatibility + disableIncomingRequestSpans: true, propagateTraceInOutgoingRequests: true, }); }, diff --git a/packages/node-core/test/integrations/http.test.ts b/packages/node-core/test/integrations/http.test.ts new file mode 100644 index 000000000000..01124327a030 --- /dev/null +++ b/packages/node-core/test/integrations/http.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { isStaticAssetRequest } from '../../src/integrations/http/incoming-requests'; + +describe('httpIntegration', () => { + describe('isStaticAssetRequest', () => { + it.each([ + ['/favicon.ico', true], + ['/apple-touch-icon.png', true], + ['/static/style.css', true], + ['/assets/app.js', true], + ['/fonts/font.woff2', true], + ['/images/logo.svg', true], + ['/img/photo.jpeg', true], + ['/img/photo.jpg', true], + ['/img/photo.jpg?v=123', true], + ['/img/photo.webp', true], + ['/font/font.ttf', true], + ['/robots.txt', true], + ['/sitemap.xml', true], + ['/manifest.json', true], + ['/browserconfig.xml', true], + // non-static routes + ['/api/users', false], + ['/some-json.json', false], + ['/some-xml.xml', false], + ['/some-txt.txt', false], + ['/users', false], + ['/graphql', false], + ['/', false], + ])('returns %s -> %s', (urlPath, expected) => { + expect(isStaticAssetRequest(urlPath)).toBe(expected); + }); + }); +}); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 4f1eeeea9e9c..db2f28397167 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -3,7 +3,7 @@ import { diag } from '@opentelemetry/api'; import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import type { Span } from '@sentry/core'; -import { defineIntegration, getClient, hasSpansEnabled, stripUrlQueryAndFragment } from '@sentry/core'; +import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core'; import type { HTTPModuleRequestIncomingMessage, NodeClient } from '@sentry/node-core'; import { type SentryHttpInstrumentationOptions, @@ -133,12 +133,6 @@ interface HttpOptions { request: ClientRequest | HTTPModuleRequestIncomingMessage, response: HTTPModuleRequestIncomingMessage | ServerResponse, ) => void; - - /** - * You can pass any configuration through to the underlying instrumentation. - * Note that there are no semver guarantees for this! - */ - _experimentalConfig?: ConstructorParameters[0]; }; } @@ -167,7 +161,10 @@ export const instrumentOtelHttp = generateInstrumentOnce = {}): boolean { +export function _shouldUseOtelHttpInstrumentation( + options: HttpOptions, + clientOptions: Partial = {}, +): boolean { // If `spans` is passed in, it takes precedence // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled if (typeof options.spans === 'boolean') { @@ -200,21 +197,22 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, setupOnce() { - const instrumentSpans = _shouldInstrumentSpans(options, getClient()?.getOptions()); + const clientOptions = (getClient()?.getOptions() || {}) as Partial; + const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); + const disableIncomingRequestSpans = options.disableIncomingRequestSpans ?? !hasSpansEnabled(clientOptions); // This is Sentry-specific instrumentation for request isolation and breadcrumbs instrumentSentryHttp({ ...options, - // If spans are not instrumented, it means the HttpInstrumentation has not been added - // In that case, we want to handle incoming trace extraction ourselves - extractIncomingTraceFromHeader: !instrumentSpans, + disableIncomingRequestSpans, + ignoreSpansForIncomingRequests: options.ignoreIncomingRequests, // If spans are not instrumented, it means the HttpInstrumentation has not been added // In that case, we want to handle trace propagation ourselves - propagateTraceInOutgoingRequests: !instrumentSpans, + propagateTraceInOutgoingRequests: !useOtelHttpInstrumentation, }); // This is the "regular" OTEL instrumentation that emits spans - if (instrumentSpans) { + if (useOtelHttpInstrumentation) { const instrumentationConfig = getConfigWithDefaults(options); instrumentOtelHttp(instrumentationConfig); } @@ -243,28 +241,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => }; }); -/** - * Determines if @param req is a ClientRequest, meaning the request was created within the express app - * and it's an outgoing request. - * Checking for properties instead of using `instanceOf` to avoid importing the request classes. - */ -function _isClientRequest(req: ClientRequest | HTTPModuleRequestIncomingMessage): req is ClientRequest { - return 'outputData' in req && 'outputSize' in req && !('client' in req) && !('statusCode' in req); -} - -/** - * Detects if an incoming request is a prefetch request. - */ -function isKnownPrefetchRequest(req: HTTPModuleRequestIncomingMessage): boolean { - // Currently only handles Next.js prefetch requests but may check other frameworks in the future. - return req.headers['next-router-prefetch'] === '1'; -} - function getConfigWithDefaults(options: Partial = {}): HttpInstrumentationConfig { const instrumentationConfig = { - ...options.instrumentation?._experimentalConfig, - - disableIncomingRequestInstrumentation: options.disableIncomingRequestSpans, + disableIncomingRequestInstrumentation: true, ignoreOutgoingRequestHook: request => { const url = getRequestUrl(request); @@ -281,37 +260,9 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume return false; }, - ignoreIncomingRequestHook: request => { - // request.url is the only property that holds any information about the url - // it only consists of the URL path and query string (if any) - const urlPath = request.url; - - const method = request.method?.toUpperCase(); - // We do not capture OPTIONS/HEAD requests as transactions - if (method === 'OPTIONS' || method === 'HEAD') { - return true; - } - - // Default static asset filtering - if (options.ignoreStaticAssets !== false && method === 'GET' && urlPath && isStaticAssetRequest(urlPath)) { - return true; - } - - const _ignoreIncomingRequests = options.ignoreIncomingRequests; - if (urlPath && _ignoreIncomingRequests?.(urlPath, request)) { - return true; - } - - return false; - }, - requireParentforOutgoingSpans: false, - requireParentforIncomingSpans: false, requestHook: (span, req) => { addOriginToSpan(span, 'auto.http.otel.http'); - if (!_isClientRequest(req) && isKnownPrefetchRequest(req)) { - span.setAttribute('sentry.http.prefetch', true); - } options.instrumentation?.requestHook?.(span, req); }, @@ -329,23 +280,3 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume return instrumentationConfig; } - -/** - * Check if a request is for a common static asset that should be ignored by default. - * - * Only exported for tests. - */ -export function isStaticAssetRequest(urlPath: string): boolean { - const path = stripUrlQueryAndFragment(urlPath); - // Common static file extensions - if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) { - return true; - } - - // Common metadata files - if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) { - return true; - } - - return false; -} diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index 2a24464c801c..aa7b3331fef1 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { _shouldInstrumentSpans, isStaticAssetRequest } from '../../src/integrations/http'; +import { _shouldUseOtelHttpInstrumentation } from '../../src/integrations/http'; import { conditionalTest } from '../helpers/conditional'; describe('httpIntegration', () => { @@ -13,48 +13,18 @@ describe('httpIntegration', () => { [{}, { tracesSampleRate: 0, skipOpenTelemetrySetup: true }, false], [{}, { tracesSampleRate: 0 }, true], ])('returns the correct value for options=%j and clientOptions=%j', (options, clientOptions, expected) => { - const actual = _shouldInstrumentSpans(options, clientOptions); + const actual = _shouldUseOtelHttpInstrumentation(options, clientOptions); expect(actual).toBe(expected); }); conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22', () => { - const actual = _shouldInstrumentSpans({}, {}); + const actual = _shouldUseOtelHttpInstrumentation({}, {}); expect(actual).toBe(false); }); conditionalTest({ max: 21 })('returns true without tracesSampleRate on Node <22', () => { - const actual = _shouldInstrumentSpans({}, {}); + const actual = _shouldUseOtelHttpInstrumentation({}, {}); expect(actual).toBe(true); }); }); - - describe('isStaticAssetRequest', () => { - it.each([ - ['/favicon.ico', true], - ['/apple-touch-icon.png', true], - ['/static/style.css', true], - ['/assets/app.js', true], - ['/fonts/font.woff2', true], - ['/images/logo.svg', true], - ['/img/photo.jpeg', true], - ['/img/photo.jpg', true], - ['/img/photo.jpg?v=123', true], - ['/img/photo.webp', true], - ['/font/font.ttf', true], - ['/robots.txt', true], - ['/sitemap.xml', true], - ['/manifest.json', true], - ['/browserconfig.xml', true], - // non-static routes - ['/api/users', false], - ['/some-json.json', false], - ['/some-xml.xml', false], - ['/some-txt.txt', false], - ['/users', false], - ['/graphql', false], - ['/', false], - ])('returns %s -> %s', (urlPath, expected) => { - expect(isStaticAssetRequest(urlPath)).toBe(expected); - }); - }); }); diff --git a/yarn.lock b/yarn.lock index a79397ae7beb..5e0eafdb61fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6924,7 +6924,7 @@ mitt "^3.0.0" "@sentry-internal/test-utils@link:dev-packages/test-utils": - version "10.2.0" + version "10.3.0" dependencies: express "^4.21.1" @@ -6966,81 +6966,41 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.49.0.tgz#290657e5840b360cb8ca25c8a78f8c0f15c66b03" - integrity sha512-bgowyDeFuXbjkGq1ZKqcWhmzgfBe7oKIXYWJOOps4+32QfG+YsrdNnottHS01td3bzrJq0QnHj8H12fA81DqrA== - "@sentry/cli-darwin@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.50.2.tgz#fcf924fcc02cfa54748ff07a380334e533635c74" integrity sha512-0Pjpl0vQqKhwuZm19z6AlEF+ds3fJg1KWabv8WzGaSc/fwxMEwjFwOZj+IxWBJPV578cXXNvB39vYjjpCH8j7A== -"@sentry/cli-linux-arm64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.49.0.tgz#a732004d7131f7e7b44f6a64abdccc36efb35d52" - integrity sha512-dqxsDUd76aDm03fUwUOs5BR7RHLpSb2EH/B1hlWm0mFvo9uY907XxW9wDFx/qDpCdmpC0aF+lF/lOBOrG9B5Fg== - "@sentry/cli-linux-arm64@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.50.2.tgz#ac9e6dba42095832bac8084abab4b86fdd2956f3" integrity sha512-03Cj215M3IdoHAwevCxm5oOm9WICFpuLR05DQnODFCeIUsGvE1pZsc+Gm0Ky/ZArq2PlShBJTpbHvXbCUka+0w== -"@sentry/cli-linux-arm@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.49.0.tgz#73719561510df3369e05e9a4898b4e43b8753e4c" - integrity sha512-RBDIjIGmNsFw+a6vAt6m3D7ROKsMEB9i3u+UuIRxk0/DyHTcfVWxnK/ScPXGILM6PxQ2XOBfOKad0mmiDHBzZA== - "@sentry/cli-linux-arm@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.50.2.tgz#835acd53ca83f6be9fc0d3d85a3cd4c694051bce" integrity sha512-jzFwg9AeeuFAFtoCcyaDEPG05TU02uOy1nAX09c1g7FtsyQlPcbhI94JQGmnPzdRjjDmORtwIUiVZQrVTkDM7w== -"@sentry/cli-linux-i686@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.49.0.tgz#8d1bb1378251a3aa995cc4b56bd352fa12a84b66" - integrity sha512-gDAd5/vJbEhd4Waud0Cd8ZRqLEagDlOvWwNH3KB694EiHJUwzRSiTA1YUVMYGI8Z9UyEA1sKxARwm2Trv99BxA== - "@sentry/cli-linux-i686@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.50.2.tgz#72f0e4bc1c515754aa11225efce711a24fb53524" integrity sha512-J+POvB34uVyHbIYF++Bc/OCLw+gqKW0H/y/mY7rRZCiocgpk266M4NtsOBl6bEaurMx1D+BCIEjr4nc01I/rqA== -"@sentry/cli-linux-x64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.49.0.tgz#7bf58fb7005c89fdde4e1262d5ed35e23065aceb" - integrity sha512-mbohGvPNhHjUciYNXzkt9TYUebTmxeAp9v9JfLSb/Soz6fubKwEHhpRJuz1zASxVWIR4PuqkePchqN5zhcLC0A== - "@sentry/cli-linux-x64@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.50.2.tgz#d06f8ffd65871b1373a0d2228ab254d9456a615c" integrity sha512-81yQVRLj8rnuHoYcrM7QbOw8ubA3weiMdPtTxTim1s6WExmPgnPTKxLCr9xzxGJxFdYo3xIOhtf5JFpUX/3j4A== -"@sentry/cli-win32-arm64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.49.0.tgz#2bf6dd911acbe3ddb02eec0afb4301bb8fb25b53" - integrity sha512-3zwvsp61EPpSuGpGdXY4JelVJmNEjoj4vn5m6EFoOtk7OUI5/VFqqR4wchjy9Hjm3Eh6MB5K+KTKXs4W2p18ng== - "@sentry/cli-win32-arm64@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.50.2.tgz#4bd7a140367c17f77d621903cfe0914232108657" integrity sha512-QjentLGvpibgiZlmlV9ifZyxV73lnGH6pFZWU5wLeRiaYKxWtNrrHpVs+HiWlRhkwQ0mG1/S40PGNgJ20DJ3gA== -"@sentry/cli-win32-i686@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.49.0.tgz#32e31472ae6c5f69e538a4061d651937fcb8f14a" - integrity sha512-2oWaNl6z0BaOCAjM1Jxequfgjod3XO6wothxow4kA8e9+43JLhgarSdpwJPgQjcVyxjygwQ3/jKPdUFh0qNOmg== - "@sentry/cli-win32-i686@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.50.2.tgz#1eb997cf780c396446cdd8e63c6d4309894465e8" integrity sha512-UkBIIzkQkQ1UkjQX8kHm/+e7IxnEhK6CdgSjFyNlxkwALjDWHJjMztevqAPz3kv4LdM6q1MxpQ/mOqXICNhEGg== -"@sentry/cli-win32-x64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.49.0.tgz#86aab38cb41f885914d7c99ceaab7b6ce52c72c6" - integrity sha512-dR4ulyrA6ZT7x7cg4Rwm0tcHf4TZz5QO6t1W1jX6uJ9n/U0bOSqSFZHNf/RryiUzQE1g8LBthOYyKGMkET6T8w== - "@sentry/cli-win32-x64@2.50.2": version "2.50.2" resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.50.2.tgz#1d0c106125b6dc87f3a598ac02519c699f17a6c0"