diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 96122ee95b3e..91ba709d0e7f 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -117,6 +117,19 @@ "max-lines": "off" } }, + { + "files": ["**/integrations/node-fetch/vendored/**/*.ts"], + "rules": { + "typescript/consistent-type-imports": "off", + "typescript/no-unnecessary-type-assertion": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-explicit-any": "off", + "typescript/prefer-for-of": "off", + "max-lines": "off", + "complexity": "off", + "no-param-reassign": "off" + } + }, { "files": [ "**/scenarios/**", diff --git a/packages/node-core/src/integrations/node-fetch/types.ts b/packages/node-core/src/integrations/node-fetch/types.ts index 0139dadde413..26ed710f2474 100644 --- a/packages/node-core/src/integrations/node-fetch/types.ts +++ b/packages/node-core/src/integrations/node-fetch/types.ts @@ -15,7 +15,8 @@ */ /** - * Vendored from https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/types.ts + * Aligned with upstream Undici request shape; see `packages/node/.../node-fetch/vendored/types.ts` + * (vendored from `@opentelemetry/instrumentation-undici`). */ export interface UndiciRequest { @@ -24,9 +25,9 @@ export interface UndiciRequest { path: string; /** * Serialized string of headers in the form `name: value\r\n` for v5 - * Array of strings v6 + * Array of strings `[key1, value1, ...]` for v6 (values may be `string | string[]`) */ - headers: string | string[]; + headers: string | (string | string[])[]; /** * Helper method to add headers (from v6) */ diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index cad20496e478..85edd6a73b58 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -42,7 +42,13 @@ export function addTracePropagationHeadersToFetchRequest( const { 'sentry-trace': sentryTrace, baggage, traceparent } = addedHeaders; - const requestHeaders = Array.isArray(request.headers) ? request.headers : stringToArrayHeaders(request.headers); + // Undici can expose headers either as a raw string (v5-style) or as a flat array of pairs (v6-style). + // In the array form, even indices are header names and odd indices are values; in undici v6 a value + // may be `string | string[]` when a header has multiple values. The helpers below (_deduplicateArrayHeader, + // push, etc.) expect each value slot to be a single string, so we normalize array headers first. + const requestHeaders: string[] = Array.isArray(request.headers) + ? normalizeUndiciHeaderPairs(request.headers) + : stringToArrayHeaders(request.headers); // OTel's UndiciInstrumentation calls propagation.inject() which unconditionally // appends headers to the request. When the user also sets headers via getTraceData(), @@ -84,12 +90,37 @@ export function addTracePropagationHeadersToFetchRequest( } } - if (!Array.isArray(request.headers)) { - // For original string request headers, we need to write them back to the request + if (Array.isArray(request.headers)) { + // Replace contents in place so we keep the same array reference undici/fetch still holds. + // `requestHeaders` is already normalized (string pairs only); splice writes them back. + request.headers.splice(0, request.headers.length, ...requestHeaders); + } else { request.headers = arrayToStringHeaders(requestHeaders); } } +/** + * Convert undici’s header array into `[name, value, name, value, ...]` where every value is a string. + * + * Undici v6 uses this shape: `[k1, v1, k2, v2, ...]`. Types allow each `v` to be `string | string[]` when + * that header has multiple values. Sentry’s dedupe/merge helpers expect one string per value slot, so + * multi-value arrays are joined with `', '`. Missing value slots become `''`. + */ +function normalizeUndiciHeaderPairs(headers: (string | string[])[]): string[] { + const out: string[] = []; + for (let i = 0; i < headers.length; i++) { + const entry = headers[i]; + if (i % 2 === 0) { + // Header name (should always be a string; coerce defensively). + out.push(typeof entry === 'string' ? entry : String(entry)); + } else { + // Header value: flatten `string[]` to a single string for downstream string-only helpers. + out.push(Array.isArray(entry) ? entry.join(', ') : (entry ?? '')); + } + } + return out; +} + function stringToArrayHeaders(requestHeaders: string): string[] { const headersArray = requestHeaders.split('\r\n'); const headers: string[] = []; diff --git a/packages/node/package.json b/packages/node/package.json index a348cb4affa7..ddd4f9154496 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -89,7 +89,6 @@ "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", - "@opentelemetry/instrumentation-undici": "0.24.0", "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch/index.ts similarity index 96% rename from packages/node/src/integrations/node-fetch.ts rename to packages/node/src/integrations/node-fetch/index.ts index 74bfff2dab47..2aa277b211c4 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch/index.ts @@ -1,5 +1,5 @@ -import type { UndiciInstrumentationConfig } from '@opentelemetry/instrumentation-undici'; -import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import type { UndiciInstrumentationConfig } from './vendored/types'; +import { UndiciInstrumentation } from './vendored/undici'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, @@ -12,7 +12,7 @@ import { } from '@sentry/core'; import type { NodeClient } from '@sentry/node-core'; import { generateInstrumentOnce, SentryNodeFetchInstrumentation } from '@sentry/node-core'; -import type { NodeClientOptions } from '../types'; +import type { NodeClientOptions } from '../../types'; const INTEGRATION_NAME = 'NodeFetch'; diff --git a/packages/node/src/integrations/node-fetch/vendored/internal-types.ts b/packages/node/src/integrations/node-fetch/vendored/internal-types.ts new file mode 100644 index 000000000000..cde91d1e139c --- /dev/null +++ b/packages/node/src/integrations/node-fetch/vendored/internal-types.ts @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici + * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 + * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-undici (#20165) */ + +import type { UndiciRequest, UndiciResponse } from './types'; + +export interface ListenerRecord { + name: string; + unsubscribe: () => void; +} + +export interface RequestMessage { + request: UndiciRequest; +} + +export interface RequestHeadersMessage { + request: UndiciRequest; + socket: any; +} + +export interface ResponseHeadersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestTrailersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestErrorMessage { + request: UndiciRequest; + error: Error; +} diff --git a/packages/node/src/integrations/node-fetch/vendored/types.ts b/packages/node/src/integrations/node-fetch/vendored/types.ts new file mode 100644 index 000000000000..f7c7d46c014a --- /dev/null +++ b/packages/node/src/integrations/node-fetch/vendored/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici + * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 + * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-undici (#20165) */ + +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { Attributes, Span } from '@opentelemetry/api'; + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` for v5 + * Array of strings `[key1, value1, key2, value2]`, where values are + * `string | string[]` for v6 + */ + headers: string | (string | string[])[]; + /** + * Helper method to add headers (from v6) + */ + addHeader: (name: string, value: string) => void; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + body: any; +} + +export interface UndiciResponse { + headers: Buffer[]; + statusCode: number; + statusText: string; +} + +export interface IgnoreRequestFunction { + (request: T): boolean; +} + +export interface RequestHookFunction { + (span: Span, request: T): void; +} + +export interface ResponseHookFunction { + (span: Span, info: { request: RequestType; response: ResponseType }): void; +} + +export interface StartSpanHookFunction { + (request: T): Attributes; +} + +// This package will instrument HTTP requests made through `undici` or `fetch` global API +// so it seems logical to have similar options than the HTTP instrumentation +export interface UndiciInstrumentationConfig< + RequestType = UndiciRequest, + ResponseType = UndiciResponse, +> extends InstrumentationConfig { + /** Not trace all outgoing requests that matched with custom function */ + ignoreRequestHook?: IgnoreRequestFunction; + /** Function for adding custom attributes before request is handled */ + requestHook?: RequestHookFunction; + /** Function called once response headers have been received */ + responseHook?: ResponseHookFunction; + /** Function for adding custom attributes before a span is started */ + startSpanHook?: StartSpanHookFunction; + /** Require parent to create span for outgoing requests */ + requireParentforSpans?: boolean; + /** Map the following HTTP headers to span attributes. */ + headersToSpanAttributes?: { + requestHeaders?: string[]; + responseHeaders?: string[]; + }; +} diff --git a/packages/node/src/integrations/node-fetch/vendored/undici.ts b/packages/node/src/integrations/node-fetch/vendored/undici.ts new file mode 100644 index 000000000000..55e09d7c4d53 --- /dev/null +++ b/packages/node/src/integrations/node-fetch/vendored/undici.ts @@ -0,0 +1,522 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici + * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 + * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 + * - Minor TypeScript strictness adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-undici (#20165) */ + +import * as diagch from 'diagnostics_channel'; +import { URL } from 'url'; + +import { InstrumentationBase, safeExecuteInTheMiddle } from '@opentelemetry/instrumentation'; +import type { Attributes, Histogram, HrTime, Span } from '@opentelemetry/api'; +import { + context, + INVALID_SPAN_CONTEXT, + propagation, + SpanKind, + SpanStatusCode, + trace, + ValueType, +} from '@opentelemetry/api'; +import { hrTime, hrTimeDuration, hrTimeToMilliseconds } from '@opentelemetry/core'; +import { + ATTR_ERROR_TYPE, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_REQUEST_METHOD_ORIGINAL, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_NETWORK_PEER_ADDRESS, + ATTR_NETWORK_PEER_PORT, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_FULL, + ATTR_URL_PATH, + ATTR_URL_QUERY, + ATTR_URL_SCHEME, + ATTR_USER_AGENT_ORIGINAL, + METRIC_HTTP_CLIENT_REQUEST_DURATION, +} from '@opentelemetry/semantic-conventions'; + +import type { + ListenerRecord, + RequestHeadersMessage, + RequestMessage, + RequestTrailersMessage, + ResponseHeadersMessage, +} from './internal-types'; +import type { UndiciInstrumentationConfig, UndiciRequest } from './types'; + +import { SDK_VERSION } from '@sentry/core'; + +interface InstrumentationRecord { + span: Span; + attributes: Attributes; + startTime: HrTime; +} + +const PACKAGE_NAME = '@sentry/instrumentation-undici'; + +// A combination of https://github.com/elastic/apm-agent-nodejs and +// https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts +export class UndiciInstrumentation extends InstrumentationBase { + // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for + // unsubscribing. + declare private _channelSubs: Array; + private _recordFromReq = new WeakMap(); + + declare private _httpClientDurationHistogram: Histogram; + + constructor(config: UndiciInstrumentationConfig = {}) { + super(PACKAGE_NAME, SDK_VERSION, config); + } + + // No need to instrument files/modules + protected override init() { + return undefined; + } + + override disable(): void { + super.disable(); + this._channelSubs.forEach(sub => sub.unsubscribe()); + this._channelSubs.length = 0; + } + + override enable(): void { + // "enabled" handling is currently a bit messy with InstrumentationBase. + // If constructed with `{enabled: false}`, this `.enable()` is still called, + // and `this.getConfig().enabled !== this.isEnabled()`, creating confusion. + // + // For now, this class will setup for instrumenting if `.enable()` is + // called, but use `this.getConfig().enabled` to determine if + // instrumentation should be generated. This covers the more likely common + // case of config being given a construction time, rather than later via + // `instance.enable()`, `.disable()`, or `.setConfig()` calls. + super.enable(); + + // This method is called by the super-class constructor before ours is + // called. So we need to ensure the property is initalized. + this._channelSubs = this._channelSubs || []; + + // Avoid to duplicate subscriptions + if (this._channelSubs.length > 0) { + return; + } + + this.subscribeToChannel('undici:request:create', this.onRequestCreated.bind(this)); + this.subscribeToChannel('undici:client:sendHeaders', this.onRequestHeaders.bind(this)); + this.subscribeToChannel('undici:request:headers', this.onResponseHeaders.bind(this)); + this.subscribeToChannel('undici:request:trailers', this.onDone.bind(this)); + this.subscribeToChannel('undici:request:error', this.onError.bind(this)); + } + + protected override _updateMetricInstruments() { + this._httpClientDurationHistogram = this.meter.createHistogram(METRIC_HTTP_CLIENT_REQUEST_DURATION, { + description: 'Measures the duration of outbound HTTP requests.', + unit: 's', + valueType: ValueType.DOUBLE, + advice: { + explicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10], + }, + }); + } + + private subscribeToChannel(diagnosticChannel: string, onMessage: (message: any, name: string | symbol) => void) { + // `diagnostics_channel` had a ref counting bug until v18.19.0. + // https://github.com/nodejs/node/pull/47520 + const [major = 0, minor = 0] = process.version + .replace('v', '') + .split('.') + .map(n => Number(n)); + const useNewSubscribe = major > 18 || (major === 18 && minor >= 19); + + let unsubscribe: () => void; + if (useNewSubscribe) { + diagch.subscribe?.(diagnosticChannel, onMessage); + unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage); + } else { + const channel = diagch.channel(diagnosticChannel); + channel.subscribe(onMessage); + unsubscribe = () => channel.unsubscribe(onMessage); + } + + this._channelSubs.push({ + name: diagnosticChannel, + unsubscribe, + }); + } + + private parseRequestHeaders(request: UndiciRequest) { + const result = new Map(); + + if (Array.isArray(request.headers)) { + // headers are an array [k1, v2, k2, v2] (undici v6+) + // values could be string or a string[] for multiple values + for (let i = 0; i < request.headers.length; i += 2) { + const key = request.headers[i]; + const value = request.headers[i + 1]; + + // Key should always be a string, but the types don't know that, and let's be safe + if (typeof key === 'string' && value !== undefined) { + result.set(key.toLowerCase(), value); + } + } + } else if (typeof request.headers === 'string') { + // headers are a raw string (undici v5) + // headers could be repeated in several lines for multiple values + const headers = request.headers.split('\r\n'); + for (const line of headers) { + if (!line) { + continue; + } + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + // Invalid header? Probably this can't happen, but again let's be safe. + continue; + } + const key = line.substring(0, colonIndex).toLowerCase(); + const value = line.substring(colonIndex + 1).trim(); + const allValues = result.get(key); + + if (allValues && Array.isArray(allValues)) { + allValues.push(value); + } else if (allValues) { + result.set(key, [allValues, value]); + } else { + result.set(key, value); + } + } + } + return result; + } + + // This is the 1st message we receive for each request (fired after request creation). Here we will + // create the span and populate some atttributes, then link the span to the request for further + // span processing + private onRequestCreated({ request }: RequestMessage): void { + // Ignore if: + // - instrumentation is disabled + // - ignored by config + // - method is 'CONNECT' + const config = this.getConfig(); + const enabled = config.enabled !== false; + const shouldIgnoreReq = safeExecuteInTheMiddle( + () => !enabled || request.method === 'CONNECT' || config.ignoreRequestHook?.(request), + e => e && this._diag.error('caught ignoreRequestHook error: ', e), + true, + ); + + if (shouldIgnoreReq) { + return; + } + + const startTime = hrTime(); + let requestUrl; + try { + requestUrl = new URL(request.path, request.origin); + } catch (err) { + this._diag.warn('could not determine url.full:', err); + // Skip instrumenting this request. + return; + } + const urlScheme = requestUrl.protocol.replace(':', ''); + const requestMethod = this.getRequestMethod(request.method); + const attributes: Attributes = { + [ATTR_HTTP_REQUEST_METHOD]: requestMethod, + [ATTR_HTTP_REQUEST_METHOD_ORIGINAL]: request.method, + [ATTR_URL_FULL]: requestUrl.toString(), + [ATTR_URL_PATH]: requestUrl.pathname, + [ATTR_URL_QUERY]: requestUrl.search, + [ATTR_URL_SCHEME]: urlScheme, + }; + + const schemePorts: Record = { https: '443', http: '80' }; + const serverAddress = requestUrl.hostname; + const serverPort = requestUrl.port || schemePorts[urlScheme]; + + attributes[ATTR_SERVER_ADDRESS] = serverAddress; + if (serverPort && !isNaN(Number(serverPort))) { + attributes[ATTR_SERVER_PORT] = Number(serverPort); + } + + // Get user agent from headers + const headersMap = this.parseRequestHeaders(request); + const userAgentValues = headersMap.get('user-agent'); + + if (userAgentValues) { + // NOTE: having multiple user agents is not expected so + // we're going to take last one like `curl` does + // ref: https://curl.se/docs/manpage.html#-A + const userAgent = Array.isArray(userAgentValues) ? userAgentValues[userAgentValues.length - 1] : userAgentValues; + attributes[ATTR_USER_AGENT_ORIGINAL] = userAgent; + } + + // Get attributes from the hook if present + const hookAttributes = safeExecuteInTheMiddle( + () => config.startSpanHook?.(request), + e => e && this._diag.error('caught startSpanHook error: ', e), + true, + ); + if (hookAttributes) { + Object.entries(hookAttributes).forEach(([key, val]) => { + attributes[key] = val; + }); + } + + // Check if parent span is required via config and: + // - if a parent is required but not present, we use a `NoopSpan` to still + // propagate context without recording it. + // - create a span otherwise + const activeCtx = context.active(); + const currentSpan = trace.getSpan(activeCtx); + let span: Span; + + if (config.requireParentforSpans && (!currentSpan || !trace.isSpanContextValid(currentSpan.spanContext()))) { + span = trace.wrapSpanContext(INVALID_SPAN_CONTEXT); + } else { + span = this.tracer.startSpan( + requestMethod === '_OTHER' ? 'HTTP' : requestMethod, + { + kind: SpanKind.CLIENT, + attributes: attributes, + }, + activeCtx, + ); + } + + // Execute the request hook if defined + safeExecuteInTheMiddle( + () => config.requestHook?.(span, request), + e => e && this._diag.error('caught requestHook error: ', e), + true, + ); + + // Context propagation goes last so no hook can tamper + // the propagation headers + const requestContext = trace.setSpan(context.active(), span); + const addedHeaders: Record = {}; + propagation.inject(requestContext, addedHeaders); + + const headerEntries = Object.entries(addedHeaders); + + for (let i = 0; i < headerEntries.length; i++) { + const pair = headerEntries[i]; + if (!pair) { + continue; + } + const [k, v] = pair; + + if (typeof request.addHeader === 'function') { + request.addHeader(k, v); + } else if (typeof request.headers === 'string') { + request.headers += `${k}: ${v}\r\n`; + } else if (Array.isArray(request.headers)) { + // undici@6.11.0 accidentally, briefly removed `request.addHeader()`. + request.headers.push(k, v); + } + } + this._recordFromReq.set(request, { span, attributes, startTime }); + } + + // This is the 2nd message we receive for each request. It is fired when connection with + // the remote is established and about to send the first byte. Here we do have info about the + // remote address and port so we can populate some `network.*` attributes into the span + private onRequestHeaders({ request, socket }: RequestHeadersMessage): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const config = this.getConfig(); + const { span } = record; + const { remoteAddress, remotePort } = socket; + const spanAttributes: Attributes = { + [ATTR_NETWORK_PEER_ADDRESS]: remoteAddress, + [ATTR_NETWORK_PEER_PORT]: remotePort, + }; + + // After hooks have been processed (which may modify request headers) + // we can collect the headers based on the configuration + if (config.headersToSpanAttributes?.requestHeaders) { + const headersToAttribs = new Set(config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())); + const headersMap = this.parseRequestHeaders(request); + + for (const [name, value] of headersMap.entries()) { + if (headersToAttribs.has(name)) { + const attrValue = Array.isArray(value) ? value : [value]; + spanAttributes[`http.request.header.${name}`] = attrValue; + } + } + } + + span.setAttributes(spanAttributes); + } + + // This is the 3rd message we get for each request and it's fired when the server + // headers are received, body may not be accessible yet. + // From the response headers we can set the status and content length + private onResponseHeaders({ request, response }: ResponseHeadersMessage): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes } = record; + const spanAttributes: Attributes = { + [ATTR_HTTP_RESPONSE_STATUS_CODE]: response.statusCode, + }; + + const config = this.getConfig(); + + // Execute the response hook if defined + safeExecuteInTheMiddle( + () => config.responseHook?.(span, { request, response }), + e => e && this._diag.error('caught responseHook error: ', e), + true, + ); + + if (config.headersToSpanAttributes?.responseHeaders) { + const headersToAttribs = new Set(); + config.headersToSpanAttributes?.responseHeaders.forEach(name => headersToAttribs.add(name.toLowerCase())); + + for (let idx = 0; idx < response.headers.length; idx = idx + 2) { + const nameBuf = response.headers[idx]; + const valueBuf = response.headers[idx + 1]; + if (nameBuf === undefined || valueBuf === undefined) { + continue; + } + const name = nameBuf.toString().toLowerCase(); + const value = valueBuf; + + if (headersToAttribs.has(name)) { + const attrName = `http.response.header.${name}`; + if (!Object.prototype.hasOwnProperty.call(spanAttributes, attrName)) { + spanAttributes[attrName] = [value.toString()]; + } else { + (spanAttributes[attrName] as string[]).push(value.toString()); + } + } + } + } + + span.setAttributes(spanAttributes); + span.setStatus({ + code: response.statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET, + }); + record.attributes = Object.assign(attributes, spanAttributes); + } + + // This is the last event we receive if the request went without any errors + private onDone({ request }: RequestTrailersMessage): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes, startTime } = record; + + // End the span + span.end(); + this._recordFromReq.delete(request); + + // Record metrics + this.recordRequestDuration(attributes, startTime); + } + + // This is the event we get when something is wrong in the request like + // - invalid options when calling `fetch` global API or any undici method for request + // - connectivity errors such as unreachable host + // - requests aborted through an `AbortController.signal` + // NOTE: server errors are considered valid responses and it's the lib consumer + // who should deal with that. + private onError({ request, error }: any): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes, startTime } = record; + + // NOTE: in `undici@6.3.0` when request aborted the error type changes from + // a custom error (`RequestAbortedError`) to a built-in `DOMException` carrying + // some differences: + // - `code` is from DOMEXception (ABORT_ERR: 20) + // - `message` changes + // - stacktrace is smaller and contains node internal frames + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + this._recordFromReq.delete(request); + + // Record metrics (with the error) + attributes[ATTR_ERROR_TYPE] = error.message; + this.recordRequestDuration(attributes, startTime); + } + + private recordRequestDuration(attributes: Attributes, startTime: HrTime) { + // Time to record metrics + const metricsAttributes: Attributes = {}; + // Get the attribs already in span attributes + const keysToCopy = [ + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_REQUEST_METHOD, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_SCHEME, + ATTR_ERROR_TYPE, + ]; + keysToCopy.forEach(key => { + if (key in attributes) { + metricsAttributes[key] = attributes[key]; + } + }); + + // Take the duration and record it + const durationSeconds = hrTimeToMilliseconds(hrTimeDuration(startTime, hrTime())) / 1000; + this._httpClientDurationHistogram.record(durationSeconds, metricsAttributes); + } + + private getRequestMethod(original: string): string { + const knownMethods = { + CONNECT: true, + OPTIONS: true, + HEAD: true, + GET: true, + POST: true, + PUT: true, + PATCH: true, + DELETE: true, + TRACE: true, + // QUERY from https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/ + QUERY: true, + }; + + if (original.toUpperCase() in knownMethods) { + return original.toUpperCase(); + } + + return '_OTHER'; + } +} diff --git a/yarn.lock b/yarn.lock index c1a28d46e642..dee387c3960d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6440,15 +6440,6 @@ "@opentelemetry/semantic-conventions" "^1.33.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.24.0": - version "0.24.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz#6ad41245012742899294edf65aa79fd190369094" - integrity sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ== - dependencies: - "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/semantic-conventions" "^1.24.0" - "@opentelemetry/instrumentation@0.214.0", "@opentelemetry/instrumentation@^0.214.0": version "0.214.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz#2649e8a29a8c4748bc583d35281c80632f046e25" @@ -6537,7 +6528,7 @@ "@opentelemetry/resources" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.40.0": +"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.40.0": version "1.40.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz#10b2944ca559386590683392022a897eefd011d3" integrity sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw== @@ -28557,7 +28548,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"