UNPKG

@sentry/node

Version:

Sentry Node SDK using OpenTelemetry for performance instrumentation

483 lines (416 loc) 17.5 kB
import * as diagch from 'diagnostics_channel'; import { URL } from 'url'; import { InstrumentationBase, safeExecuteInTheMiddle } from '@opentelemetry/instrumentation'; import { ValueType, context, trace, INVALID_SPAN_CONTEXT, SpanKind, propagation, SpanStatusCode } from '@opentelemetry/api'; import { hrTime, hrTimeToMilliseconds, hrTimeDuration } from '@opentelemetry/core'; import { METRIC_HTTP_CLIENT_REQUEST_DURATION, ATTR_URL_QUERY, ATTR_URL_PATH, ATTR_URL_FULL, ATTR_HTTP_REQUEST_METHOD_ORIGINAL, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_ERROR_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_SCHEME, ATTR_USER_AGENT_ORIGINAL, ATTR_NETWORK_PEER_PORT, ATTR_NETWORK_PEER_ADDRESS } from '@opentelemetry/semantic-conventions'; import { SDK_VERSION } from '@sentry/core'; /* * 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) */ 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 class UndiciInstrumentation extends InstrumentationBase { // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for // unsubscribing. __init() {this._recordFromReq = new WeakMap();} constructor(config = {}) { super(PACKAGE_NAME, SDK_VERSION, config);UndiciInstrumentation.prototype.__init.call(this); } // No need to instrument files/modules init() { return undefined; } disable() { super.disable(); this._channelSubs.forEach(sub => sub.unsubscribe()); this._channelSubs.length = 0; } enable() { // "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)); } _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], }, }); } subscribeToChannel(diagnosticChannel, onMessage) { // `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; 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, }); } parseRequestHeaders(request) { 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 onRequestCreated({ request }) { // 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 = { [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 = { 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; 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 = {}; 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 onRequestHeaders({ request, socket }) { const record = this._recordFromReq.get(request); if (!record) { return; } const config = this.getConfig(); const { span } = record; const { remoteAddress, remotePort } = socket; const spanAttributes = { [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 onResponseHeaders({ request, response }) { const record = this._recordFromReq.get(request); if (!record) { return; } const { span, attributes } = record; const spanAttributes = { [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] ).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 onDone({ request }) { 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. onError({ request, error }) { 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); } recordRequestDuration(attributes, startTime) { // Time to record metrics const metricsAttributes = {}; // 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); } getRequestMethod(original) { 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'; } } export { UndiciInstrumentation }; //# sourceMappingURL=undici.js.map