From b323c844c0c8f39eaa246a8a1d83f5abee684148 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 11:17:23 +0100 Subject: [PATCH 1/7] feat(instrumentation): add createInstrumentation factory function --- .../src/http-delegate.ts | 1126 ++++++++++ .../functionals/http-delegate-enable.test.ts | 1838 +++++++++++++++++ .../src/index.ts | 2 +- .../src/platform/index.ts | 2 +- .../platform/node/create-instrumentation.ts | 465 +++++ .../src/platform/node/index.ts | 1 + .../src/types.ts | 48 +- 7 files changed, 3478 insertions(+), 4 deletions(-) create mode 100644 experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts new file mode 100644 index 00000000000..c56ef878ee5 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts @@ -0,0 +1,1126 @@ +/* + * 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. + */ +import { + context, + HrTime, + INVALID_SPAN_CONTEXT, + propagation, + ROOT_CONTEXT, + Span, + SpanKind, + SpanOptions, + SpanStatus, + SpanStatusCode, + trace, + Histogram, + Attributes, + ValueType, + DiagLogger, + Meter, + Tracer, +} from '@opentelemetry/api'; +import { + hrTime, + hrTimeDuration, + hrTimeToMilliseconds, + suppressTracing, + RPCMetadata, + RPCType, + setRPCMetadata, +} from '@opentelemetry/core'; +import type * as http from 'http'; +import type * as https from 'https'; +import { Socket } from 'net'; +import * as url from 'url'; +import { HttpInstrumentationConfig } from './types'; +import { VERSION } from './version'; +import { + Instrumentation, + InstrumentationNodeModuleDefinition, + SemconvStability, + semconvStabilityFromStr, + safeExecuteInTheMiddle, + createInstrumentation, +} from '@opentelemetry/instrumentation'; +import { errorMonitor } from 'events'; +import { + ATTR_ERROR_TYPE, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_HTTP_ROUTE, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_SCHEME, + METRIC_HTTP_CLIENT_REQUEST_DURATION, + METRIC_HTTP_SERVER_REQUEST_DURATION, +} from '@opentelemetry/semantic-conventions'; +import { + extractHostnameAndPort, + getIncomingRequestAttributes, + getIncomingRequestAttributesOnResponse, + getIncomingRequestMetricAttributes, + getIncomingRequestMetricAttributesOnResponse, + getIncomingStableRequestMetricAttributesOnResponse, + getOutgoingRequestAttributes, + getOutgoingRequestAttributesOnResponse, + getOutgoingRequestMetricAttributes, + getOutgoingRequestMetricAttributesOnResponse, + getOutgoingStableRequestMetricAttributesOnResponse, + getRequestInfo, + headerCapture, + isValidOptionsType, + parseResponseStatus, + setSpanWithError, +} from './utils'; +import { Err, Func, Http, HttpRequestArgs, Https } from './internal-types'; +import { InstrumentationDelegate, Shimmer } from '@opentelemetry/instrumentation/src/types'; +import { Logger } from '@opentelemetry/api-logs'; + +type HeaderCapture = { + client: { + captureRequestHeaders: ReturnType; + captureResponseHeaders: ReturnType; + }; + server: { + captureRequestHeaders: ReturnType; + captureResponseHeaders: ReturnType; + } +} + +class HttpInstrumentationDelegate implements InstrumentationDelegate { + name = '@opentelemetry/instrumentation-http'; + version = VERSION; + private _config!: HttpInstrumentationConfig; + private _diag!: DiagLogger; + private _tracer!: Tracer; + // private _logger!: Logger; + + /** keep track on spans not ended */ + private readonly _spanNotEnded: WeakSet = new WeakSet(); + private _headerCapture!: HeaderCapture; + declare private _oldHttpServerDurationHistogram: Histogram; + declare private _stableHttpServerDurationHistogram: Histogram; + declare private _oldHttpClientDurationHistogram: Histogram; + declare private _stableHttpClientDurationHistogram: Histogram; + private _semconvStability: SemconvStability = SemconvStability.OLD; + + constructor() { + this._semconvStability = semconvStabilityFromStr( + 'http', + process.env.OTEL_SEMCONV_STABILITY_OPT_IN + ); + } + + setDiag(diag: DiagLogger): void { + this._diag = diag; + } + + setTracer(tracer: Tracer): void { + this._tracer = tracer; + } + + setLogger(logger: Logger): void { + // this._logger = logger; + } + + setMeter(meter: Meter) { + this._oldHttpServerDurationHistogram = meter.createHistogram( + 'http.server.duration', + { + description: 'Measures the duration of inbound HTTP requests.', + unit: 'ms', + valueType: ValueType.DOUBLE, + } + ); + this._oldHttpClientDurationHistogram = meter.createHistogram( + 'http.client.duration', + { + description: 'Measures the duration of outbound HTTP requests.', + unit: 'ms', + valueType: ValueType.DOUBLE, + } + ); + this._stableHttpServerDurationHistogram = meter.createHistogram( + METRIC_HTTP_SERVER_REQUEST_DURATION, + { + description: 'Duration of HTTP server 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, + ], + }, + } + ); + this._stableHttpClientDurationHistogram = meter.createHistogram( + METRIC_HTTP_CLIENT_REQUEST_DURATION, + { + description: 'Duration of HTTP client 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 _recordServerDuration( + durationMs: number, + oldAttributes: Attributes, + stableAttributes: Attributes + ) { + if (this._semconvStability & SemconvStability.OLD) { + // old histogram is counted in MS + this._oldHttpServerDurationHistogram.record(durationMs, oldAttributes); + } + + if (this._semconvStability & SemconvStability.STABLE) { + // stable histogram is counted in S + this._stableHttpServerDurationHistogram.record( + durationMs / 1000, + stableAttributes + ); + } + } + + private _recordClientDuration( + durationMs: number, + oldAttributes: Attributes, + stableAttributes: Attributes + ) { + if (this._semconvStability & SemconvStability.OLD) { + // old histogram is counted in MS + this._oldHttpClientDurationHistogram.record(durationMs, oldAttributes); + } + + if (this._semconvStability & SemconvStability.STABLE) { + // stable histogram is counted in S + this._stableHttpClientDurationHistogram.record( + durationMs / 1000, + stableAttributes + ); + } + } + + setConfig(config: HttpInstrumentationConfig = {}) { + this._config = config; + this._headerCapture = this._createHeaderCapture(); + } + + getConfig(): HttpInstrumentationConfig { + return this._config; + } + + init(shimmer: Shimmer): [ + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleDefinition, + ] { + return [this._getHttpsInstrumentation(shimmer), this._getHttpInstrumentation(shimmer)]; + } + + private _getHttpInstrumentation(shimmer: Shimmer) { + return new InstrumentationNodeModuleDefinition( + 'http', + ['*'], + (moduleExports: Http): Http => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isESM = (moduleExports as any)[Symbol.toStringTag] === 'Module'; + if (!this.getConfig().disableOutgoingRequestInstrumentation) { + const patchedRequest = shimmer.wrap( + moduleExports, + 'request', + this._getPatchOutgoingRequestFunction('http') + ) as unknown as Func; + const patchedGet = shimmer.wrap( + moduleExports, + 'get', + this._getPatchOutgoingGetFunction(patchedRequest) + ); + if (isESM) { + // To handle `import http from 'http'`, which returns the default + // export, we need to set `module.default.*`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports as any).default.request = patchedRequest; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports as any).default.get = patchedGet; + } + } + if (!this.getConfig().disableIncomingRequestInstrumentation) { + shimmer.wrap( + moduleExports.Server.prototype, + 'emit', + this._getPatchIncomingRequestFunction('http') + ); + } + return moduleExports; + }, + (moduleExports: Http) => { + if (moduleExports === undefined) return; + + if (!this.getConfig().disableOutgoingRequestInstrumentation) { + shimmer.unwrap(moduleExports, 'request'); + shimmer.unwrap(moduleExports, 'get'); + } + if (!this.getConfig().disableIncomingRequestInstrumentation) { + shimmer.unwrap(moduleExports.Server.prototype, 'emit'); + } + } + ); + } + + private _getHttpsInstrumentation(shimmer: Shimmer) { + return new InstrumentationNodeModuleDefinition( + 'https', + ['*'], + (moduleExports: Https): Https => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isESM = (moduleExports as any)[Symbol.toStringTag] === 'Module'; + if (!this.getConfig().disableOutgoingRequestInstrumentation) { + const patchedRequest = shimmer.wrap( + moduleExports, + 'request', + this._getPatchHttpsOutgoingRequestFunction('https') + ) as unknown as Func; + const patchedGet = shimmer.wrap( + moduleExports, + 'get', + this._getPatchHttpsOutgoingGetFunction(patchedRequest) + ); + if (isESM) { + // To handle `import https from 'https'`, which returns the default + // export, we need to set `module.default.*`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports as any).default.request = patchedRequest; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports as any).default.get = patchedGet; + } + } + if (!this.getConfig().disableIncomingRequestInstrumentation) { + shimmer.wrap( + moduleExports.Server.prototype, + 'emit', + this._getPatchIncomingRequestFunction('https') + ); + } + return moduleExports; + }, + (moduleExports: Https) => { + if (moduleExports === undefined) return; + + if (!this.getConfig().disableOutgoingRequestInstrumentation) { + shimmer.unwrap(moduleExports, 'request'); + shimmer.unwrap(moduleExports, 'get'); + } + if (!this.getConfig().disableIncomingRequestInstrumentation) { + shimmer.unwrap(moduleExports.Server.prototype, 'emit'); + } + } + ); + } + + /** + * Creates spans for incoming requests, restoring spans' context if applied. + */ + private _getPatchIncomingRequestFunction(component: 'http' | 'https') { + return ( + original: (event: string, ...args: unknown[]) => boolean + ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { + return this._incomingRequestFunction(component, original); + }; + } + + /** + * Creates spans for outgoing requests, sending spans' context for distributed + * tracing. + */ + private _getPatchOutgoingRequestFunction(component: 'http' | 'https') { + return (original: Func): Func => { + return this._outgoingRequestFunction(component, original); + }; + } + + private _getPatchOutgoingGetFunction( + clientRequest: ( + options: http.RequestOptions | string | url.URL, + ...args: HttpRequestArgs + ) => http.ClientRequest + ) { + return (_original: Func): Func => { + // Re-implement http.get. This needs to be done (instead of using + // getPatchOutgoingRequestFunction to patch it) because we need to + // set the trace context header before the returned http.ClientRequest is + // ended. The Node.js docs state that the only differences between + // request and get are that (1) get defaults to the HTTP GET method and + // (2) the returned request object is ended immediately. The former is + // already true (at least in supported Node versions up to v10), so we + // simply follow the latter. Ref: + // https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback + // https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198 + return function outgoingGetRequest< + T extends http.RequestOptions | string | url.URL, + >(options: T, ...args: HttpRequestArgs): http.ClientRequest { + const req = clientRequest(options, ...args); + req.end(); + return req; + }; + }; + } + + /** Patches HTTPS outgoing requests */ + private _getPatchHttpsOutgoingRequestFunction(component: 'http' | 'https') { + return (original: Func): Func => { + const instrumentation = this; + return function httpsOutgoingRequest( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + options: https.RequestOptions | string | URL, + ...args: HttpRequestArgs + ): http.ClientRequest { + // Makes sure options will have default HTTPS parameters + if ( + component === 'https' && + typeof options === 'object' && + options?.constructor?.name !== 'URL' + ) { + options = Object.assign({}, options); + instrumentation._setDefaultOptions(options); + } + return instrumentation._getPatchOutgoingRequestFunction(component)( + original + )(options, ...args); + }; + }; + } + + private _setDefaultOptions(options: https.RequestOptions) { + options.protocol = options.protocol || 'https:'; + options.port = options.port || 443; + } + + /** Patches HTTPS outgoing get requests */ + private _getPatchHttpsOutgoingGetFunction( + clientRequest: ( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + options: http.RequestOptions | string | URL, + ...args: HttpRequestArgs + ) => http.ClientRequest + ) { + return (original: Func): Func => { + const instrumentation = this; + return function httpsOutgoingRequest( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + options: https.RequestOptions | string | URL, + ...args: HttpRequestArgs + ): http.ClientRequest { + return instrumentation._getPatchOutgoingGetFunction(clientRequest)( + original + )(options, ...args); + }; + }; + } + + /** + * Attach event listeners to a client request to end span and add span attributes. + * + * @param request The original request object. + * @param span representing the current operation + * @param startTime representing the start time of the request to calculate duration in Metric + * @param oldMetricAttributes metric attributes for old semantic conventions + * @param stableMetricAttributes metric attributes for new semantic conventions + */ + private _traceClientRequest( + request: http.ClientRequest, + span: Span, + startTime: HrTime, + oldMetricAttributes: Attributes, + stableMetricAttributes: Attributes + ): http.ClientRequest { + if (this.getConfig().requestHook) { + this._callRequestHook(span, request); + } + + /** + * Determines if the request has errored or the response has ended/errored. + */ + let responseFinished = false; + + /* + * User 'response' event listeners can be added before our listener, + * force our listener to be the first, so response emitter is bound + * before any user listeners are added to it. + */ + request.prependListener( + 'response', + (response: http.IncomingMessage & { aborted?: boolean }) => { + this._diag.debug('outgoingRequest on response()'); + if (request.listenerCount('response') <= 1) { + response.resume(); + } + const responseAttributes = getOutgoingRequestAttributesOnResponse( + response, + this._semconvStability + ); + span.setAttributes(responseAttributes); + oldMetricAttributes = Object.assign( + oldMetricAttributes, + getOutgoingRequestMetricAttributesOnResponse(responseAttributes) + ); + stableMetricAttributes = Object.assign( + stableMetricAttributes, + getOutgoingStableRequestMetricAttributesOnResponse(responseAttributes) + ); + + if (this.getConfig().responseHook) { + this._callResponseHook(span, response); + } + + this._headerCapture.client.captureRequestHeaders(span, header => + request.getHeader(header) + ); + this._headerCapture.client.captureResponseHeaders( + span, + header => response.headers[header] + ); + + context.bind(context.active(), response); + + const endHandler = () => { + this._diag.debug('outgoingRequest on end()'); + if (responseFinished) { + return; + } + responseFinished = true; + let status: SpanStatus; + + if (response.aborted && !response.complete) { + status = { code: SpanStatusCode.ERROR }; + } else { + // behaves same for new and old semconv + status = { + code: parseResponseStatus(SpanKind.CLIENT, response.statusCode), + }; + } + + span.setStatus(status); + + if (this.getConfig().applyCustomAttributesOnSpan) { + safeExecuteInTheMiddle( + () => + this.getConfig().applyCustomAttributesOnSpan!( + span, + request, + response + ), + () => {}, + true + ); + } + + this._closeHttpSpan( + span, + SpanKind.CLIENT, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + }; + + response.on('end', endHandler); + response.on(errorMonitor, (error: Err) => { + this._diag.debug('outgoingRequest on error()', error); + if (responseFinished) { + return; + } + responseFinished = true; + this._onOutgoingRequestError( + span, + oldMetricAttributes, + stableMetricAttributes, + startTime, + error + ); + }); + } + ); + request.on('close', () => { + this._diag.debug('outgoingRequest on request close()'); + if (request.aborted || responseFinished) { + return; + } + responseFinished = true; + this._closeHttpSpan( + span, + SpanKind.CLIENT, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + }); + request.on(errorMonitor, (error: Err) => { + this._diag.debug('outgoingRequest on request error()', error); + if (responseFinished) { + return; + } + responseFinished = true; + this._onOutgoingRequestError( + span, + oldMetricAttributes, + stableMetricAttributes, + startTime, + error + ); + }); + + this._diag.debug('http.ClientRequest return request'); + return request; + } + + private _incomingRequestFunction( + component: 'http' | 'https', + original: (event: string, ...args: unknown[]) => boolean + ) { + const instrumentation = this; + return function incomingRequest( + this: unknown, + event: string, + ...args: unknown[] + ): boolean { + // Only traces request events + if (event !== 'request') { + return original.apply(this, [event, ...args]); + } + + const request = args[0] as http.IncomingMessage; + const response = args[1] as http.ServerResponse & { socket: Socket }; + const method = request.method || 'GET'; + + instrumentation._diag.debug( + `${component} instrumentation incomingRequest` + ); + + if ( + safeExecuteInTheMiddle( + () => + instrumentation.getConfig().ignoreIncomingRequestHook?.(request), + (e: unknown) => { + if (e != null) { + instrumentation._diag.error( + 'caught ignoreIncomingRequestHook error: ', + e + ); + } + }, + true + ) + ) { + return context.with(suppressTracing(context.active()), () => { + context.bind(context.active(), request); + context.bind(context.active(), response); + return original.apply(this, [event, ...args]); + }); + } + + const headers = request.headers; + + const spanAttributes = getIncomingRequestAttributes( + request, + { + component: component, + serverName: instrumentation.getConfig().serverName, + hookAttributes: instrumentation._callStartSpanHook( + request, + instrumentation.getConfig().startIncomingSpanHook + ), + semconvStability: instrumentation._semconvStability, + enableSyntheticSourceDetection: + instrumentation.getConfig().enableSyntheticSourceDetection || false, + }, + instrumentation._diag + ); + + const spanOptions: SpanOptions = { + kind: SpanKind.SERVER, + attributes: spanAttributes, + }; + + const startTime = hrTime(); + const oldMetricAttributes = + getIncomingRequestMetricAttributes(spanAttributes); + + // request method and url.scheme are both required span attributes + const stableMetricAttributes: Attributes = { + [ATTR_HTTP_REQUEST_METHOD]: spanAttributes[ATTR_HTTP_REQUEST_METHOD], + [ATTR_URL_SCHEME]: spanAttributes[ATTR_URL_SCHEME], + }; + + // recommended if and only if one was sent, same as span recommendation + if (spanAttributes[ATTR_NETWORK_PROTOCOL_VERSION]) { + stableMetricAttributes[ATTR_NETWORK_PROTOCOL_VERSION] = + spanAttributes[ATTR_NETWORK_PROTOCOL_VERSION]; + } + + const ctx = propagation.extract(ROOT_CONTEXT, headers); + const span = instrumentation._startHttpSpan(method, spanOptions, ctx); + const rpcMetadata: RPCMetadata = { + type: RPCType.HTTP, + span, + }; + + return context.with( + setRPCMetadata(trace.setSpan(ctx, span), rpcMetadata), + () => { + context.bind(context.active(), request); + context.bind(context.active(), response); + + if (instrumentation.getConfig().requestHook) { + instrumentation._callRequestHook(span, request); + } + if (instrumentation.getConfig().responseHook) { + instrumentation._callResponseHook(span, response); + } + + instrumentation._headerCapture.server.captureRequestHeaders( + span, + header => request.headers[header] + ); + + // After 'error', no further events other than 'close' should be emitted. + let hasError = false; + response.on('close', () => { + if (hasError) { + return; + } + instrumentation._onServerResponseFinish( + request, + response, + span, + oldMetricAttributes, + stableMetricAttributes, + startTime + ); + }); + response.on(errorMonitor, (err: Err) => { + hasError = true; + instrumentation._onServerResponseError( + span, + oldMetricAttributes, + stableMetricAttributes, + startTime, + err + ); + }); + + return safeExecuteInTheMiddle( + () => original.apply(this, [event, ...args]), + error => { + if (error) { + instrumentation._onServerResponseError( + span, + oldMetricAttributes, + stableMetricAttributes, + startTime, + error + ); + throw error; + } + } + ); + } + ); + }; + } + + private _outgoingRequestFunction( + component: 'http' | 'https', + original: Func + ): Func { + const instrumentation = this; + return function outgoingRequest( + this: unknown, + options: url.URL | http.RequestOptions | string, + ...args: unknown[] + ): http.ClientRequest { + if (!isValidOptionsType(options)) { + return original.apply(this, [options, ...args]); + } + const extraOptions = + typeof args[0] === 'object' && + (typeof options === 'string' || options instanceof url.URL) + ? (args.shift() as http.RequestOptions) + : undefined; + const { method, invalidUrl, optionsParsed } = getRequestInfo( + instrumentation._diag, + options, + extraOptions + ); + + if ( + safeExecuteInTheMiddle( + () => + instrumentation + .getConfig() + .ignoreOutgoingRequestHook?.(optionsParsed), + (e: unknown) => { + if (e != null) { + instrumentation._diag.error( + 'caught ignoreOutgoingRequestHook error: ', + e + ); + } + }, + true + ) + ) { + return original.apply(this, [optionsParsed, ...args]); + } + + const { hostname, port } = extractHostnameAndPort(optionsParsed); + const attributes = getOutgoingRequestAttributes( + optionsParsed, + { + component, + port, + hostname, + hookAttributes: instrumentation._callStartSpanHook( + optionsParsed, + instrumentation.getConfig().startOutgoingSpanHook + ), + redactedQueryParams: instrumentation.getConfig().redactedQueryParams, // Added config for adding custom query strings + }, + instrumentation._semconvStability, + instrumentation.getConfig().enableSyntheticSourceDetection || false + ); + + const startTime = hrTime(); + const oldMetricAttributes: Attributes = + getOutgoingRequestMetricAttributes(attributes); + + // request method, server address, and server port are both required span attributes + const stableMetricAttributes: Attributes = { + [ATTR_HTTP_REQUEST_METHOD]: attributes[ATTR_HTTP_REQUEST_METHOD], + [ATTR_SERVER_ADDRESS]: attributes[ATTR_SERVER_ADDRESS], + [ATTR_SERVER_PORT]: attributes[ATTR_SERVER_PORT], + }; + + // required if and only if one was sent, same as span requirement + if (attributes[ATTR_HTTP_RESPONSE_STATUS_CODE]) { + stableMetricAttributes[ATTR_HTTP_RESPONSE_STATUS_CODE] = + attributes[ATTR_HTTP_RESPONSE_STATUS_CODE]; + } + + // recommended if and only if one was sent, same as span recommendation + if (attributes[ATTR_NETWORK_PROTOCOL_VERSION]) { + stableMetricAttributes[ATTR_NETWORK_PROTOCOL_VERSION] = + attributes[ATTR_NETWORK_PROTOCOL_VERSION]; + } + + const spanOptions: SpanOptions = { + kind: SpanKind.CLIENT, + attributes, + }; + const span = instrumentation._startHttpSpan(method, spanOptions); + + const parentContext = context.active(); + const requestContext = trace.setSpan(parentContext, span); + + if (!optionsParsed.headers) { + optionsParsed.headers = {}; + } else { + // Make a copy of the headers object to avoid mutating an object the + // caller might have a reference to. + optionsParsed.headers = Object.assign({}, optionsParsed.headers); + } + propagation.inject(requestContext, optionsParsed.headers); + + return context.with(requestContext, () => { + /* + * The response callback is registered before ClientRequest is bound, + * thus it is needed to bind it before the function call. + */ + const cb = args[args.length - 1]; + if (typeof cb === 'function') { + args[args.length - 1] = context.bind(parentContext, cb); + } + + const request: http.ClientRequest = safeExecuteInTheMiddle( + () => { + if (invalidUrl) { + // we know that the url is invalid, there's no point in injecting context as it will fail validation. + // Passing in what the user provided will give the user an error that matches what they'd see without + // the instrumentation. + return original.apply(this, [options, ...args]); + } else { + return original.apply(this, [optionsParsed, ...args]); + } + }, + error => { + if (error) { + instrumentation._onOutgoingRequestError( + span, + oldMetricAttributes, + stableMetricAttributes, + startTime, + error + ); + throw error; + } + } + ); + + instrumentation._diag.debug( + `${component} instrumentation outgoingRequest` + ); + context.bind(parentContext, request); + return instrumentation._traceClientRequest( + request, + span, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + }); + }; + } + + private _onServerResponseFinish( + request: http.IncomingMessage, + response: http.ServerResponse, + span: Span, + oldMetricAttributes: Attributes, + stableMetricAttributes: Attributes, + startTime: HrTime + ) { + const attributes = getIncomingRequestAttributesOnResponse( + request, + response, + this._semconvStability + ); + oldMetricAttributes = Object.assign( + oldMetricAttributes, + getIncomingRequestMetricAttributesOnResponse(attributes) + ); + stableMetricAttributes = Object.assign( + stableMetricAttributes, + getIncomingStableRequestMetricAttributesOnResponse(attributes) + ); + + this._headerCapture.server.captureResponseHeaders(span, header => + response.getHeader(header) + ); + + span.setAttributes(attributes).setStatus({ + code: parseResponseStatus(SpanKind.SERVER, response.statusCode), + }); + + const route = attributes[ATTR_HTTP_ROUTE]; + if (route) { + span.updateName(`${request.method || 'GET'} ${route}`); + } + + if (this.getConfig().applyCustomAttributesOnSpan) { + safeExecuteInTheMiddle( + () => + this.getConfig().applyCustomAttributesOnSpan!( + span, + request, + response + ), + () => {}, + true + ); + } + + this._closeHttpSpan( + span, + SpanKind.SERVER, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + } + + private _onOutgoingRequestError( + span: Span, + oldMetricAttributes: Attributes, + stableMetricAttributes: Attributes, + startTime: HrTime, + error: Err + ) { + setSpanWithError(span, error, this._semconvStability); + stableMetricAttributes[ATTR_ERROR_TYPE] = error.name; + + this._closeHttpSpan( + span, + SpanKind.CLIENT, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + } + + private _onServerResponseError( + span: Span, + oldMetricAttributes: Attributes, + stableMetricAttributes: Attributes, + startTime: HrTime, + error: Err + ) { + setSpanWithError(span, error, this._semconvStability); + stableMetricAttributes[ATTR_ERROR_TYPE] = error.name; + + this._closeHttpSpan( + span, + SpanKind.SERVER, + startTime, + oldMetricAttributes, + stableMetricAttributes + ); + } + + private _startHttpSpan( + name: string, + options: SpanOptions, + ctx = context.active() + ) { + /* + * If a parent is required but not present, we use a `NoopSpan` to still + * propagate context without recording it. + */ + const requireParent = + options.kind === SpanKind.CLIENT + ? this.getConfig().requireParentforOutgoingSpans + : this.getConfig().requireParentforIncomingSpans; + + let span: Span; + const currentSpan = trace.getSpan(ctx); + + if ( + requireParent === true && + (!currentSpan || !trace.isSpanContextValid(currentSpan.spanContext())) + ) { + span = trace.wrapSpanContext(INVALID_SPAN_CONTEXT); + } else if (requireParent === true && currentSpan?.spanContext().isRemote) { + span = currentSpan; + } else { + span = this._tracer.startSpan(name, options, ctx); + } + this._spanNotEnded.add(span); + return span; + } + + private _closeHttpSpan( + span: Span, + spanKind: SpanKind, + startTime: HrTime, + oldMetricAttributes: Attributes, + stableMetricAttributes: Attributes + ) { + if (!this._spanNotEnded.has(span)) { + return; + } + + span.end(); + this._spanNotEnded.delete(span); + + // Record metrics + const duration = hrTimeToMilliseconds(hrTimeDuration(startTime, hrTime())); + if (spanKind === SpanKind.SERVER) { + this._recordServerDuration( + duration, + oldMetricAttributes, + stableMetricAttributes + ); + } else if (spanKind === SpanKind.CLIENT) { + this._recordClientDuration( + duration, + oldMetricAttributes, + stableMetricAttributes + ); + } + } + + private _callResponseHook( + span: Span, + response: http.IncomingMessage | http.ServerResponse + ) { + safeExecuteInTheMiddle( + () => this.getConfig().responseHook!(span, response), + () => {}, + true + ); + } + + private _callRequestHook( + span: Span, + request: http.ClientRequest | http.IncomingMessage + ) { + safeExecuteInTheMiddle( + () => this.getConfig().requestHook!(span, request), + () => {}, + true + ); + } + + private _callStartSpanHook( + request: http.IncomingMessage | http.RequestOptions, + hookFunc: Function | undefined + ) { + if (typeof hookFunc === 'function') { + return safeExecuteInTheMiddle( + () => hookFunc(request), + () => {}, + true + ); + } + } + + private _createHeaderCapture() { + const config = this.getConfig(); + + return { + client: { + captureRequestHeaders: headerCapture( + 'request', + config.headersToSpanAttributes?.client?.requestHeaders ?? [] + ), + captureResponseHeaders: headerCapture( + 'response', + config.headersToSpanAttributes?.client?.responseHeaders ?? [] + ), + }, + server: { + captureRequestHeaders: headerCapture( + 'request', + config.headersToSpanAttributes?.server?.requestHeaders ?? [] + ), + captureResponseHeaders: headerCapture( + 'response', + config.headersToSpanAttributes?.server?.responseHeaders ?? [] + ), + }, + }; + } +} + +export function createHttpInstrumentation(config: HttpInstrumentationConfig = {}): Instrumentation { + return createInstrumentation(new HttpInstrumentationDelegate(), config); +} diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts new file mode 100644 index 00000000000..6b7dd2c341e --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts @@ -0,0 +1,1838 @@ +/* + * 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. + */ +import { + SpanStatusCode, + context, + diag, + propagation, + Span as ISpan, + SpanKind, + trace, + Attributes, + DiagConsoleLogger, + INVALID_SPAN_CONTEXT, +} from '@opentelemetry/api'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { + ATTR_CLIENT_ADDRESS, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_ROUTE, + ATTR_NETWORK_PEER_ADDRESS, + ATTR_NETWORK_PEER_PORT, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_FULL, + ATTR_URL_PATH, + ATTR_URL_SCHEME, + HTTP_REQUEST_METHOD_VALUE_GET, +} from '@opentelemetry/semantic-conventions'; +import { + ATTR_HTTP_CLIENT_IP, + ATTR_HTTP_FLAVOR, + ATTR_HTTP_HOST, + ATTR_HTTP_METHOD, + ATTR_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, + ATTR_HTTP_SCHEME, + ATTR_HTTP_STATUS_CODE, + ATTR_HTTP_TARGET, + ATTR_HTTP_URL, + ATTR_NET_HOST_IP, + ATTR_NET_HOST_NAME, + ATTR_NET_HOST_PORT, + ATTR_NET_PEER_IP, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + ATTR_NET_TRANSPORT, + NET_TRANSPORT_VALUE_IP_TCP, +} from '../../src/semconv'; +import * as assert from 'assert'; +import * as nock from 'nock'; +import * as path from 'path'; +import { HttpInstrumentationConfig } from '../../src/types'; +import { createHttpInstrumentation } from '../../src/http-delegate'; +import { assertSpan } from '../utils/assertSpan'; +import { DummyPropagation } from '../utils/DummyPropagation'; +import { httpRequest } from '../utils/httpRequest'; +import { ContextManager } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import type { + ClientRequest, + IncomingMessage, + ServerResponse, + RequestOptions, +} from 'http'; +import { isWrapped, SemconvStability } from '@opentelemetry/instrumentation'; +import { getRPCMetadata, RPCType } from '@opentelemetry/core'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import { AttributeNames } from '../../src/enums/AttributeNames'; +import { getRemoteClientAddress } from '../../src/utils'; + +const applyCustomAttributesOnSpanErrorMessage = + 'bad applyCustomAttributesOnSpan function'; + +let server: http.Server; +const serverPort = 22346; +const protocol = 'http'; +const hostname = 'localhost'; +const pathname = '/test'; +const serverName = 'my.server.name'; +const memoryExporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], +}); +instrumentation.setTracerProvider(provider); + +function doNock( + hostname: string, + path: string, + httpCode: number, + respBody: string, + times?: number +) { + const i = times || 1; + nock(`${protocol}://${hostname}`) + .get(path) + .times(i) + .reply(httpCode, respBody); +} + +export const customAttributeFunction = (span: ISpan): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +export const requestHookFunction = ( + span: ISpan, + request: ClientRequest | IncomingMessage +): void => { + span.setAttribute('custom request hook attribute', 'request'); +}; + +export const responseHookFunction = ( + span: ISpan, + response: IncomingMessage | ServerResponse +): void => { + span.setAttribute('custom response hook attribute', 'response'); + // IncomingMessage (Readable) 'end'. + response.on('end', () => { + span.setAttribute('custom incoming message attribute', 'end'); + }); + // ServerResponse (writable) 'finish'. + response.on('finish', () => { + span.setAttribute('custom server response attribute', 'finish'); + }); +}; + +export const startIncomingSpanHookFunction = ( + request: IncomingMessage +): Attributes => { + return { guid: request.headers?.guid }; +}; + +export const startOutgoingSpanHookFunction = ( + request: RequestOptions +): Attributes => { + return { guid: request.headers?.guid }; +}; + +describe('HttpInstrumentationDelegate', () => { + let contextManager: ContextManager; + + before(() => { + propagation.setGlobalPropagator(new DummyPropagation()); + }); + + after(() => { + propagation.disable(); + }); + + beforeEach(() => { + contextManager = new AsyncHooksContextManager().enable(); + context.setGlobalContextManager(contextManager); + }); + + afterEach(() => { + context.disable(); + }); + + describe('enable()', () => { + describe('with bad instrumentation options', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(async () => { + const config: HttpInstrumentationConfig = { + ignoreIncomingRequestHook: _request => { + throw new Error('bad ignoreIncomingRequestHook function'); + }, + ignoreOutgoingRequestHook: _request => { + throw new Error('bad ignoreOutgoingRequestHook function'); + }, + applyCustomAttributesOnSpan: () => { + throw new Error(applyCustomAttributesOnSpanErrorMessage); + }, + }; + instrumentation.setConfig(config); + instrumentation.enable(); + server = http.createServer((request, response) => { + response.end('Test Server Response'); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should generate valid spans (client side and server side)', async () => { + const result = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'user-agent': 'tester', + }, + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: result.method!, + pathname, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assertSpan(incomingSpan, SpanKind.SERVER, validations); + assertSpan(outgoingSpan, SpanKind.CLIENT, validations); + assert.strictEqual( + incomingSpan.attributes[ATTR_NET_HOST_PORT], + serverPort + ); + assert.strictEqual( + outgoingSpan.attributes[ATTR_NET_PEER_PORT], + serverPort + ); + }); + + it('should redact auth from the `http.url` attribute (client side and server side)', async () => { + await httpRequest.get( + `${protocol}://user:pass@${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + assert.strictEqual(spans.length, 2); + assert.strictEqual(incomingSpan.kind, SpanKind.SERVER); + assert.strictEqual(outgoingSpan.kind, SpanKind.CLIENT); + assert.strictEqual( + incomingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}` + ); + }); + }); + + describe('partially disable instrumentation', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + afterEach(() => { + server.close(); + instrumentation.disable(); + }); + + it('allows to disable outgoing request instrumentation', async () => { + server.close(); + instrumentation.disable(); + + instrumentation.setConfig({ + disableOutgoingRequestInstrumentation: true, + }); + instrumentation.enable(); + server = http.createServer((_request, response) => { + response.end('Test Server Response'); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + + assert.strictEqual(isWrapped(http.Server.prototype.emit), true); + assert.strictEqual(isWrapped(http.get), false); + assert.strictEqual(isWrapped(http.request), false); + }); + + it('allows to disable incoming request instrumentation', async () => { + server.close(); + instrumentation.disable(); + + instrumentation.setConfig({ + disableIncomingRequestInstrumentation: true, + }); + instrumentation.enable(); + server = http.createServer((_request, response) => { + response.end('Test Server Response'); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + + assert.strictEqual(isWrapped(http.Server.prototype.emit), false); + assert.strictEqual(isWrapped(http.get), true); + assert.strictEqual(isWrapped(http.request), true); + }); + }); + + describe('with good instrumentation options', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(async () => { + instrumentation.setConfig({ + ignoreIncomingRequestHook: request => { + return ( + request.headers['user-agent']?.match('ignored-string') != null + ); + }, + ignoreOutgoingRequestHook: request => { + if (request.headers?.['user-agent'] != null) { + return ( + `${request.headers['user-agent']}`.match('ignored-string') != + null + ); + } + return false; + }, + applyCustomAttributesOnSpan: customAttributeFunction, + requestHook: requestHookFunction, + responseHook: responseHookFunction, + startIncomingSpanHook: startIncomingSpanHookFunction, + startOutgoingSpanHook: startOutgoingSpanHookFunction, + serverName, + }); + instrumentation.enable(); + server = http.createServer((request, response) => { + if (request.url?.includes('/premature-close')) { + response.destroy(); + return; + } + if (request.url?.includes('/hang')) { + // write response headers. + response.write(''); + // hang the request. + return; + } + if (request.url?.includes('/destroy-request')) { + // force flush http response header to trigger client response callback + response.write(''); + setTimeout(() => { + request.socket.destroy(); + }, 100); + return; + } + if (request.url?.includes('/ignored')) { + provider.getTracer('test').startSpan('some-span').end(); + } + if (request.url?.includes('/setroute')) { + const rpcData = getRPCMetadata(context.active()); + assert.ok(rpcData != null); + assert.strictEqual(rpcData.type, RPCType.HTTP); + assert.strictEqual(rpcData.route, undefined); + rpcData.route = 'TheRoute'; + } + if (request.url?.includes('/login')) { + assert.strictEqual( + request.headers.authorization, + 'Basic ' + Buffer.from('username:password').toString('base64') + ); + } + if (request.url?.includes('/withQuery')) { + assert.match(request.url, /withQuery\?foo=bar$/); + } + response.end('Test Server Response'); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it(`${protocol} module should be patched`, () => { + assert.strictEqual(isWrapped(http.Server.prototype.emit), true); + }); + + it('should generate valid spans (client side and server side)', async () => { + const result = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'x-forwarded-for': ', , ', + 'user-agent': 'chrome', + }, + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: result.method!, + pathname, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + serverName, + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual( + incomingSpan.attributes[ATTR_HTTP_CLIENT_IP], + '' + ); + assert.strictEqual( + incomingSpan.attributes[ATTR_NET_HOST_PORT], + serverPort + ); + assert.strictEqual( + outgoingSpan.attributes[ATTR_NET_PEER_PORT], + serverPort + ); + [ + { span: incomingSpan, kind: SpanKind.SERVER }, + { span: outgoingSpan, kind: SpanKind.CLIENT }, + ].forEach(({ span, kind }) => { + assert.strictEqual(span.attributes[ATTR_HTTP_FLAVOR], '1.1'); + assert.strictEqual( + span.attributes[ATTR_NET_TRANSPORT], + NET_TRANSPORT_VALUE_IP_TCP + ); + assertSpan(span, kind, validations); + }); + }); + + it('should respect HTTP_ROUTE', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}/setroute` + ); + const span = memoryExporter.getFinishedSpans()[0]; + + assert.strictEqual(span.kind, SpanKind.SERVER); + assert.strictEqual(span.attributes[ATTR_HTTP_ROUTE], 'TheRoute'); + assert.strictEqual(span.name, 'GET TheRoute'); + }); + + const httpErrorCodes = [ + 400, 401, 403, 404, 429, 501, 503, 504, 500, 505, 597, + ]; + + for (let i = 0; i < httpErrorCodes.length; i++) { + it(`should test span for GET requests with http error ${httpErrorCodes[i]}`, async () => { + const testPath = '/outgoing/rootSpan/1'; + + doNock( + hostname, + testPath, + httpErrorCodes[i], + httpErrorCodes[i].toString() + ); + + const isReset = memoryExporter.getFinishedSpans().length === 0; + assert.ok(isReset); + + const result = await httpRequest.get( + `${protocol}://${hostname}${testPath}` + ); + const spans = memoryExporter.getFinishedSpans(); + const reqSpan = spans[0]; + + assert.strictEqual(result.data, httpErrorCodes[i].toString()); + assert.strictEqual(spans.length, 1); + + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assertSpan(reqSpan, SpanKind.CLIENT, validations); + }); + } + + it('should create a child span for GET requests', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 200, 'Ok'); + const name = 'TestRootSpan'; + const span = provider.getTracer('default').startSpan(name); + return context.with(trace.setSpan(context.active(), span), async () => { + const result = await httpRequest.get( + `${protocol}://${hostname}${testPath}` + ); + span.end(); + const spans = memoryExporter.getFinishedSpans(); + const [reqSpan, localSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.ok(localSpan.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(spans.length, 2); + assert.strictEqual(reqSpan.name, 'GET'); + assert.strictEqual( + localSpan.spanContext().traceId, + reqSpan.spanContext().traceId + ); + assertSpan(reqSpan, SpanKind.CLIENT, validations); + assert.notStrictEqual( + localSpan.spanContext().spanId, + reqSpan.spanContext().spanId + ); + }); + }); + + for (let i = 0; i < httpErrorCodes.length; i++) { + it(`should test child spans for GET requests with http error ${httpErrorCodes[i]}`, async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock( + hostname, + testPath, + httpErrorCodes[i], + httpErrorCodes[i].toString() + ); + const name = 'TestRootSpan'; + const span = provider.getTracer('default').startSpan(name); + return context.with( + trace.setSpan(context.active(), span), + async () => { + const result = await httpRequest.get( + `${protocol}://${hostname}${testPath}` + ); + span.end(); + const spans = memoryExporter.getFinishedSpans(); + const [reqSpan, localSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.ok(localSpan.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(spans.length, 2); + assert.strictEqual(reqSpan.name, 'GET'); + assert.strictEqual( + localSpan.spanContext().traceId, + reqSpan.spanContext().traceId + ); + assertSpan(reqSpan, SpanKind.CLIENT, validations); + assert.notStrictEqual( + localSpan.spanContext().spanId, + reqSpan.spanContext().spanId + ); + } + ); + }); + } + + it('should create multiple child spans for GET requests', async () => { + const testPath = '/outgoing/rootSpan/childs'; + const num = 5; + doNock(hostname, testPath, 200, 'Ok', num); + const name = 'TestRootSpan'; + const span = provider.getTracer('default').startSpan(name); + await context.with(trace.setSpan(context.active(), span), async () => { + for (let i = 0; i < num; i++) { + await httpRequest.get(`${protocol}://${hostname}${testPath}`); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans[i].name, 'GET'); + assert.strictEqual( + span.spanContext().traceId, + spans[i].spanContext().traceId + ); + } + span.end(); + const spans = memoryExporter.getFinishedSpans(); + // 5 child spans ended + 1 span (root) + assert.strictEqual(spans.length, 6); + }); + }); + + it('should not trace ignored requests when ignore hook returns true', async () => { + const testValue = 'ignored-string'; + + await Promise.all([ + httpRequest.get(`${protocol}://${hostname}:${serverPort}`, { + headers: { + 'user-agent': testValue, + }, + }), + ]); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + it('should trace requests when ignore hook returns false', async () => { + await httpRequest.get(`${protocol}://${hostname}:${serverPort}`, { + headers: { + 'user-agent': 'test-bot', + }, + }); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + }); + + for (const arg of [{}, new Date()]) { + it(`should be traceable and not throw exception in ${protocol} instrumentation when passing the following argument ${JSON.stringify( + arg + )}`, async () => { + try { + await httpRequest.get(arg); + } catch (error) { + // request has been made + // nock throw + assert.ok(error.message.startsWith('Nock: No match for request')); + } + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + }); + } + + for (const arg of [true, 1, false, 0, '']) { + it(`should not throw exception in ${protocol} instrumentation when passing the following argument ${JSON.stringify( + arg + )}`, async () => { + try { + await httpRequest.get(arg as any); + } catch (error) { + // request has been made + // nock throw + assert.ok( + error.stack.indexOf( + path.normalize('/node_modules/nock/lib/intercept.js') + ) > 0 + ); + } + const spans = memoryExporter.getFinishedSpans(); + // for this arg with don't provide trace. We pass arg to original method (http.get) + assert.strictEqual(spans.length, 0); + }); + } + + it('should have 1 ended span when request throw on bad "options" object', () => { + assert.throws( + () => http.request({ headers: { cookie: undefined } }), + (err: unknown) => { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + + assert.ok(err instanceof Error); + + const validations = { + httpStatusCode: undefined, + httpMethod: 'GET', + resHeaders: {}, + hostname: 'localhost', + pathname: '/', + forceStatus: { + code: SpanStatusCode.ERROR, + message: err.message, + }, + component: 'http', + noNetPeer: true, + error: err, + }; + assertSpan(spans[0], SpanKind.CLIENT, validations); + return true; + } + ); + }); + + it('should have 1 ended span when response.end throw an exception', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 400, 'Not Ok'); + + const promiseRequest = new Promise((resolve, reject) => { + const req = http.request( + `${protocol}://${hostname}${testPath}`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + reject(new Error(data)); + }); + } + ); + return req.end(); + }); + + try { + await promiseRequest; + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when request throw on bad "options" object', () => { + nock.cleanAll(); + nock.enableNetConnect(); + try { + http.request({ protocol: 'telnet' }); + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when response.end throw an exception', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 400, 'Not Ok'); + + const promiseRequest = new Promise((resolve, reject) => { + const req = http.request( + `${protocol}://${hostname}${testPath}`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + reject(new Error(data)); + }); + } + ); + return req.end(); + }); + + try { + await promiseRequest; + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when request is aborted', async () => { + nock(`${protocol}://my.server.com`) + .get('/') + .delayConnection(50) + .reply(200, ''); + + const promiseRequest = new Promise((resolve, reject) => { + const req = http.request( + `${protocol}://my.server.com`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + resolve(data); + }); + } + ); + req.setTimeout(10, () => { + req.abort(); + }); + // Instrumentation should not swallow error event. + assert.strictEqual(req.listeners('error').length, 0); + req.on('error', err => { + reject(err); + }); + return req.end(); + }); + + await assert.rejects(promiseRequest, /Error: socket hang up/); + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.ok(Object.keys(span.attributes).length >= 6); + }); + + it('should have 1 ended span when request is aborted after receiving response', async () => { + nock(`${protocol}://my.server.com`) + .get('/') + .delay({ + body: 50, + }) + .replyWithFile(200, `${process.cwd()}/package.json`); + + const promiseRequest = new Promise((resolve, reject) => { + const req = http.request( + `${protocol}://my.server.com`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + req.destroy(Error('request destroyed')); + data += chunk; + }); + resp.on('end', () => { + resolve(data); + }); + } + ); + // Instrumentation should not swallow error event. + assert.strictEqual(req.listeners('error').length, 0); + req.on('error', err => { + reject(err); + }); + + return req.end(); + }); + + await assert.rejects(promiseRequest, /Error: request destroyed/); + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.ok(Object.keys(span.attributes).length > 7); + }); + + it("should have 1 ended client span when request doesn't listening response", done => { + // nock doesn't emit close event. + nock.cleanAll(); + nock.enableNetConnect(); + + const req = http.request(`${protocol}://${hostname}:${serverPort}/`); + req.on('close', () => { + const spans = memoryExporter + .getFinishedSpans() + .filter(it => it.kind === SpanKind.CLIENT); + assert.strictEqual(spans.length, 1); + const [span] = spans; + assert.ok(Object.keys(span.attributes).length > 6); + done(); + }); + req.end(); + }); + + it("should have 1 ended span when response is listened by using req.on('response')", done => { + const host = `${protocol}://${hostname}`; + nock(host).get('/').reply(404); + const req = http.request(`${host}/`); + req.on('response', response => { + response.on('data', () => {}); + response.on('end', () => { + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.ok(Object.keys(span.attributes).length > 6); + assert.strictEqual(span.attributes[ATTR_HTTP_STATUS_CODE], 404); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + done(); + }); + }); + req.end(); + }); + + it('custom attributes should show up on client and server spans', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { headers: { guid: 'user_guid' } } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + + // server request + assert.strictEqual( + incomingSpan.attributes['custom request hook attribute'], + 'request' + ); + assert.strictEqual( + incomingSpan.attributes['custom response hook attribute'], + 'response' + ); + assert.strictEqual( + incomingSpan.attributes['custom server response attribute'], + 'finish' + ); + assert.strictEqual(incomingSpan.attributes['guid'], 'user_guid'); + assert.strictEqual( + incomingSpan.attributes['span kind'], + SpanKind.CLIENT + ); + + // client request + assert.strictEqual( + outgoingSpan.attributes['custom request hook attribute'], + 'request' + ); + assert.strictEqual( + outgoingSpan.attributes['custom response hook attribute'], + 'response' + ); + assert.strictEqual( + outgoingSpan.attributes['custom incoming message attribute'], + 'end' + ); + assert.strictEqual(outgoingSpan.attributes['guid'], 'user_guid'); + assert.strictEqual( + outgoingSpan.attributes['span kind'], + SpanKind.CLIENT + ); + }); + + it('should not set span as active in context for outgoing request', done => { + assert.deepStrictEqual(trace.getSpan(context.active()), undefined); + http.get(`${protocol}://${hostname}:${serverPort}/test`, res => { + assert.deepStrictEqual(trace.getSpan(context.active()), undefined); + + res.on('data', () => { + assert.deepStrictEqual(trace.getSpan(context.active()), undefined); + }); + + res.on('end', () => { + assert.deepStrictEqual(trace.getSpan(context.active()), undefined); + done(); + }); + }); + }); + + it('should have 2 ended span when client prematurely close', async () => { + const promise = new Promise(resolve => { + const req = http.get( + `${protocol}://${hostname}:${serverPort}/hang`, + res => { + res.on('close', () => {}); + res.on('error', () => {}); + } + ); + // close the socket. + setTimeout(() => { + req.destroy(); + }, 10); + + req.on('error', () => {}); + + req.on('close', () => { + // yield to server to end the span. + setTimeout(resolve, 10); + }); + }); + + await promise; + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const [serverSpan, clientSpan] = spans.sort( + (lhs, rhs) => lhs.kind - rhs.kind + ); + assert.strictEqual(serverSpan.kind, SpanKind.SERVER); + assert.ok(Object.keys(serverSpan.attributes).length >= 6); + + assert.strictEqual(clientSpan.kind, SpanKind.CLIENT); + assert.ok(Object.keys(clientSpan.attributes).length >= 6); + }); + + it('should have 2 ended span when server prematurely close', async () => { + const promise = new Promise(resolve => { + const req = http.get( + `${protocol}://${hostname}:${serverPort}/premature-close` + ); + req.on('error', err => { + resolve(); + }); + }); + + await promise; + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const [serverSpan, clientSpan] = spans.sort( + (lhs, rhs) => lhs.kind - rhs.kind + ); + assert.strictEqual(serverSpan.kind, SpanKind.SERVER); + assert.ok(Object.keys(serverSpan.attributes).length >= 6); + + assert.strictEqual(clientSpan.kind, SpanKind.CLIENT); + assert.strictEqual(clientSpan.status.code, SpanStatusCode.ERROR); + assert.ok(Object.keys(clientSpan.attributes).length >= 6); + }); + + it('should not end span multiple times if request socket destroyed before response completes', async () => { + const warnMessages: string[] = []; + diag.setLogger({ + ...new DiagConsoleLogger(), + warn: message => { + warnMessages.push(message); + }, + }); + const promise = new Promise(resolve => { + const req = http.request( + `${protocol}://${hostname}:${serverPort}/destroy-request`, + { + // Allow `req.write()`. + method: 'POST', + }, + res => { + res.on('end', () => {}); + res.on('close', () => { + resolve(); + }); + res.on('error', () => {}); + } + ); + // force flush http request header to trigger client response callback + req.write(''); + req.on('error', () => {}); + }); + + await promise; + + diag.disable(); + + assert.deepStrictEqual(warnMessages, []); + }); + + it('should not throw with cyrillic characters in the request path', async () => { + // see https://github.com/open-telemetry/opentelemetry-js/issues/5060 + await httpRequest.get(`${protocol}://${hostname}:${serverPort}/привет`); + }); + + it('should keep username and password in the request', async () => { + await httpRequest.get( + `${protocol}://username:password@${hostname}:${serverPort}/login` + ); + }); + + it('should keep query in the request', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}/withQuery?foo=bar` + ); + }); + + it('using an invalid url does throw from client but still creates a span', async () => { + try { + await httpRequest.get(`http://instrumentation.test:string-as-port/`); + } catch (e) { + assert.match(e.message, /Invalid URL/); + } + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + }); + }); + + describe('with semconv stability set to http', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(async () => { + instrumentation.setConfig({}); + // @ts-expect-error + instrumentation['_delegate']['_semconvStability'] = SemconvStability.STABLE; + instrumentation.enable(); + server = http.createServer((request, response) => { + if (request.url?.includes('/premature-close')) { + response.destroy(); + return; + } + if (request.url?.includes('/hang')) { + // write response headers. + response.write(''); + // hang the request. + return; + } + if (request.url?.includes('/destroy-request')) { + // force flush http response header to trigger client response callback + response.write(''); + setTimeout(() => { + request.socket.destroy(); + }, 100); + return; + } + if (request.url?.includes('/ignored')) { + provider.getTracer('test').startSpan('some-span').end(); + } + if (request.url?.includes('/setroute')) { + const rpcData = getRPCMetadata(context.active()); + assert.ok(rpcData != null); + assert.strictEqual(rpcData.type, RPCType.HTTP); + assert.strictEqual(rpcData.route, undefined); + rpcData.route = 'TheRoute'; + } + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ address: getRemoteClientAddress(request) }) + ); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should generate semconv 1.27 client spans', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + assert.strictEqual(spans.length, 2); + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(outgoingSpan.attributes, { + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_SERVER_PORT]: serverPort, + [ATTR_URL_FULL]: `${protocol}://${hostname}:${serverPort}${pathname}`, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: response.address, + [ATTR_NETWORK_PEER_PORT]: serverPort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + }); + }); + + it('should generate semconv 1.27 server spans', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, _] = spans; + assert.strictEqual(spans.length, 2); + + const body = JSON.parse(response.data); + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(incomingSpan.attributes, { + [ATTR_CLIENT_ADDRESS]: body.address, + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_SERVER_PORT]: serverPort, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: body.address, + [ATTR_NETWORK_PEER_PORT]: response.clientRemotePort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_URL_PATH]: pathname, + [ATTR_URL_SCHEME]: protocol, + }); + }); + + it('should redact auth from the `url.full` attribute (client side and server side)', async () => { + await httpRequest.get( + `${protocol}://user:pass@${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + assert.strictEqual(spans.length, 2); + assert.strictEqual(outgoingSpan.kind, SpanKind.CLIENT); + assert.strictEqual( + outgoingSpan.attributes[ATTR_URL_FULL], + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}` + ); + }); + + it('should generate semconv 1.27 server spans with route when RPC metadata is available', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}/setroute` + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, _] = spans; + assert.strictEqual(spans.length, 2); + + const body = JSON.parse(response.data); + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(incomingSpan.attributes, { + [ATTR_CLIENT_ADDRESS]: body.address, + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_HTTP_ROUTE]: 'TheRoute', + [ATTR_SERVER_PORT]: serverPort, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: body.address, + [ATTR_NETWORK_PEER_PORT]: response.clientRemotePort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_URL_PATH]: `${pathname}/setroute`, + [ATTR_URL_SCHEME]: protocol, + }); + }); + }); + + describe('with semconv stability set to http/dup', () => { + beforeEach(() => { + memoryExporter.reset(); + instrumentation.setConfig({}); + }); + + before(async () => { + // @ts-expect-error + instrumentation['_delegate']['_semconvStability'] = SemconvStability.DUPLICATE; + instrumentation.enable(); + server = http.createServer((request, response) => { + if (request.url?.includes('/setroute')) { + const rpcData = getRPCMetadata(context.active()); + assert.ok(rpcData != null); + assert.strictEqual(rpcData.type, RPCType.HTTP); + assert.strictEqual(rpcData.route, undefined); + rpcData.route = 'TheRoute'; + } + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ address: getRemoteClientAddress(request) }) + ); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should create client spans with semconv 1.27 and old 1.7', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const outgoingSpan = spans[1]; + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(outgoingSpan.attributes, { + // 1.27 attributes + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_SERVER_PORT]: serverPort, + [ATTR_URL_FULL]: `http://${hostname}:${serverPort}${pathname}`, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: response.address, + [ATTR_NETWORK_PEER_PORT]: serverPort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + + // 1.7 attributes + [ATTR_HTTP_FLAVOR]: '1.1', + [ATTR_HTTP_HOST]: `${hostname}:${serverPort}`, + [ATTR_HTTP_METHOD]: 'GET', + [ATTR_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED]: + response.data.length, + [ATTR_HTTP_STATUS_CODE]: 200, + [ATTR_HTTP_TARGET]: '/test', + [ATTR_HTTP_URL]: `http://${hostname}:${serverPort}${pathname}`, + [ATTR_NET_PEER_IP]: response.address, + [ATTR_NET_PEER_NAME]: hostname, + [ATTR_NET_PEER_PORT]: serverPort, + [ATTR_NET_TRANSPORT]: 'ip_tcp', + + // unspecified old names + [AttributeNames.HTTP_STATUS_TEXT]: 'OK', + }); + }); + + it('should create server spans with semconv 1.27 and old 1.7', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const incomingSpan = spans[0]; + const body = JSON.parse(response.data); + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(incomingSpan.attributes, { + // 1.27 attributes + [ATTR_CLIENT_ADDRESS]: body.address, + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_SERVER_PORT]: serverPort, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: body.address, + [ATTR_NETWORK_PEER_PORT]: response.clientRemotePort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_URL_PATH]: pathname, + [ATTR_URL_SCHEME]: protocol, + + // 1.7 attributes + [ATTR_HTTP_FLAVOR]: '1.1', + [ATTR_HTTP_HOST]: `${hostname}:${serverPort}`, + [ATTR_HTTP_METHOD]: 'GET', + [ATTR_HTTP_SCHEME]: protocol, + [ATTR_HTTP_STATUS_CODE]: 200, + [ATTR_HTTP_TARGET]: '/test', + [ATTR_HTTP_URL]: `http://${hostname}:${serverPort}${pathname}`, + [ATTR_NET_TRANSPORT]: 'ip_tcp', + [ATTR_NET_HOST_IP]: body.address, + [ATTR_NET_HOST_NAME]: hostname, + [ATTR_NET_HOST_PORT]: serverPort, + [ATTR_NET_PEER_IP]: body.address, + [ATTR_NET_PEER_PORT]: response.clientRemotePort, + + // unspecified old names + [AttributeNames.HTTP_STATUS_TEXT]: 'OK', + }); + }); + + it('should create server spans with semconv 1.27 and old 1.7 including http.route if RPC metadata is available', async () => { + const response = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}/setroute` + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const incomingSpan = spans[0]; + const body = JSON.parse(response.data); + + // should have only required and recommended attributes for semconv 1.27 + assert.deepStrictEqual(incomingSpan.attributes, { + // 1.27 attributes + [ATTR_CLIENT_ADDRESS]: body.address, + [ATTR_HTTP_REQUEST_METHOD]: HTTP_REQUEST_METHOD_VALUE_GET, + [ATTR_SERVER_ADDRESS]: hostname, + [ATTR_SERVER_PORT]: serverPort, + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PEER_ADDRESS]: body.address, + [ATTR_NETWORK_PEER_PORT]: response.clientRemotePort, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_URL_PATH]: `${pathname}/setroute`, + [ATTR_URL_SCHEME]: protocol, + [ATTR_HTTP_ROUTE]: 'TheRoute', + + // 1.7 attributes + [ATTR_HTTP_FLAVOR]: '1.1', + [ATTR_HTTP_HOST]: `${hostname}:${serverPort}`, + [ATTR_HTTP_METHOD]: 'GET', + [ATTR_HTTP_SCHEME]: protocol, + [ATTR_HTTP_STATUS_CODE]: 200, + [ATTR_HTTP_TARGET]: `${pathname}/setroute`, + [ATTR_HTTP_URL]: `http://${hostname}:${serverPort}${pathname}/setroute`, + [ATTR_NET_TRANSPORT]: 'ip_tcp', + [ATTR_NET_HOST_IP]: body.address, + [ATTR_NET_HOST_NAME]: hostname, + [ATTR_NET_HOST_PORT]: serverPort, + [ATTR_NET_PEER_IP]: body.address, + [ATTR_NET_PEER_PORT]: response.clientRemotePort, + + // unspecified old names + [AttributeNames.HTTP_STATUS_TEXT]: 'OK', + }); + }); + }); + + describe('with require parent span', () => { + beforeEach(done => { + memoryExporter.reset(); + instrumentation.setConfig({}); + instrumentation.enable(); + server = http.createServer((request, response) => { + response.end('Test Server Response'); + }); + server.listen(serverPort, done); + }); + + afterEach(() => { + server.close(); + instrumentation.disable(); + }); + + it('should not trace without parent with options enabled (both client & server)', async () => { + instrumentation.disable(); + instrumentation.setConfig({ + requireParentforIncomingSpans: true, + requireParentforOutgoingSpans: true, + }); + instrumentation.enable(); + const testPath = '/test/test'; + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${testPath}` + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + it('should not trace without parent with options enabled (client only)', async () => { + instrumentation.disable(); + instrumentation.setConfig({ + requireParentforOutgoingSpans: true, + }); + instrumentation.enable(); + const testPath = '/test/test'; + const result = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${testPath}` + ); + assert.ok( + result.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY] !== undefined + ); + assert.ok( + result.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY] !== undefined + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual( + spans.every(span => span.kind === SpanKind.SERVER), + true + ); + }); + + it('should not trace without parent with options enabled (server only)', async () => { + instrumentation.disable(); + instrumentation.setConfig({ + requireParentforIncomingSpans: true, + }); + instrumentation.enable(); + const testPath = '/test/test'; + const result = await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${testPath}` + ); + assert.ok( + result.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY] !== undefined + ); + assert.ok( + result.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY] !== undefined + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual( + spans.every(span => span.kind === SpanKind.CLIENT), + true + ); + }); + + it('should not trace with INVALID_SPAN_CONTEXT parent with requireParent options enabled', async () => { + instrumentation.disable(); + instrumentation.setConfig({ + requireParentforIncomingSpans: true, + requireParentforOutgoingSpans: true, + }); + instrumentation.enable(); + const root = trace.wrapSpanContext(INVALID_SPAN_CONTEXT); + await context.with(trace.setSpan(context.active(), root), async () => { + const testPath = '/test/test'; + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${testPath}` + ); + }); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + it('should trace with parent with both requireParent options enabled', done => { + instrumentation.disable(); + instrumentation.setConfig({ + requireParentforIncomingSpans: true, + requireParentforOutgoingSpans: true, + }); + instrumentation.enable(); + const testPath = '/test/test'; + const tracer = provider.getTracer('default'); + const span = tracer.startSpan('parentSpan', { + kind: SpanKind.INTERNAL, + }); + context.with(trace.setSpan(context.active(), span), () => { + httpRequest + .get(`${protocol}://${hostname}:${serverPort}${testPath}`) + .then(result => { + span.end(); + assert.ok( + result.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY] !== + undefined + ); + assert.ok( + result.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY] !== + undefined + ); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.CLIENT).length, + 1 + ); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.INTERNAL).length, + 1 + ); + return done(); + }) + .catch(done); + }); + }); + }); + describe('rpc metadata', () => { + beforeEach(() => { + memoryExporter.reset(); + instrumentation.setConfig({ requireParentforOutgoingSpans: true }); + instrumentation.enable(); + }); + + afterEach(() => { + server.close(); + instrumentation.disable(); + }); + + it('should set rpc metadata for incoming http request', async () => { + server = http.createServer((request, response) => { + const rpcMetadata = getRPCMetadata(context.active()); + assert.ok(typeof rpcMetadata !== 'undefined'); + assert.ok(rpcMetadata.type === RPCType.HTTP); + assert.ok(rpcMetadata.span.setAttribute('key', 'value')); + response.end('Test Server Response'); + }); + await new Promise(resolve => server.listen(serverPort, resolve)); + await httpRequest.get(`${protocol}://${hostname}:${serverPort}`); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].attributes.key, 'value'); + }); + }); + }); + + describe('capturing headers as span attributes', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(async () => { + instrumentation.setConfig({ + headersToSpanAttributes: { + client: { + requestHeaders: ['X-Client-Header1'], + responseHeaders: ['X-Server-Header1'], + }, + server: { + requestHeaders: ['X-Client-Header2'], + responseHeaders: ['X-Server-Header2'], + }, + }, + }); + instrumentation.enable(); + server = http.createServer((request, response) => { + response.setHeader('X-ServeR-header1', 'server123'); + response.setHeader('X-Server-header2', '123server'); + response.end('Test Server Response'); + }); + + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should convert headers to span attributes', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'X-client-header1': 'client123', + 'X-CLIENT-HEADER2': '123client', + }, + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + + assert.strictEqual(spans.length, 2); + + assert.deepStrictEqual( + incomingSpan.attributes['http.request.header.x_client_header2'], + ['123client'] + ); + + assert.deepStrictEqual( + incomingSpan.attributes['http.response.header.x_server_header2'], + ['123server'] + ); + + assert.strictEqual( + incomingSpan.attributes['http.request.header.x_client_header1'], + undefined + ); + + assert.strictEqual( + incomingSpan.attributes['http.response.header.x_server_header1'], + undefined + ); + + assert.deepStrictEqual( + outgoingSpan.attributes['http.request.header.x_client_header1'], + ['client123'] + ); + assert.deepStrictEqual( + outgoingSpan.attributes['http.response.header.x_server_header1'], + ['server123'] + ); + + assert.strictEqual( + outgoingSpan.attributes['http.request.header.x_client_header2'], + undefined + ); + + assert.strictEqual( + outgoingSpan.attributes['http.response.header.x_server_header2'], + undefined + ); + }); + }); + describe('URL Redaction', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(async () => { + instrumentation.setConfig({}); + instrumentation.enable(); + server = http.createServer((request, response) => { + response.end('Test Server Response'); + }); + await new Promise(resolve => server.listen(serverPort, resolve)); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should redact authentication credentials from URLs', async () => { + await httpRequest.get( + `${protocol}://user:password@${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(incomingSpan.kind, SpanKind.SERVER); + assert.strictEqual(outgoingSpan.kind, SpanKind.CLIENT); + + // Server shouldn't see auth in URL + assert.strictEqual( + incomingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + + // Client should have redacted auth + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}` + ); + }); + it('should redact default query strings', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=xyz789&normal=value` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=REDACTED&normal=value` + ); + }); + + it('should handle both auth credentials and sensitive default query parameters', async () => { + await httpRequest.get( + `${protocol}://username:password@${hostname}:${serverPort}${pathname}?AWSAccessKeyId=secret` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}?AWSAccessKeyId=REDACTED` + ); + }); + it('should handle URLs with special characters in auth and query', async () => { + await httpRequest.get( + `${protocol}://user%40domain:p%40ssword@${hostname}:${serverPort}${pathname}?sig=abc%3Ddef` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}?sig=REDACTED` + ); + }); + + it('should handle malformed query strings', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=value&=nokey&malformed=` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=REDACTED&=nokey&malformed=` + ); + }); + it('should not modify URLs without auth or sensitive query parameters', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?param=value&another=123` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?param=value&another=123` + ); + }); + + it('should not modify URLs with no query string', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + }); + + it('should not modify URLs with empty query parameters', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?sig=&empty=` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?sig=&empty=` + ); + }); + + it('should preserve non-sensitive query parameters when sensitive ones are redacted', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?normal=value&Signature=secret&other=data` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?normal=value&Signature=REDACTED&other=data` + ); + }); + it('should redact only custom query parameters when user provides a populated config', async () => { + // Set additional parameters while keeping the default ones + instrumentation.setConfig({ + redactedQueryParams: ['authorize', 'session_id'], + }); + + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?sig=abc123&authorize=xyz789&normal=value` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?sig=abc123&authorize=REDACTED&normal=value` + ); + }); + it('should not redact query strings when redactedQueryParams is empty', async () => { + instrumentation.setConfig({ + redactedQueryParams: [], + }); + + // URL with both default sensitive params and custom ones + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=secret&api_key=12345&normal=value` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?X-Goog-Signature=secret&api_key=12345&normal=value` + ); + }); + it('should handle case-sensitive query parameter names correctly', async () => { + instrumentation.setConfig({ + redactedQueryParams: ['TOKEN'], + }); + + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}?token=lowercase&TOKEN=uppercase&sig=secret` + ); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + // This tests whether parameter name matching is case-sensitive or case-insensitive + assert.strictEqual( + outgoingSpan.attributes[ATTR_HTTP_URL], + `${protocol}://${hostname}:${serverPort}${pathname}?token=lowercase&TOKEN=REDACTED&sig=secret` + ); + }); + it('should handle very complex URLs with multiple redaction points and if custom query strings are provided only redact those', async () => { + instrumentation.setConfig({ + redactedQueryParams: ['api_key', 'token'], + }); + + const complexUrl = + `${protocol}://user:pass@${hostname}:${serverPort}${pathname}?` + + 'sig=abc123&api_key=secret&normal=value&Signature=xyz&' + + 'token=sensitive&X-Goog-Signature=gcp&AWSAccessKeyId=aws'; + + await httpRequest.get(complexUrl); + const spans = memoryExporter.getFinishedSpans(); + const [_, outgoingSpan] = spans; + + const expectedUrl = + `${protocol}://REDACTED:REDACTED@${hostname}:${serverPort}${pathname}?` + + 'sig=abc123&api_key=REDACTED&normal=value&Signature=xyz&' + + 'token=REDACTED&X-Goog-Signature=gcp&AWSAccessKeyId=aws'; + + assert.strictEqual(outgoingSpan.attributes[ATTR_HTTP_URL], expectedUrl); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation/src/index.ts b/experimental/packages/opentelemetry-instrumentation/src/index.ts index d903567ff34..7819c60b7ca 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/index.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/index.ts @@ -15,7 +15,7 @@ */ export { registerInstrumentations } from './autoLoader'; -export { InstrumentationBase } from './platform/index'; +export { InstrumentationBase, createInstrumentation } from './platform/index'; export { InstrumentationNodeModuleDefinition } from './instrumentationNodeModuleDefinition'; export { InstrumentationNodeModuleFile } from './instrumentationNodeModuleFile'; export type { diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/index.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/index.ts index f24b70eac5d..e29c3219125 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/platform/index.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { InstrumentationBase, normalize } from './node'; +export { InstrumentationBase, createInstrumentation, normalize } from './node'; diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts new file mode 100644 index 00000000000..30b5160113a --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts @@ -0,0 +1,465 @@ +/* + * 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. + */ + +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { types } from 'util'; + +import { DiagLogger, diag, metrics, trace } from '@opentelemetry/api'; +import { logs } from '@opentelemetry/api-logs'; +import type { HookFn } from 'import-in-the-middle'; +import { Hook as HookImport } from 'import-in-the-middle'; +import type { OnRequireFn } from 'require-in-the-middle'; +import { Hook as HookRequire } from 'require-in-the-middle'; + +import { + RequireInTheMiddleSingleton, + Hooked, +} from './RequireInTheMiddleSingleton'; +import { satisfies } from '../../semver'; +import * as shimmer from '../../shimmer'; +import type { + Instrumentation, + InstrumentationConfig, + InstrumentationDelegate, + InstrumentationModuleDefinition, +} from '../../types'; +import { isWrapped } from '../../utils'; + +const _kOtDiag = Symbol('otel_instrumentation_diag'); +const _kOtEnabled = Symbol('otel_instrumentation_enabled'); +const _kOtModules = Symbol('otel_instrumentation_modules'); +const _kOtHooks = Symbol('otel_instrumentation_hooks'); + +/** + * sets a value in the target object for the given symbol + */ +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +function set(target: any, key: symbol, val: unknown) { + target[key] = val; +} + +/** + * gets the value stored for the symbol. + */ +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +function get(target: any, key: symbol): T { + return target[key] as T; +} + +export function createInstrumentation( + delegate: InstrumentationDelegate, + config: T +): Instrumentation { + const delegateConfig = { enabled: true, ...config }; + const diagLogger = diag.createComponentLogger({ namespace: delegate.name }); + delegate.setConfig(delegateConfig); + delegate.setDiag(diagLogger); + delegate.setTracer(trace.getTracer(delegate.name, delegate.version)); + delegate.setMeter(metrics.getMeter(delegate.name, delegate.version)); + delegate.setLogger(logs.getLogger(delegate.name, delegate.version)); + + // Keep the diagLogger + set(delegate, _kOtDiag, diagLogger); + set(delegate, _kOtHooks, []); + // Set the modules + let modules = delegate.init(nodeShimmer); + if (modules && !Array.isArray(modules)) { + modules = [modules]; + } + set(delegate, _kOtModules, modules || []); + // And enable + if (delegateConfig.enabled) { + enableInstrumentation(delegate); + } + + return { + _delegate: delegate, // For testing purposes + instrumentationName: delegate.name, + instrumentationVersion: delegate.version, + setConfig(cfg) { + delegate.setConfig(cfg); + }, + getConfig() { + return delegate.getConfig(); + }, + enable() { + enableInstrumentation(delegate); + delegate.enable?.(); + }, + disable() { + disableInstrumentation(delegate); + delegate.disable?.(); + }, + setTracerProvider(traceProv) { + delegate.setTracer(traceProv.getTracer(delegate.name, delegate.version)); + }, + setMeterProvider(meterProv) { + delegate.setMeter(meterProv.getMeter(delegate.name, delegate.version)); + }, + setLoggerProvider(loggerProv) { + delegate.setLogger(loggerProv.getLogger(delegate.name, delegate.version)); + }, + } as Instrumentation; +} + +/** + * Registers IITM and RITM hooks the 1st time is called and + * applies the patches on any subsequent call if not enabled. + */ +function enableInstrumentation(delegate: InstrumentationDelegate) { + const enabled = get(delegate, _kOtEnabled); + if (enabled) { + return; + } + + set(delegate, _kOtEnabled, true); + const diag = get(delegate, _kOtDiag); + const modules = get(delegate, _kOtModules); + const hooks = get<(Hooked | HookRequire)[]>(delegate, _kOtHooks); + + // already hooked, just call patch again + if (hooks.length > 0) { + for (const module of modules) { + if (typeof module.patch === 'function' && module.moduleExports) { + diag.debug( + 'Applying instrumentation patch for nodejs module on instrumentation enabled', + { + module: module.name, + version: module.moduleVersion, + } + ); + module.patch(module.moduleExports, module.moduleVersion); + } + for (const file of module.files) { + if (file.moduleExports) { + diag.debug( + 'Applying instrumentation patch for nodejs module file on instrumentation enabled', + { + module: module.name, + version: module.moduleVersion, + fileName: file.name, + } + ); + file.patch(file.moduleExports, module.moduleVersion); + } + } + } + return; + } + + // warn on preloaded modules + for (const module of modules) { + const { name } = module; + try { + const resolvedModule = require.resolve(name); + if (require.cache[resolvedModule]) { + // Module is already cached, which means the instrumentation hook might not work + diag.warn( + `Module ${name} has been loaded before ${delegate.name} so it might not work, please initialize it before requiring ${name}` + ); + } + } catch { + // Module isn't available, we can simply skip + } + } + + // Patch modules for the 1st time and register the hooks + const ritmSingleton = RequireInTheMiddleSingleton.getInstance(); + for (const module of modules) { + const hookFn: HookFn = (exports, name, baseDir) => { + if (!baseDir && path.isAbsolute(name)) { + const parsedPath = path.parse(name); + name = parsedPath.name; + baseDir = parsedPath.dir; + } + return onRequire( + delegate, + module, + exports, + name, + baseDir + ); + }; + const requireFn: OnRequireFn = (exports, name, baseDir) => { + return onRequire( + delegate, + module, + exports, + name, + baseDir + ); + }; + + // `RequireInTheMiddleSingleton` does not support absolute paths. + // For an absolute paths, we must create a separate instance of the + // require-in-the-middle `Hook`. + const hook = path.isAbsolute(module.name) + ? new HookRequire([module.name], { internals: true }, requireFn) + : ritmSingleton.register(module.name, requireFn); + + hooks.push(hook); + const esmHook = new HookImport( + [module.name], + { internals: false }, + hookFn + ); + hooks.push(esmHook); + } +} + +/** + * Unpatches the modules if the instrumentation is enabled. + */ +function disableInstrumentation(delegate: InstrumentationDelegate) { + const enabled = get(delegate, _kOtEnabled); + if (!enabled) { + return; + } + + set(delegate, _kOtEnabled, false); + const diag = get(delegate, _kOtDiag); + const modules = get(delegate, _kOtModules); + + for (const module of modules) { + if (typeof module.unpatch === 'function' && module.moduleExports) { + diag.debug( + 'Removing instrumentation patch for nodejs module on instrumentation disabled', + { + module: module.name, + version: module.moduleVersion, + } + ); + module.unpatch(module.moduleExports, module.moduleVersion); + } + for (const file of module.files) { + if (file.moduleExports) { + diag.debug( + 'Removing instrumentation patch for nodejs module file on instrumentation disabled', + { + module: module.name, + version: module.moduleVersion, + fileName: file.name, + } + ); + file.unpatch(file.moduleExports, module.moduleVersion); + } + } + } +} + +/** + * Applies the patches defined by the instrumentation delegate to + * the exported module. + */ +function onRequire( + delegate: InstrumentationDelegate, + module: InstrumentationModuleDefinition, + exports: T, + name: string, + baseDir?: string | void +): T { + const enabled = get(delegate, _kOtEnabled); + const diag = get(delegate, _kOtDiag); + + if (!baseDir) { + if (typeof module.patch === 'function') { + module.moduleExports = exports; + if (enabled) { + diag.debug( + 'Applying instrumentation patch for nodejs core module on require hook', + { + module: module.name, + } + ); + return module.patch(exports); + } + } + return exports; + } + + // Get the version + try { + const pkgInfo = JSON.parse( + readFileSync(path.join(baseDir, 'package.json'), { + encoding: 'utf8', + }) + ); + module.moduleVersion = + typeof pkgInfo.version === 'string' ? pkgInfo.version : undefined; + } catch (error) { + diag.warn('Failed extracting version', baseDir, error.message); + } + + if (module.name === name) { + // main module + if ( + isSupported( + module.supportedVersions, + module.moduleVersion, + module.includePrerelease + ) + ) { + if (typeof module.patch === 'function') { + module.moduleExports = exports; + if (enabled) { + diag.debug( + 'Applying instrumentation patch for module on require hook', + { + module: module.name, + version: module.moduleVersion, + baseDir, + } + ); + return module.patch(exports, module.moduleVersion); + } + } + } + return exports; + } + + // internal file + const files = module.files ?? []; + const normalizedName = path.normalize(name); + const supportedFileInstrumentations = files + .filter(f => f.name === normalizedName) + .filter(f => + isSupported( + f.supportedVersions, + module.moduleVersion, + module.includePrerelease + ) + ); + return supportedFileInstrumentations.reduce((patchedExports, file) => { + file.moduleExports = patchedExports; + if (enabled) { + diag.debug( + 'Applying instrumentation patch for nodejs module file on require hook', + { + module: module.name, + version: module.moduleVersion, + fileName: file.name, + baseDir, + } + ); + + // patch signature is not typed, so we cast it assuming it's correct + return file.patch(patchedExports, module.moduleVersion) as T; + } + return patchedExports; + }, exports); +} + +/** + * Tells if a package version is supported + */ +function isSupported( + supportedVersions: string[], + version?: string, + includePrerelease?: boolean +): boolean { + if (typeof version === 'undefined') { + // If we don't have the version, accept the wildcard case only + return supportedVersions.includes('*'); + } + + return supportedVersions.some(supportedVersion => { + return satisfies(version, supportedVersion, { includePrerelease }); + }); +} + +// A shimmer for node +const nodeWrap: typeof shimmer.wrap = (moduleExports, name, wrapper) => { + if (isWrapped(moduleExports[name])) { + nodeUnwrap(moduleExports, name); + } + if (!types.isProxy(moduleExports)) { + return shimmer.wrap(moduleExports, name, wrapper); + } else { + const wrapped = shimmer.wrap( + Object.assign({}, moduleExports), + name, + wrapper + ); + Object.defineProperty(moduleExports, name, { + value: wrapped, + }); + return wrapped; + } +}; +const nodeUnwrap: typeof shimmer.unwrap = (moduleExports, name) => { + if (!types.isProxy(moduleExports)) { + return shimmer.unwrap(moduleExports, name); + } else { + return Object.defineProperty(moduleExports, name, { + value: moduleExports[name], + }); + } +}; + +const nodeMassWrap: typeof shimmer.massWrap = ( + moduleExportsArray, + names, + wrapper +) => { + if (!moduleExportsArray) { + diag.error('must provide one or more modules to patch'); + return; + } else if (!Array.isArray(moduleExportsArray)) { + moduleExportsArray = [moduleExportsArray]; + } + + if (!(names && Array.isArray(names))) { + diag.error('must provide one or more functions to wrap on modules'); + return; + } + + moduleExportsArray.forEach(moduleExports => { + names.forEach(name => { + nodeWrap(moduleExports, name, wrapper); + }); + }); +}; + +const nodeMassUnwrap: typeof shimmer.massUnwrap = ( + moduleExportsArray, + names +) => { + if (!moduleExportsArray) { + diag.error('must provide one or more modules to patch'); + return; + } else if (!Array.isArray(moduleExportsArray)) { + moduleExportsArray = [moduleExportsArray]; + } + + if (!(names && Array.isArray(names))) { + diag.error('must provide one or more functions to wrap on modules'); + return; + } + + moduleExportsArray.forEach(moduleExports => { + names.forEach(name => { + nodeUnwrap(moduleExports, name); + }); + }); +}; + +const nodeShimmer = { + wrap: nodeWrap, + unwrap: nodeUnwrap, + massWrap: nodeMassWrap, + massUnwrap: nodeMassUnwrap, +}; diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/index.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/index.ts index 94f517dfa37..f88f0c17823 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/platform/node/index.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ export { InstrumentationBase } from './instrumentation'; +export { createInstrumentation } from './create-instrumentation'; export { normalize } from './normalize'; diff --git a/experimental/packages/opentelemetry-instrumentation/src/types.ts b/experimental/packages/opentelemetry-instrumentation/src/types.ts index b67b26cfa07..50e2259570d 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/types.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/types.ts @@ -14,8 +14,17 @@ * limitations under the License. */ -import { TracerProvider, MeterProvider, Span } from '@opentelemetry/api'; -import { LoggerProvider } from '@opentelemetry/api-logs'; +import type { + TracerProvider, + Tracer, + MeterProvider, + Span, + Meter, + DiagLogger, +} from '@opentelemetry/api'; +import type { Logger, LoggerProvider } from '@opentelemetry/api-logs'; + +import type { wrap, unwrap, massWrap, massUnwrap } from './shimmer'; /** Interface Instrumentation to apply patch. */ export interface Instrumentation< @@ -49,6 +58,34 @@ export interface Instrumentation< getConfig(): ConfigType; } +export interface InstrumentationDelegate< + ConfigType extends InstrumentationConfig = InstrumentationConfig, +> { + /** Instrumentation Name */ + name: string; + + /** Instrumentation Version */ + version: string; + + /** Method to set instrumentation config */ + setConfig(config: ConfigType): void; + + /** Method to get instrumentation config */ + getConfig(): ConfigType; // TODO: is it necessary? + + setDiag(diag: DiagLogger): void; + setTracer(tracer: Tracer): void; + setMeter(meter: Meter): void; // this should handle update instruments + setLogger(logger: Logger): void; + + /** Method to initialize instrumentation config */ + init(shimmer: Shimmer): InstrumentationModuleDefinition[] | undefined; + /** Method to enable the instrumentation */ + enable?: () => void; + /** Method to disable the instrumentation */ + disable?: () => void; +} + /** * Base interface for configuration options common to all instrumentations. * This interface can be extended by individual instrumentations to include @@ -75,6 +112,13 @@ export interface ShimWrapped extends Function { __original: Function; } +export interface Shimmer { + wrap: typeof wrap; + unwrap: typeof unwrap; + massWrap: typeof massWrap; + massUnwrap: typeof massUnwrap; +} + export interface InstrumentationModuleFile { /** Name of file to be patched with relative path */ name: string; From 5285987a15e98b4658941c794bd58565eb20079e Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 12:32:39 +0100 Subject: [PATCH 2/7] chore: fix lint issues --- .../src/http-delegate.ts | 31 +++++++++++++------ .../functionals/http-delegate-enable.test.ts | 10 +++--- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts index c56ef878ee5..dace9fcccd6 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts @@ -87,7 +87,10 @@ import { setSpanWithError, } from './utils'; import { Err, Func, Http, HttpRequestArgs, Https } from './internal-types'; -import { InstrumentationDelegate, Shimmer } from '@opentelemetry/instrumentation/src/types'; +import { + InstrumentationDelegate, + Shimmer, +} from '@opentelemetry/instrumentation/src/types'; import { Logger } from '@opentelemetry/api-logs'; type HeaderCapture = { @@ -98,16 +101,19 @@ type HeaderCapture = { server: { captureRequestHeaders: ReturnType; captureResponseHeaders: ReturnType; - } -} + }; +}; -class HttpInstrumentationDelegate implements InstrumentationDelegate { +class HttpInstrumentationDelegate + implements InstrumentationDelegate +{ name = '@opentelemetry/instrumentation-http'; version = VERSION; private _config!: HttpInstrumentationConfig; private _diag!: DiagLogger; private _tracer!: Tracer; - // private _logger!: Logger; + // @ts-expect-error - unused for now + private _logger!: Logger; /** keep track on spans not ended */ private readonly _spanNotEnded: WeakSet = new WeakSet(); @@ -134,7 +140,7 @@ class HttpInstrumentationDelegate implements InstrumentationDelegate { +export function createHttpInstrumentation( + config: HttpInstrumentationConfig = {} +): Instrumentation { return createInstrumentation(new HttpInstrumentationDelegate(), config); } diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts index 6b7dd2c341e..046a16711ca 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts @@ -1072,8 +1072,9 @@ describe('HttpInstrumentationDelegate', () => { before(async () => { instrumentation.setConfig({}); - // @ts-expect-error - instrumentation['_delegate']['_semconvStability'] = SemconvStability.STABLE; + // @ts-expect-error - accesing internal property not available in the type + instrumentation['_delegate']['_semconvStability'] = + SemconvStability.STABLE; instrumentation.enable(); server = http.createServer((request, response) => { if (request.url?.includes('/premature-close')) { @@ -1212,8 +1213,9 @@ describe('HttpInstrumentationDelegate', () => { }); before(async () => { - // @ts-expect-error - instrumentation['_delegate']['_semconvStability'] = SemconvStability.DUPLICATE; + // @ts-expect-error - accesing internal property not available in the type + instrumentation['_delegate']['_semconvStability'] = + SemconvStability.DUPLICATE; instrumentation.enable(); server = http.createServer((request, response) => { if (request.url?.includes('/setroute')) { From 5a00cd39a2bf64707379cce714d3b6e503288dc4 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 15:56:49 +0100 Subject: [PATCH 3/7] chore: add more tests --- .../src/index.ts | 1 + .../functionals/http-delegate-disable.test.ts | 90 +++ .../functionals/http-delegate-enable.test.ts | 3 +- .../functionals/http-delegate-metrics.test.ts | 490 +++++++++++ .../functionals/http-delegate-package.test.ts | 127 +++ .../https-delegate-disable.test.ts | 98 +++ .../functionals/https-delegate-enable.test.ts | 758 ++++++++++++++++++ .../https-delegate-package.test.ts | 127 +++ 8 files changed, 1692 insertions(+), 2 deletions(-) create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-disable.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-package.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-disable.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-enable.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-package.test.ts diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/index.ts b/experimental/packages/opentelemetry-instrumentation-http/src/index.ts index 73d72fec1cb..aa2f9ab8821 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/index.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/index.ts @@ -15,6 +15,7 @@ */ export { HttpInstrumentation } from './http'; +export { createHttpInstrumentation } from './http-delegate'; export type { HttpCustomAttributeFunction, HttpInstrumentationConfig, diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-disable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-disable.test.ts new file mode 100644 index 00000000000..729b939e33a --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-disable.test.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ +import * as assert from 'assert'; +import { createHttpInstrumentation } from '../../src'; +import { AddressInfo } from 'net'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +import { httpRequest } from '../utils/httpRequest'; +import { isWrapped } from '@opentelemetry/instrumentation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import { + trace, + TracerProvider, + INVALID_SPAN_CONTEXT, +} from '@opentelemetry/api'; + +describe('HttpInstrumentationDelegate', () => { + let server: http.Server; + let serverPort = 0; + + describe('disable()', () => { + let provider: TracerProvider; + let startSpanStub: sinon.SinonStub; + + before(() => { + provider = { + getTracer: () => { + startSpanStub = sinon + .stub() + .returns(trace.wrapSpanContext(INVALID_SPAN_CONTEXT)); + return { startSpan: startSpanStub } as any; + }, + }; + nock.cleanAll(); + nock.enableNetConnect(); + instrumentation.enable(); + assert.strictEqual(isWrapped(http.Server.prototype.emit), true); + instrumentation.setTracerProvider(provider); + + server = http.createServer((request, response) => { + response.end('Test Server Response'); + }); + + server.listen(serverPort); + server.once('listening', () => { + serverPort = (server.address() as AddressInfo).port; + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + after(() => { + server.close(); + }); + describe('unpatch()', () => { + it('should not call provider methods for creating span', async () => { + instrumentation.disable(); + assert.strictEqual(isWrapped(http.Server.prototype.emit), false); + + const testPath = '/incoming/unpatch/'; + + const options = { host: 'localhost', path: testPath, port: serverPort }; + + await httpRequest.get(options).then(() => { + sinon.assert.notCalled(startSpanStub); + }); + }); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts index 046a16711ca..9f44fd452f5 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts @@ -67,8 +67,7 @@ import { import * as assert from 'assert'; import * as nock from 'nock'; import * as path from 'path'; -import { HttpInstrumentationConfig } from '../../src/types'; -import { createHttpInstrumentation } from '../../src/http-delegate'; +import { createHttpInstrumentation, HttpInstrumentationConfig } from '../../src'; import { assertSpan } from '../utils/assertSpan'; import { DummyPropagation } from '../utils/DummyPropagation'; import { httpRequest } from '../utils/httpRequest'; diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts new file mode 100644 index 00000000000..bb30cf5c50c --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts @@ -0,0 +1,490 @@ +/* + * 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. + */ +import { + AggregationTemporality, + DataPointType, + InMemoryMetricExporter, + MeterProvider, +} from '@opentelemetry/sdk-metrics'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + ATTR_ERROR_TYPE, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_ROUTE, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_SCHEME, +} from '@opentelemetry/semantic-conventions'; +import { + ATTR_HTTP_FLAVOR, + ATTR_HTTP_METHOD, + ATTR_HTTP_SCHEME, + ATTR_HTTP_STATUS_CODE, + ATTR_NET_HOST_NAME, + ATTR_NET_HOST_PORT, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, +} from '../../src/semconv'; +import * as assert from 'assert'; +import { createHttpInstrumentation } from '../../src'; +import { httpRequest } from '../utils/httpRequest'; +import { TestMetricReader } from '../utils/TestMetricReader'; +import { context, ContextManager } from '@opentelemetry/api'; +import { SemconvStability } from '@opentelemetry/instrumentation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import { getRPCMetadata, RPCType } from '@opentelemetry/core'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; + +let server: http.Server; +const serverPort = 22346; +const protocol = 'http'; +const hostname = 'localhost'; +const pathname = '/test'; +const tracerProvider = new NodeTracerProvider(); +const metricsMemoryExporter = new InMemoryMetricExporter( + AggregationTemporality.DELTA +); +const metricReader = new TestMetricReader(metricsMemoryExporter); +const meterProvider = new MeterProvider({ readers: [metricReader] }); + +instrumentation.setTracerProvider(tracerProvider); +instrumentation.setMeterProvider(meterProvider); + +describe('HttpInstrumentationDelegate - metrics', () => { + let contextManager: ContextManager; + + beforeEach(() => { + contextManager = new AsyncHooksContextManager().enable(); + context.setGlobalContextManager(contextManager); + instrumentation.setMeterProvider(meterProvider); + metricsMemoryExporter.reset(); + }); + + before(() => { + instrumentation.setConfig({}); + instrumentation.enable(); + server = http.createServer((request, response) => { + const rpcData = getRPCMetadata(context.active()); + assert.ok(rpcData != null); + assert.strictEqual(rpcData.type, RPCType.HTTP); + assert.strictEqual(rpcData.route, undefined); + rpcData.route = 'TheRoute'; + response.end('Test Server Response'); + }); + server.listen(serverPort); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + describe('with no stability set', () => { + it('should add server/client duration metrics', async () => { + const requestCount = 3; + for (let i = 0; i < requestCount; i++) { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + } + await metricReader.collectAndExport(); + const resourceMetrics = metricsMemoryExporter.getMetrics(); + const scopeMetrics = resourceMetrics[0].scopeMetrics; + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + const metrics = scopeMetrics[0].metrics; + assert.strictEqual(metrics.length, 2, 'metrics count'); + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[0].descriptor.description, + 'Measures the duration of inbound HTTP requests.' + ); + assert.strictEqual(metrics[0].descriptor.name, 'http.server.duration'); + assert.strictEqual(metrics[0].descriptor.unit, 'ms'); + assert.strictEqual(metrics[0].dataPoints.length, 1); + assert.strictEqual( + (metrics[0].dataPoints[0].value as any).count, + requestCount + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_SCHEME], + 'http' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_METHOD], + 'GET' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_FLAVOR], + '1.1' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_NET_HOST_NAME], + 'localhost' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_STATUS_CODE], + 200 + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_NET_HOST_PORT], + 22346 + ); + + assert.strictEqual(metrics[1].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[1].descriptor.description, + 'Measures the duration of outbound HTTP requests.' + ); + assert.strictEqual(metrics[1].descriptor.name, 'http.client.duration'); + assert.strictEqual(metrics[1].descriptor.unit, 'ms'); + assert.strictEqual(metrics[1].dataPoints.length, 1); + assert.strictEqual( + (metrics[1].dataPoints[0].value as any).count, + requestCount + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_METHOD], + 'GET' + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_NET_PEER_NAME], + 'localhost' + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_NET_PEER_PORT], + 22346 + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_STATUS_CODE], + 200 + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_FLAVOR], + '1.1' + ); + }); + }); + + describe('with semconv stability set to stable', () => { + before(() => { + // @ts-expect-error - accesing internal property not available in the type + instrumentation['_delegate']['_semconvStability'] = SemconvStability.STABLE; + }); + + it('should add server/client duration metrics', async () => { + const requestCount = 3; + for (let i = 0; i < requestCount; i++) { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + } + await metricReader.collectAndExport(); + let resourceMetrics = metricsMemoryExporter.getMetrics(); + let scopeMetrics = resourceMetrics[0].scopeMetrics; + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + let metrics = scopeMetrics[0].metrics; + assert.strictEqual(metrics.length, 2, 'metrics count'); + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[0].descriptor.description, + 'Duration of HTTP server requests.' + ); + assert.strictEqual( + metrics[0].descriptor.name, + 'http.server.request.duration' + ); + assert.strictEqual(metrics[0].descriptor.unit, 's'); + assert.strictEqual(metrics[0].dataPoints.length, 1); + assert.strictEqual( + (metrics[0].dataPoints[0].value as any).count, + requestCount + ); + assert.deepStrictEqual(metrics[0].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_URL_SCHEME]: 'http', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_HTTP_ROUTE]: 'TheRoute', + }); + + assert.strictEqual(metrics[1].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[1].descriptor.description, + 'Duration of HTTP client requests.' + ); + assert.strictEqual( + metrics[1].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual(metrics[1].descriptor.unit, 's'); + assert.strictEqual(metrics[1].dataPoints.length, 1); + assert.strictEqual( + (metrics[1].dataPoints[0].value as any).count, + requestCount + ); + + assert.deepStrictEqual(metrics[1].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 22346, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + }); + + metricsMemoryExporter.reset(); + + assert.throws(() => + http.request({ + hostname, + port: serverPort, + pathname, + headers: { cookie: undefined }, + }) + ); + + await metricReader.collectAndExport(); + resourceMetrics = metricsMemoryExporter.getMetrics(); + scopeMetrics = resourceMetrics[0].scopeMetrics; + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + metrics = scopeMetrics[0].metrics; + assert.strictEqual(metrics.length, 1, 'metrics count'); + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[0].descriptor.description, + 'Duration of HTTP client requests.' + ); + assert.strictEqual( + metrics[0].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual(metrics[0].descriptor.unit, 's'); + assert.strictEqual(metrics[0].dataPoints.length, 1); + assert.strictEqual((metrics[0].dataPoints[0].value as any).count, 1); + + assert.deepStrictEqual(metrics[0].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 22346, + [ATTR_ERROR_TYPE]: 'TypeError', + }); + }); + }); + + describe('with semconv stability set to duplicate', () => { + before(() => { + // @ts-expect-error - accesing internal property not available in the type + instrumentation['_delegate']['_semconvStability'] = SemconvStability.DUPLICATE; + }); + + it('should add server/client duration metrics', async () => { + const requestCount = 3; + for (let i = 0; i < requestCount; i++) { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}` + ); + } + await metricReader.collectAndExport(); + let resourceMetrics = metricsMemoryExporter.getMetrics(); + let scopeMetrics = resourceMetrics[0].scopeMetrics; + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + let metrics = scopeMetrics[0].metrics; + assert.strictEqual(metrics.length, 4, 'metrics count'); + + // old metrics + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[0].descriptor.description, + 'Measures the duration of inbound HTTP requests.' + ); + assert.strictEqual(metrics[0].descriptor.name, 'http.server.duration'); + assert.strictEqual(metrics[0].descriptor.unit, 'ms'); + assert.strictEqual(metrics[0].dataPoints.length, 1); + assert.strictEqual( + (metrics[0].dataPoints[0].value as any).count, + requestCount + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_SCHEME], + 'http' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_METHOD], + 'GET' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_FLAVOR], + '1.1' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_NET_HOST_NAME], + 'localhost' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_STATUS_CODE], + 200 + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_NET_HOST_PORT], + 22346 + ); + + assert.strictEqual(metrics[1].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[1].descriptor.description, + 'Measures the duration of outbound HTTP requests.' + ); + assert.strictEqual(metrics[1].descriptor.name, 'http.client.duration'); + assert.strictEqual(metrics[1].descriptor.unit, 'ms'); + assert.strictEqual(metrics[1].dataPoints.length, 1); + assert.strictEqual( + (metrics[1].dataPoints[0].value as any).count, + requestCount + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_METHOD], + 'GET' + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_NET_PEER_NAME], + 'localhost' + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_NET_PEER_PORT], + 22346 + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_STATUS_CODE], + 200 + ); + assert.strictEqual( + metrics[1].dataPoints[0].attributes[ATTR_HTTP_FLAVOR], + '1.1' + ); + + // Stable metrics + assert.strictEqual(metrics[2].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[2].descriptor.description, + 'Duration of HTTP server requests.' + ); + assert.strictEqual( + metrics[2].descriptor.name, + 'http.server.request.duration' + ); + assert.strictEqual(metrics[2].descriptor.unit, 's'); + assert.strictEqual(metrics[2].dataPoints.length, 1); + assert.strictEqual( + (metrics[2].dataPoints[0].value as any).count, + requestCount + ); + assert.deepStrictEqual(metrics[2].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_URL_SCHEME]: 'http', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_HTTP_ROUTE]: 'TheRoute', + }); + + assert.strictEqual(metrics[3].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[3].descriptor.description, + 'Duration of HTTP client requests.' + ); + assert.strictEqual( + metrics[3].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual(metrics[3].descriptor.unit, 's'); + assert.strictEqual(metrics[3].dataPoints.length, 1); + assert.strictEqual( + (metrics[3].dataPoints[0].value as any).count, + requestCount + ); + + assert.deepStrictEqual(metrics[3].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 22346, + [ATTR_NETWORK_PROTOCOL_VERSION]: '1.1', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + }); + + metricsMemoryExporter.reset(); + + assert.throws(() => + http.request({ + hostname, + port: serverPort, + pathname, + headers: { cookie: undefined }, + }) + ); + + await metricReader.collectAndExport(); + resourceMetrics = metricsMemoryExporter.getMetrics(); + scopeMetrics = resourceMetrics[0].scopeMetrics; + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + metrics = scopeMetrics[0].metrics; + assert.strictEqual(metrics.length, 2, 'metrics count'); + + // Old metrics + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[0].descriptor.description, + 'Measures the duration of outbound HTTP requests.' + ); + assert.strictEqual(metrics[0].descriptor.name, 'http.client.duration'); + assert.strictEqual(metrics[0].descriptor.unit, 'ms'); + assert.strictEqual(metrics[0].dataPoints.length, 1); + assert.strictEqual((metrics[0].dataPoints[0].value as any).count, 1); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_HTTP_METHOD], + 'GET' + ); + assert.strictEqual( + metrics[0].dataPoints[0].attributes[ATTR_NET_PEER_NAME], + 'localhost' + ); + + // Stable metrics + assert.strictEqual(metrics[1].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual( + metrics[1].descriptor.description, + 'Duration of HTTP client requests.' + ); + assert.strictEqual( + metrics[1].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual(metrics[1].descriptor.unit, 's'); + assert.strictEqual(metrics[1].dataPoints.length, 1); + assert.strictEqual((metrics[1].dataPoints[0].value as any).count, 1); + + assert.deepStrictEqual(metrics[1].dataPoints[0].attributes, { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_SERVER_ADDRESS]: 'localhost', + [ATTR_SERVER_PORT]: 22346, + [ATTR_ERROR_TYPE]: 'TypeError', + }); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-package.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-package.test.ts new file mode 100644 index 00000000000..fb197835575 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-package.test.ts @@ -0,0 +1,127 @@ +/* + * 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. + */ + +import { context, SpanKind, Span, propagation } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import * as assert from 'assert'; +import * as path from 'path'; +import { createHttpInstrumentation } from '../../src'; +import { assertSpan } from '../utils/assertSpan'; +import { DummyPropagation } from '../utils/DummyPropagation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import * as superagent from 'superagent'; +import * as nock from 'nock'; +import * as axios from 'axios'; + +const memoryExporter = new InMemorySpanExporter(); +const protocol = 'http'; +const customAttributeFunction = (span: Span): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +describe('HttpInstrumentationDelegate - Packages', () => { + beforeEach(() => { + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + }); + + afterEach(() => { + context.disable(); + }); + describe('get', () => { + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], + }); + instrumentation.setTracerProvider(provider); + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + propagation.setGlobalPropagator(new DummyPropagation()); + instrumentation.setConfig({ + applyCustomAttributesOnSpan: customAttributeFunction, + }); + instrumentation.enable(); + }); + + after(() => { + // back to normal + propagation.disable(); + nock.cleanAll(); + nock.enableNetConnect(); + }); + + let resHeaders: http.IncomingHttpHeaders; + [ + { name: 'axios', httpPackage: axios }, //keep first + { name: 'superagent', httpPackage: superagent }, + ].forEach(({ name, httpPackage }) => { + it(`should create a span for GET requests and add propagation headers by using ${name} package`, async () => { + nock.load(path.join(__dirname, '../', '/fixtures/google-http.json')); + + const urlparsed = new URL( + `${protocol}://www.google.com/search?q=axios&oq=axios&aqs=chrome.0.69i59l2j0l3j69i60.811j0j7&sourceid=chrome&ie=UTF-8` + ); + const result = await httpPackage.get(urlparsed.href); + if (!resHeaders) { + const res = result as axios.AxiosResponse; + resHeaders = res.headers as any; + } + const spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + const validations = { + hostname: urlparsed.hostname, + httpStatusCode: 200, + httpMethod: 'GET', + pathname: urlparsed.pathname, + path: urlparsed.pathname + urlparsed.search, + resHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.name, 'GET'); + + switch (name) { + case 'axios': + assert.ok( + result.request.getHeader(DummyPropagation.TRACE_CONTEXT_KEY) + ); + assert.ok( + result.request.getHeader(DummyPropagation.SPAN_CONTEXT_KEY) + ); + break; + case 'superagent': + break; + default: + break; + } + assert.strictEqual(span.attributes['span kind'], SpanKind.CLIENT); + assertSpan(span, SpanKind.CLIENT, validations); + }); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-disable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-disable.test.ts new file mode 100644 index 00000000000..2303e340f08 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-disable.test.ts @@ -0,0 +1,98 @@ +/* + * 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. + */ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import type { AddressInfo } from 'net'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +import { createHttpInstrumentation } from '../../src'; +import { isWrapped } from '@opentelemetry/instrumentation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as https from 'https'; +import { httpsRequest } from '../utils/httpsRequest'; +import { + INVALID_SPAN_CONTEXT, + trace, + TracerProvider, +} from '@opentelemetry/api'; + +describe('HttpsInstrumentationDelegate', () => { + let server: https.Server; + let serverPort = 0; + + describe('disable()', () => { + let provider: TracerProvider; + let startSpanStub: sinon.SinonStub; + + before(() => { + provider = { + getTracer: () => { + startSpanStub = sinon + .stub() + .returns(trace.wrapSpanContext(INVALID_SPAN_CONTEXT)); + return { startSpan: startSpanStub } as any; + }, + }; + nock.cleanAll(); + nock.enableNetConnect(); + + instrumentation.enable(); + assert.strictEqual(isWrapped(https.Server.prototype.emit), true); + instrumentation.setTracerProvider(provider); + + server = https.createServer( + { + key: fs.readFileSync('test/fixtures/server-key.pem'), + cert: fs.readFileSync('test/fixtures/server-cert.pem'), + }, + (request, response) => { + response.end('Test Server Response'); + } + ); + + server.listen(serverPort); + server.once('listening', () => { + serverPort = (server.address() as AddressInfo).port; + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + after(() => { + server.close(); + }); + describe('unpatch()', () => { + it('should not call tracer methods for creating span', async () => { + instrumentation.disable(); + const testPath = '/incoming/unpatch/'; + + const options = { host: 'localhost', path: testPath, port: serverPort }; + + await httpsRequest.get(options).then(() => { + sinon.assert.notCalled(startSpanStub); + assert.strictEqual(isWrapped(https.Server.prototype.emit), false); + }); + }); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-enable.test.ts new file mode 100644 index 00000000000..9df678158f2 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-enable.test.ts @@ -0,0 +1,758 @@ +/* + * 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. + */ + +import { + SpanStatusCode, + context, + propagation, + Span as ISpan, + SpanKind, + trace, +} from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { ContextManager } from '@opentelemetry/api'; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { + ATTR_HTTP_CLIENT_IP, + ATTR_HTTP_FLAVOR, + ATTR_HTTP_STATUS_CODE, + ATTR_NET_HOST_PORT, + ATTR_NET_PEER_PORT, + ATTR_NET_TRANSPORT, + NET_TRANSPORT_VALUE_IP_TCP, +} from '../../src/semconv'; +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as nock from 'nock'; +import * as path from 'path'; +import { createHttpInstrumentation } from '../../src'; +import { assertSpan } from '../utils/assertSpan'; +import { DummyPropagation } from '../utils/DummyPropagation'; +import { isWrapped } from '@opentelemetry/instrumentation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import * as https from 'https'; +import { httpsRequest } from '../utils/httpsRequest'; + +const applyCustomAttributesOnSpanErrorMessage = + 'bad applyCustomAttributesOnSpan function'; + +let server: https.Server; +const serverPort = 32345; +const protocol = 'https'; +const hostname = 'localhost'; +const serverName = 'my.server.name'; +const pathname = '/test'; +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], +}); +instrumentation.setTracerProvider(provider); +const tracer = provider.getTracer('test-https'); + +function doNock( + hostname: string, + path: string, + httpCode: number, + respBody: string, + times?: number +) { + const i = times || 1; + nock(`${protocol}://${hostname}`) + .get(path) + .times(i) + .reply(httpCode, respBody); +} + +export const customAttributeFunction = (span: ISpan): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +describe('HttpsInstrumentationDelegate', () => { + let contextManager: ContextManager; + + beforeEach(() => { + contextManager = new AsyncHooksContextManager().enable(); + propagation.setGlobalPropagator(new DummyPropagation()); + context.setGlobalContextManager(contextManager); + }); + + afterEach(() => { + contextManager.disable(); + context.disable(); + propagation.disable(); + }); + + describe('enable()', () => { + describe('with bad instrumentation options', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + instrumentation.setConfig({ + ignoreIncomingRequestHook: _request => { + throw new Error('bad ignoreIncomingRequestHook function'); + }, + ignoreOutgoingRequestHook: _request => { + throw new Error('bad ignoreOutgoingRequestHook function'); + }, + applyCustomAttributesOnSpan: () => { + throw new Error(applyCustomAttributesOnSpanErrorMessage); + }, + }); + instrumentation.enable(); + server = https.createServer( + { + key: fs.readFileSync('test/fixtures/server-key.pem'), + cert: fs.readFileSync('test/fixtures/server-cert.pem'), + }, + (request, response) => { + response.end('Test Server Response'); + } + ); + + server.listen(serverPort); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should generate valid spans (client side and server side)', async () => { + const result = await httpsRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'user-agent': 'tester', + }, + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: result.method!, + pathname, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assertSpan(incomingSpan, SpanKind.SERVER, validations); + assertSpan(outgoingSpan, SpanKind.CLIENT, validations); + assert.strictEqual( + incomingSpan.attributes[ATTR_NET_HOST_PORT], + serverPort + ); + assert.strictEqual( + outgoingSpan.attributes[ATTR_NET_PEER_PORT], + serverPort + ); + }); + }); + + describe('with good instrumentation options', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + instrumentation.setConfig({ + ignoreIncomingRequestHook: request => { + return ( + request.headers['user-agent']?.match('ignored-string') != null + ); + }, + ignoreOutgoingRequestHook: request => { + if (request.headers?.['user-agent'] != null) { + return ( + `${request.headers['user-agent']}`.match('ignored-string') != + null + ); + } + return false; + }, + applyCustomAttributesOnSpan: customAttributeFunction, + serverName, + }); + instrumentation.enable(); + server = https.createServer( + { + key: fs.readFileSync('test/fixtures/server-key.pem'), + cert: fs.readFileSync('test/fixtures/server-cert.pem'), + }, + (request, response) => { + if (request.url?.includes('/ignored')) { + tracer.startSpan('some-span').end(); + } + response.end('Test Server Response'); + } + ); + + server.listen(serverPort); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it(`${protocol} module should be patched`, () => { + assert.strictEqual(isWrapped(https.Server.prototype.emit), true); + }); + + it('should generate valid spans (client side and server side)', async () => { + const result = await httpsRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'x-forwarded-for': ', , ', + 'user-agent': 'chrome', + }, + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: result.method!, + pathname, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + serverName, + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual( + incomingSpan.attributes[ATTR_HTTP_CLIENT_IP], + '' + ); + assert.strictEqual( + incomingSpan.attributes[ATTR_NET_HOST_PORT], + serverPort + ); + assert.strictEqual( + outgoingSpan.attributes[ATTR_NET_PEER_PORT], + serverPort + ); + + [ + { span: incomingSpan, kind: SpanKind.SERVER }, + { span: outgoingSpan, kind: SpanKind.CLIENT }, + ].forEach(({ span, kind }) => { + assert.strictEqual(span.attributes[ATTR_HTTP_FLAVOR], '1.1'); + assert.strictEqual( + span.attributes[ATTR_NET_TRANSPORT], + NET_TRANSPORT_VALUE_IP_TCP + ); + assertSpan(span, kind, validations); + }); + }); + + const httpErrorCodes = [400, 401, 403, 404, 429, 501, 503, 504, 500, 505]; + + for (let i = 0; i < httpErrorCodes.length; i++) { + it(`should test span for GET requests with http error ${httpErrorCodes[i]}`, async () => { + const testPath = '/outgoing/rootSpan/1'; + + doNock( + hostname, + testPath, + httpErrorCodes[i], + httpErrorCodes[i].toString() + ); + + const isReset = memoryExporter.getFinishedSpans().length === 0; + assert.ok(isReset); + + const result = await httpsRequest.get( + `${protocol}://${hostname}${testPath}` + ); + const spans = memoryExporter.getFinishedSpans(); + const reqSpan = spans[0]; + + assert.strictEqual(result.data, httpErrorCodes[i].toString()); + assert.strictEqual(spans.length, 1); + + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assertSpan(reqSpan, SpanKind.CLIENT, validations); + }); + } + + it('should create a child span for GET requests', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 200, 'Ok'); + const name = 'TestRootSpan'; + const span = tracer.startSpan(name); + return context.with(trace.setSpan(context.active(), span), async () => { + const result = await httpsRequest.get( + `${protocol}://${hostname}${testPath}` + ); + span.end(); + const spans = memoryExporter.getFinishedSpans(); + const [reqSpan, localSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.ok(localSpan.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(spans.length, 2); + assert.strictEqual(reqSpan.name, 'GET'); + assert.strictEqual( + localSpan.spanContext().traceId, + reqSpan.spanContext().traceId + ); + assertSpan(reqSpan, SpanKind.CLIENT, validations); + assert.notStrictEqual( + localSpan.spanContext().spanId, + reqSpan.spanContext().spanId + ); + }); + }); + + for (let i = 0; i < httpErrorCodes.length; i++) { + it(`should test child spans for GET requests with http error ${httpErrorCodes[i]}`, async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock( + hostname, + testPath, + httpErrorCodes[i], + httpErrorCodes[i].toString() + ); + const name = 'TestRootSpan'; + const span = tracer.startSpan(name); + return context.with( + trace.setSpan(context.active(), span), + async () => { + const result = await httpsRequest.get( + `${protocol}://${hostname}${testPath}` + ); + span.end(); + const spans = memoryExporter.getFinishedSpans(); + const [reqSpan, localSpan] = spans; + const validations = { + hostname, + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: testPath, + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.ok(localSpan.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(spans.length, 2); + assert.strictEqual(reqSpan.name, 'GET'); + assert.strictEqual( + localSpan.spanContext().traceId, + reqSpan.spanContext().traceId + ); + assertSpan(reqSpan, SpanKind.CLIENT, validations); + assert.notStrictEqual( + localSpan.spanContext().spanId, + reqSpan.spanContext().spanId + ); + } + ); + }); + } + + it('should create multiple child spans for GET requests', async () => { + const testPath = '/outgoing/rootSpan/childs'; + const num = 5; + doNock(hostname, testPath, 200, 'Ok', num); + const name = 'TestRootSpan'; + const span = tracer.startSpan(name); + await context.with(trace.setSpan(context.active(), span), async () => { + for (let i = 0; i < num; i++) { + await httpsRequest.get(`${protocol}://${hostname}${testPath}`); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans[i].name, 'GET'); + assert.strictEqual( + span.spanContext().traceId, + spans[i].spanContext().traceId + ); + } + span.end(); + const spans = memoryExporter.getFinishedSpans(); + // 5 child spans ended + 1 span (root) + assert.strictEqual(spans.length, 6); + }); + }); + + it('should trace requests when ignore hook returns false', async () => { + const testValue = 'ignored-string'; + + await Promise.all([ + httpsRequest.get(`${protocol}://${hostname}:${serverPort}`, { + headers: { + 'user-agent': testValue, + }, + }), + ]); + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + for (const arg of [{}, new Date()]) { + it(`should be traceable and not throw exception in ${protocol} instrumentation when passing the following argument ${JSON.stringify( + arg + )}`, async () => { + try { + await httpsRequest.get(arg); + } catch (error) { + // request has been made + // nock throw + assert.ok(error.message.startsWith('Nock: No match for request')); + } + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + }); + } + + for (const arg of [true, 1, false, 0, '']) { + it(`should not throw exception in https instrumentation when passing the following argument ${JSON.stringify( + arg + )}`, async () => { + try { + await httpsRequest.get(arg as any); + } catch (error) { + // request has been made + // nock throw + assert.ok( + error.stack.indexOf( + path.normalize('/node_modules/nock/lib/intercept.js') + ) > 0 + ); + } + const spans = memoryExporter.getFinishedSpans(); + // for this arg with don't provide trace. We pass arg to original method (https.get) + assert.strictEqual(spans.length, 0); + }); + } + + it('should have 1 ended span when request throw on bad "options" object', () => { + try { + https.request({ protocol: 'telnet' }); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when response.end throw an exception', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 400, 'Not Ok'); + + const promiseRequest = new Promise((resolve, reject) => { + const req = https.request( + `${protocol}://${hostname}${testPath}`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + reject(new Error(data)); + }); + } + ); + return req.end(); + }); + + try { + await promiseRequest; + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when request throw on bad "options" object', () => { + nock.cleanAll(); + nock.enableNetConnect(); + try { + https.request({ protocol: 'telnet' }); + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 2 ended spans when provided "options" are an object without a constructor', async () => { + // Related issue: https://github.com/open-telemetry/opentelemetry-js/issues/2008 + const testPath = '/outgoing/test'; + const options = Object.create(null); + options.hostname = hostname; + options.port = serverPort; + options.path = pathname; + options.method = 'GET'; + + doNock(hostname, testPath, 200, 'Ok'); + + const promiseRequest = new Promise((resolve, _reject) => { + const req = https.request(options, (resp: http.IncomingMessage) => { + resp.on('data', () => {}); + resp.on('end', () => { + resolve({}); + }); + }); + return req.end(); + }); + + await promiseRequest; + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + }); + + it('should have 1 ended span when response.end throw an exception', async () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(hostname, testPath, 400, 'Not Ok'); + + const promiseRequest = new Promise((resolve, reject) => { + const req = https.request( + `${protocol}://${hostname}${testPath}`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + reject(new Error(data)); + }); + } + ); + return req.end(); + }); + + try { + await promiseRequest; + assert.fail(); + } catch (error) { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + } + }); + + it('should have 1 ended span when request is aborted', async () => { + nock(`${protocol}://my.server.com`) + .get('/') + .delayConnection(50) + .reply(200, ''); + + const promiseRequest = new Promise((resolve, reject) => { + const req = https.request( + `${protocol}://my.server.com`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + resolve(data); + }); + } + ); + req.setTimeout(10, () => { + req.destroy(); + }); + // Instrumentation should not swallow error event. + assert.strictEqual(req.listeners('error').length, 0); + req.on('error', err => { + reject(err); + }); + return req.end(); + }); + + await assert.rejects(promiseRequest, /Error: socket hang up/); + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.ok(Object.keys(span.attributes).length >= 6); + }); + + it('should have 1 ended span when request is aborted after receiving response', async () => { + nock(`${protocol}://my.server.com`) + .get('/') + .delay({ + body: 50, + }) + .replyWithFile(200, `${process.cwd()}/package.json`); + + const promiseRequest = new Promise((resolve, reject) => { + const req = https.request( + `${protocol}://my.server.com`, + (resp: http.IncomingMessage) => { + let data = ''; + resp.on('data', chunk => { + req.destroy(Error('request destroyed')); + data += chunk; + }); + resp.on('end', () => { + resolve(data); + }); + } + ); + // Instrumentation should not swallow error event. + assert.strictEqual(req.listeners('error').length, 0); + req.on('error', err => { + reject(err); + }); + + return req.end(); + }); + + await assert.rejects(promiseRequest, /Error: request destroyed/); + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + assert.ok(Object.keys(span.attributes).length > 7); + }); + + it("should have 1 ended span when response is listened by using req.on('response')", done => { + const host = `${protocol}://${hostname}`; + nock(host).get('/').reply(404); + const req = https.request(`${host}/`); + req.on('response', response => { + response.on('data', () => {}); + response.on('end', () => { + const spans = memoryExporter.getFinishedSpans(); + const [span] = spans; + assert.strictEqual(spans.length, 1); + assert.ok(Object.keys(span.attributes).length > 6); + assert.strictEqual(span.attributes[ATTR_HTTP_STATUS_CODE], 404); + assert.strictEqual(span.status.code, SpanStatusCode.ERROR); + done(); + }); + }); + req.end(); + }); + + it('should keep username and password in the request', async () => { + await httpsRequest.get( + `${protocol}://username:password@${hostname}:${serverPort}/login` + ); + }); + + it('should keep query in the request', async () => { + await httpsRequest.get( + `${protocol}://${hostname}:${serverPort}/withQuery?foo=bar` + ); + }); + + it('using an invalid url does throw from client but still creates a span', async () => { + try { + await httpsRequest.get( + `${protocol}://instrumentation.test:string-as-port/` + ); + } catch (e) { + assert.match(e.message, /Invalid URL/); + } + + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + }); + }); + + describe('partially disable instrumentation', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + afterEach(() => { + server.close(); + instrumentation.disable(); + }); + + it('allows to disable outgoing request instrumentation', () => { + instrumentation.setConfig({ + disableOutgoingRequestInstrumentation: true, + }); + instrumentation.enable(); + server = https.createServer( + { + key: fs.readFileSync('test/fixtures/server-key.pem'), + cert: fs.readFileSync('test/fixtures/server-cert.pem'), + }, + (request, response) => { + response.end('Test Server Response'); + } + ); + + server.listen(serverPort); + + assert.strictEqual(isWrapped(http.Server.prototype.emit), true); + assert.strictEqual(isWrapped(http.get), false); + assert.strictEqual(isWrapped(http.request), false); + }); + + it('allows to disable incoming request instrumentation', () => { + instrumentation.setConfig({ + disableIncomingRequestInstrumentation: true, + }); + instrumentation.enable(); + server = https.createServer( + { + key: fs.readFileSync('test/fixtures/server-key.pem'), + cert: fs.readFileSync('test/fixtures/server-cert.pem'), + }, + (request, response) => { + response.end('Test Server Response'); + } + ); + + server.listen(serverPort); + + assert.strictEqual(isWrapped(http.Server.prototype.emit), false); + assert.strictEqual(isWrapped(http.get), true); + assert.strictEqual(isWrapped(http.request), true); + }); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-package.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-package.test.ts new file mode 100644 index 00000000000..a9a1ff67314 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/https-delegate-package.test.ts @@ -0,0 +1,127 @@ +/* + * 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. + */ + +import { context, SpanKind, propagation, Span } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import * as assert from 'assert'; +import * as path from 'path'; +import { createHttpInstrumentation } from '../../src'; +import { assertSpan } from '../utils/assertSpan'; +import { DummyPropagation } from '../utils/DummyPropagation'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import * as superagent from 'superagent'; +import * as nock from 'nock'; +import * as axios from 'axios'; + +const memoryExporter = new InMemorySpanExporter(); +const customAttributeFunction = (span: Span): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +describe('HttpsInstrumentationDelegate - Packages', () => { + beforeEach(() => { + memoryExporter.reset(); + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + }); + + afterEach(() => { + context.disable(); + }); + describe('get', () => { + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], + }); + instrumentation.setTracerProvider(provider); + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + propagation.setGlobalPropagator(new DummyPropagation()); + instrumentation.setConfig({ + applyCustomAttributesOnSpan: customAttributeFunction, + }); + instrumentation.enable(); + }); + + after(() => { + // back to normal + nock.cleanAll(); + nock.enableNetConnect(); + propagation.disable(); + }); + + let resHeaders: http.IncomingHttpHeaders; + [ + { name: 'axios', httpPackage: axios }, //keep first + { name: 'superagent', httpPackage: superagent }, + ].forEach(({ name, httpPackage }) => { + it(`should create a span for GET requests and add propagation headers by using ${name} package`, async () => { + nock.load(path.join(__dirname, '../', '/fixtures/google-https.json')); + + const urlparsed = new URL( + 'https://www.google.com/search?q=axios&oq=axios&aqs=chrome.0.69i59l2j0l3j69i60.811j0j7&sourceid=chrome&ie=UTF-8' + ); + const result = await httpPackage.get(urlparsed.href); + if (!resHeaders) { + const res = result as axios.AxiosResponse; + resHeaders = res.headers as any; + } + const spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + const validations = { + hostname: urlparsed.hostname, + httpStatusCode: 200, + httpMethod: 'GET', + pathname: urlparsed.pathname, + path: urlparsed.pathname + urlparsed.search, + resHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 1); + assert.strictEqual(span.name, 'GET'); + + switch (name) { + case 'axios': + assert.ok( + result.request.getHeader(DummyPropagation.TRACE_CONTEXT_KEY) + ); + assert.ok( + result.request.getHeader(DummyPropagation.SPAN_CONTEXT_KEY) + ); + break; + case 'superagent': + break; + default: + break; + } + assert.strictEqual(span.attributes['span kind'], SpanKind.CLIENT); + assertSpan(span, SpanKind.CLIENT, validations); + }); + }); + }); +}); From 862988af9c47644f6b53245d7589f30bd52980d2 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 16:33:32 +0100 Subject: [PATCH 4/7] chore: add more tests --- .../test/integrations/delegate-esm.test.mjs | 281 ++++++++++++ .../integrations/http-delegate-enable.test.ts | 414 ++++++++++++++++++ .../https-delegate-enable.test.ts | 356 +++++++++++++++ 3 files changed, 1051 insertions(+) create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/integrations/delegate-esm.test.mjs create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/integrations/http-delegate-enable.test.ts create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/integrations/https-delegate-enable.test.ts diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/integrations/delegate-esm.test.mjs b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/delegate-esm.test.mjs new file mode 100644 index 00000000000..a72cae087da --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/delegate-esm.test.mjs @@ -0,0 +1,281 @@ +/* + * 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. + */ + +// Test that instrumentation-http works when used from ESM code. + +import * as assert from 'assert'; +import * as fs from 'fs'; + +import httpA from 'http'; // ESM import style A +import * as httpB from 'http'; // ESM import style B +import { + createServer as httpCreateServerC, + request as httpRequestC, + get as httpGetC, +} from 'http'; // ESM import style C + +import httpsA from 'https'; // ESM import style A +import * as httpsB from 'https'; // ESM import style B +import { + createServer as httpsCreateServerC, + request as httpsRequestC, + get as httpsGetC, +} from 'https'; // ESM import style C + +import { SpanKind } from '@opentelemetry/api'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; + +import { assertSpan } from '../../build/test/utils/assertSpan.js'; +import { createHttpInstrumentation } from '../../build/src/index.js'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], +}); +const instrumentation = createHttpInstrumentation(); +instrumentation.setTracerProvider(provider); + +const httpImports = [ + { + style: 'import http from "http"', + createServer: httpA.createServer, + request: httpA.request, + get: httpA.get, + }, + { + style: 'import * as http from "http"', + createServer: httpB.createServer, + request: httpB.request, + get: httpB.get, + }, + { + style: 'import {...} from "http"', + createServer: httpCreateServerC, + request: httpRequestC, + get: httpGetC, + }, +]; + +for (let httpImport of httpImports) { + describe(`HttpInstrumentationDelegate ESM Integration tests (${httpImport.style})`, () => { + let port; + let server; + + before(done => { + server = httpImport.createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200); + res.end('pong'); + }); + }); + + server.listen(0, '127.0.0.1', () => { + port = server.address().port; + assert.ok(Number.isInteger(port)); + done(); + }); + }); + + after(done => { + server.close(done); + }); + + beforeEach(() => { + memoryExporter.reset(); + }); + + it('should instrument http requests using http.request', async () => { + const spanValidations = { + httpStatusCode: 200, + httpMethod: 'GET', + hostname: '127.0.0.1', + pathname: '/http.request', + component: 'http', + }; + + await new Promise(resolve => { + const clientReq = httpImport.request( + `http://127.0.0.1:${port}/http.request`, + clientRes => { + spanValidations.resHeaders = clientRes.headers; + clientRes.resume(); + clientRes.on('end', resolve); + } + ); + clientReq.end(); + }); + + let spans = memoryExporter.getFinishedSpans(); + // console.log(spans) + assert.strictEqual(spans.length, 2); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, spanValidations); + }); + + it('should instrument http requests using http.get', async () => { + const spanValidations = { + httpStatusCode: 200, + httpMethod: 'GET', + hostname: '127.0.0.1', + pathname: '/http.get', + component: 'http', + }; + + await new Promise(resolve => { + httpImport.get(`http://127.0.0.1:${port}/http.get`, clientRes => { + spanValidations.resHeaders = clientRes.headers; + clientRes.resume(); + clientRes.on('end', resolve); + }); + }); + + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, spanValidations); + }); + }); +} + +const httpsImports = [ + { + style: 'import https from "https"', + createServer: httpsA.createServer, + request: httpsA.request, + get: httpsA.get, + }, + { + style: 'import * as https from "https"', + createServer: httpsB.createServer, + request: httpsB.request, + get: httpsB.get, + }, + { + style: 'import {...} from "https"', + createServer: httpsCreateServerC, + request: httpsRequestC, + get: httpsGetC, + }, +]; + +for (let httpsImport of httpsImports) { + describe.skip(`HttpsInstrumentationDelegate ESM Integration tests (${httpsImport.style})`, () => { + let port; + let server; + + before(done => { + server = httpsImport.createServer( + { + key: fs.readFileSync( + new URL('../fixtures/server-key.pem', import.meta.url) + ), + cert: fs.readFileSync( + new URL('../fixtures/server-cert.pem', import.meta.url) + ), + }, + (req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200); + res.end('pong'); + }); + } + ); + + server.listen(0, '127.0.0.1', () => { + port = server.address().port; + assert.ok(Number.isInteger(port)); + done(); + }); + }); + + after(done => { + server.close(done); + }); + + beforeEach(() => { + memoryExporter.reset(); + }); + + it('should instrument https requests using https.request', async () => { + const spanValidations = { + httpStatusCode: 200, + httpMethod: 'GET', + hostname: '127.0.0.1', + pathname: '/https.request', + component: 'https', + }; + + await new Promise(resolve => { + const clientReq = httpsImport.request( + `https://127.0.0.1:${port}/https.request`, + { + rejectUnauthorized: false, + }, + clientRes => { + spanValidations.resHeaders = clientRes.headers; + clientRes.resume(); + clientRes.on('end', resolve); + } + ); + clientReq.end(); + }); + + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, spanValidations); + }); + + it('should instrument http requests using https.get', async () => { + const spanValidations = { + httpStatusCode: 200, + httpMethod: 'GET', + hostname: '127.0.0.1', + pathname: '/https.get', + component: 'https', + }; + + await new Promise(resolve => { + httpsImport.get( + `https://127.0.0.1:${port}/https.get`, + { + rejectUnauthorized: false, + }, + clientRes => { + spanValidations.resHeaders = clientRes.headers; + clientRes.resume(); + clientRes.on('end', resolve); + } + ); + }); + + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, spanValidations); + }); + }); +} diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/integrations/http-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/http-delegate-enable.test.ts new file mode 100644 index 00000000000..8606db6fc41 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/http-delegate-enable.test.ts @@ -0,0 +1,414 @@ +/* + * 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. + */ + +import { SpanKind, Span, context, propagation } from '@opentelemetry/api'; +import { + ATTR_HTTP_FLAVOR, + ATTR_HTTP_HOST, + ATTR_NET_TRANSPORT, + HTTP_FLAVOR_VALUE_HTTP_1_1, + NET_TRANSPORT_VALUE_IP_TCP, +} from '../../src/semconv'; +import * as assert from 'assert'; +import { urlToHttpOptions } from 'url'; +import { createHttpInstrumentation } from '../../src'; +import { assertSpan } from '../utils/assertSpan'; +import * as utils from '../utils/utils'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import { httpRequest } from '../utils/httpRequest'; +import { DummyPropagation } from '../utils/DummyPropagation'; +import { Socket } from 'net'; +import { sendRequestTwice } from '../utils/rawRequest'; + +const protocol = 'http'; +const hostname = 'localhost'; +const memoryExporter = new InMemorySpanExporter(); + +const customAttributeFunction = (span: Span): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +describe('HttpInstrumentationDelegate Integration tests', () => { + let mockServerPort = 0; + let mockServer: http.Server; + const sockets: Array = []; + before(done => { + mockServer = http.createServer((req, res) => { + if (req.url === '/timeout') { + setTimeout(() => { + res.end(); + }, 1000); + } + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.write( + JSON.stringify({ + success: true, + }) + ); + res.end(); + }); + + mockServer.listen(0, () => { + const addr = mockServer.address(); + if (addr == null) { + done(new Error('unexpected addr null')); + return; + } + + if (typeof addr === 'string') { + done(new Error(`unexpected addr ${addr}`)); + return; + } + + if (addr.port <= 0) { + done(new Error('Could not get port')); + return; + } + mockServerPort = addr.port; + done(); + }); + }); + + after(done => { + sockets.forEach(s => s.destroy()); + mockServer.close(done); + }); + + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + propagation.setGlobalPropagator(new DummyPropagation()); + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + }); + + after(() => { + context.disable(); + propagation.disable(); + }); + describe('enable()', () => { + before(function (done) { + // mandatory + if (process.env.CI) { + done(); + return; + } + + utils.checkInternet(isConnected => { + if (!isConnected) { + this.skip(); + // don't disturb people + } + done(); + }); + }); + + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], + }); + instrumentation.setTracerProvider(provider); + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + instrumentation.setConfig({ + applyCustomAttributesOnSpan: customAttributeFunction, + }); + instrumentation.enable(); + }); + + after(() => { + instrumentation.disable(); + }); + + it('should create a rootSpan for GET requests and add propagation headers', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpRequest.get( + `${protocol}://localhost:${mockServerPort}/?query=test` + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should create a rootSpan for GET requests and add propagation headers if URL is used', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`) + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should create a valid rootSpan with propagation headers for GET requests if URL and options are used', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`), + { + headers: { 'x-foo': 'foo' }, + } + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.strictEqual(result.reqHeaders['x-foo'], 'foo'); + assert.strictEqual( + span.attributes[ATTR_HTTP_FLAVOR], + HTTP_FLAVOR_VALUE_HTTP_1_1 + ); + assert.strictEqual( + span.attributes[ATTR_NET_TRANSPORT], + NET_TRANSPORT_VALUE_IP_TCP + ); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('custom attributes should show up on client spans', async () => { + const result = await httpRequest.get( + `${protocol}://localhost:${mockServerPort}/` + ); + const spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.strictEqual(span.attributes['span kind'], SpanKind.CLIENT); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should not mutate given headers object when adding propagation headers', async () => { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const headers = { 'x-foo': 'foo' }; + const result = await httpRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`), + { headers } + ); + assert.deepStrictEqual(headers, { 'x-foo': 'foo' }); + assert.ok(result.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY]); + assert.ok(result.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY]); + }); + + it('should succeed even with malformed Forwarded header', async () => { + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const headers = { 'x-foo': 'foo', forwarded: 'malformed' }; + const result = await httpRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`), + { headers } + ); + + assert.ok(result.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY]); + assert.ok(result.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY]); + }); + + it('should create a span for GET requests and add propagation headers with Expect headers', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + const options = Object.assign( + { headers: { Expect: '100-continue' } }, + urlToHttpOptions(new URL(`${protocol}://localhost:${mockServerPort}/`)) + ); + + const result = await httpRequest.get(options); + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: 200, + httpMethod: 'GET', + pathname: '/', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'http', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + for (const headers of [ + { Expect: '100-continue', 'user-agent': 'http-plugin-test' }, + { 'user-agent': 'http-plugin-test' }, + ]) { + it(`should create a span for GET requests and add propagation when using the following signature: get(url, options, callback) and following headers: ${JSON.stringify( + headers + )}`, done => { + let validations: { + hostname: string; + httpStatusCode: number; + httpMethod: string; + pathname: string; + reqHeaders: http.OutgoingHttpHeaders; + resHeaders: http.IncomingHttpHeaders; + }; + let data = ''; + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + const options = { headers }; + const req = http.get( + `${protocol}://localhost:${mockServerPort}/`, + options, + (resp: http.IncomingMessage) => { + const res = resp as unknown as http.IncomingMessage & { + req: http.IncomingMessage; + }; + + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + validations = { + hostname: 'localhost', + httpStatusCode: 301, + httpMethod: 'GET', + pathname: '/', + resHeaders: resp.headers, + /* tslint:disable:no-any */ + reqHeaders: (res.req as any).getHeaders + ? (res.req as any).getHeaders() + : (res.req as any)._headers, + /* tslint:enable:no-any */ + }; + }); + } + ); + + req.on('close', () => { + const spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.ok(data); + assert.ok(validations.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY]); + assert.ok(validations.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY]); + done(); + }); + }); + } + + it('should work for multiple active requests in keep-alive mode', async () => { + await sendRequestTwice(hostname, mockServerPort); + const spans = memoryExporter.getFinishedSpans(); + const span = spans.find((s: any) => s.kind === SpanKind.SERVER); + assert.ok(span); + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + }); + + it('should have correct spans even when request timeout', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + try { + await httpRequest.get( + `${protocol}://localhost:${mockServerPort}/timeout`, + { timeout: 1 } + ); + } catch (err) { + assert.ok(err.message.startsWith('timeout')); + } + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + assert.strictEqual(span.name, 'GET'); + assert.strictEqual( + span.attributes[ATTR_HTTP_HOST], + `localhost:${mockServerPort}` + ); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/integrations/https-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/https-delegate-enable.test.ts new file mode 100644 index 00000000000..b4543d5f63f --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/integrations/https-delegate-enable.test.ts @@ -0,0 +1,356 @@ +/* + * 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. + */ + +import { SpanKind, Span, context, propagation } from '@opentelemetry/api'; +import { + HTTP_FLAVOR_VALUE_HTTP_1_1, + NET_TRANSPORT_VALUE_IP_TCP, + ATTR_HTTP_FLAVOR, + ATTR_NET_TRANSPORT, +} from '../../src/semconv'; +import * as assert from 'assert'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Socket } from 'net'; +import { assertSpan } from '../utils/assertSpan'; +import { urlToHttpOptions } from 'url'; +import * as utils from '../utils/utils'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { createHttpInstrumentation } from '../../src'; + +const instrumentation = createHttpInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import * as https from 'https'; +import { httpsRequest } from '../utils/httpsRequest'; +import { DummyPropagation } from '../utils/DummyPropagation'; + +const protocol = 'https'; +const memoryExporter = new InMemorySpanExporter(); + +export const customAttributeFunction = (span: Span): void => { + span.setAttribute('span kind', SpanKind.CLIENT); +}; + +describe('HttpsInstrumentationDelegate Integration tests', () => { + let mockServerPort = 0; + let mockServer: https.Server; + const sockets: Array = []; + before(done => { + mockServer = https.createServer( + { + key: fs.readFileSync( + path.join(__dirname, '..', 'fixtures', 'server-key.pem') + ), + cert: fs.readFileSync( + path.join(__dirname, '..', 'fixtures', 'server-cert.pem') + ), + }, + (req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.write( + JSON.stringify({ + success: true, + }) + ); + res.end(); + } + ); + + mockServer.listen(0, () => { + const addr = mockServer.address(); + if (addr == null) { + done(new Error('unexpected addr null')); + return; + } + + if (typeof addr === 'string') { + done(new Error(`unexpected addr ${addr}`)); + return; + } + + if (addr.port <= 0) { + done(new Error('Could not get port')); + return; + } + mockServerPort = addr.port; + done(); + }); + }); + + after(done => { + sockets.forEach(s => s.destroy()); + mockServer.close(done); + }); + + beforeEach(() => { + memoryExporter.reset(); + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + }); + + afterEach(() => { + context.disable(); + }); + + describe('enable()', () => { + before(function (done) { + // mandatory + if (process.env.CI) { + done(); + return; + } + + utils.checkInternet(isConnected => { + if (!isConnected) { + this.skip(); + // don't disturb people + } + done(); + }); + }); + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], + }); + instrumentation.setTracerProvider(provider); + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + propagation.setGlobalPropagator(new DummyPropagation()); + instrumentation.setConfig({ + applyCustomAttributesOnSpan: customAttributeFunction, + }); + instrumentation.enable(); + }); + + after(() => { + instrumentation.disable(); + propagation.disable(); + }); + + it('should create a rootSpan for GET requests and add propagation headers', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpsRequest.get( + `${protocol}://localhost:${mockServerPort}/?query=test` + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should create a rootSpan for GET requests and add propagation headers if URL is used', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpsRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`) + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should create a valid rootSpan with propagation headers for GET requests if URL and options are used', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const result = await httpsRequest.get( + new URL(`${protocol}://localhost:${mockServerPort}/?query=test`), + { + headers: { 'x-foo': 'foo' }, + } + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + path: '/?query=test', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.strictEqual(result.reqHeaders['x-foo'], 'foo'); + assert.strictEqual( + span.attributes[ATTR_HTTP_FLAVOR], + HTTP_FLAVOR_VALUE_HTTP_1_1 + ); + assert.strictEqual( + span.attributes[ATTR_NET_TRANSPORT], + NET_TRANSPORT_VALUE_IP_TCP + ); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('custom attributes should show up on client spans', async () => { + const result = await httpsRequest.get( + `${protocol}://localhost:${mockServerPort}/` + ); + const spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: result.statusCode!, + httpMethod: 'GET', + pathname: '/', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.strictEqual(span.attributes['span kind'], SpanKind.CLIENT); + assertSpan(span, SpanKind.CLIENT, validations); + }); + + it('should create a span for GET requests and add propagation headers with Expect headers', async () => { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + const options = Object.assign( + { headers: { Expect: '100-continue' } }, + urlToHttpOptions(new URL(`${protocol}://localhost:${mockServerPort}/`)) + ); + + const result = await httpsRequest.get(options); + spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + const validations = { + hostname: 'localhost', + httpStatusCode: 200, + httpMethod: 'GET', + pathname: '/', + resHeaders: result.resHeaders, + reqHeaders: result.reqHeaders, + component: 'https', + }; + + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assertSpan(span, SpanKind.CLIENT, validations); + }); + for (const headers of [ + { Expect: '100-continue', 'user-agent': 'http-plugin-test' }, + { 'user-agent': 'http-plugin-test' }, + ]) { + it(`should create a span for GET requests and add propagation when using the following signature: get(url, options, callback) and following headers: ${JSON.stringify( + headers + )}`, done => { + let validations: { + hostname: string; + httpStatusCode: number; + httpMethod: string; + pathname: string; + reqHeaders: http.OutgoingHttpHeaders; + resHeaders: http.IncomingHttpHeaders; + }; + let data = ''; + const spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + const options = { headers }; + const req = https.get( + `${protocol}://localhost:${mockServerPort}/`, + options, + (resp: http.IncomingMessage) => { + const res = resp as unknown as http.IncomingMessage & { + req: http.IncomingMessage; + }; + + resp.on('data', chunk => { + data += chunk; + }); + resp.on('end', () => { + validations = { + hostname: 'localhost', + httpStatusCode: 301, + httpMethod: 'GET', + pathname: '/', + resHeaders: resp.headers, + /* tslint:disable:no-any */ + reqHeaders: (res.req as any).getHeaders + ? (res.req as any).getHeaders() + : (res.req as any)._headers, + /* tslint:enable:no-any */ + }; + }); + } + ); + + req.on('close', () => { + const spans = memoryExporter.getFinishedSpans(); + const span = spans.find(s => s.kind === SpanKind.CLIENT); + assert.ok(span); + assert.strictEqual(spans.length, 2); + assert.strictEqual(span.name, 'GET'); + assert.ok(data); + assert.ok(validations.reqHeaders[DummyPropagation.TRACE_CONTEXT_KEY]); + assert.ok(validations.reqHeaders[DummyPropagation.SPAN_CONTEXT_KEY]); + done(); + }); + }); + } + }); +}); From e27a99cda37521bf35e96d70e2110d38c22a5bcc Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 17:33:41 +0100 Subject: [PATCH 5/7] chore: exec ESM tests file by file --- .../packages/opentelemetry-instrumentation-http/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/packages/opentelemetry-instrumentation-http/package.json b/experimental/packages/opentelemetry-instrumentation-http/package.json index 685fc45f330..e5c9d268545 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/package.json +++ b/experimental/packages/opentelemetry-instrumentation-http/package.json @@ -10,7 +10,8 @@ "compile": "tsc --build", "clean": "tsc --build --clean", "test:cjs": "nyc mocha test/**/*.test.ts", - "test:esm": "nyc node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/**/*.test.mjs'", + "test:esm": "find . -name '*.test.mjs' | xargs -I {} npm run test:esm:file -- {}", + "test:esm:file": "nyc node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha", "test": "npm run test:cjs && npm run test:esm", "tdd": "npm run test -- --watch-extensions ts --watch", "lint": "eslint . --ext .ts", From 67d5307a30d5ace175ea7729be5f55ca09ac05f1 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 17:52:24 +0100 Subject: [PATCH 6/7] chore: update types --- .../src/http-delegate.ts | 7 ------- .../functionals/http-delegate-enable.test.ts | 5 ++++- .../functionals/http-delegate-metrics.test.ts | 6 ++++-- .../src/platform/node/create-instrumentation.ts | 16 ++++++++++------ .../opentelemetry-instrumentation/src/types.ts | 16 +++++++++++----- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts index dace9fcccd6..3a9721b5335 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/http-delegate.ts @@ -91,7 +91,6 @@ import { InstrumentationDelegate, Shimmer, } from '@opentelemetry/instrumentation/src/types'; -import { Logger } from '@opentelemetry/api-logs'; type HeaderCapture = { client: { @@ -112,8 +111,6 @@ class HttpInstrumentationDelegate private _config!: HttpInstrumentationConfig; private _diag!: DiagLogger; private _tracer!: Tracer; - // @ts-expect-error - unused for now - private _logger!: Logger; /** keep track on spans not ended */ private readonly _spanNotEnded: WeakSet = new WeakSet(); @@ -139,10 +136,6 @@ class HttpInstrumentationDelegate this._tracer = tracer; } - setLogger(logger: Logger): void { - this._logger = logger; - } - setMeter(meter: Meter) { this._oldHttpServerDurationHistogram = meter.createHistogram( 'http.server.duration', diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts index 9f44fd452f5..afe6e75e187 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-enable.test.ts @@ -67,7 +67,10 @@ import { import * as assert from 'assert'; import * as nock from 'nock'; import * as path from 'path'; -import { createHttpInstrumentation, HttpInstrumentationConfig } from '../../src'; +import { + createHttpInstrumentation, + HttpInstrumentationConfig, +} from '../../src'; import { assertSpan } from '../utils/assertSpan'; import { DummyPropagation } from '../utils/DummyPropagation'; import { httpRequest } from '../utils/httpRequest'; diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts index bb30cf5c50c..b9ba85f761d 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-delegate-metrics.test.ts @@ -187,7 +187,8 @@ describe('HttpInstrumentationDelegate - metrics', () => { describe('with semconv stability set to stable', () => { before(() => { // @ts-expect-error - accesing internal property not available in the type - instrumentation['_delegate']['_semconvStability'] = SemconvStability.STABLE; + instrumentation['_delegate']['_semconvStability'] = + SemconvStability.STABLE; }); it('should add server/client duration metrics', async () => { @@ -292,7 +293,8 @@ describe('HttpInstrumentationDelegate - metrics', () => { describe('with semconv stability set to duplicate', () => { before(() => { // @ts-expect-error - accesing internal property not available in the type - instrumentation['_delegate']['_semconvStability'] = SemconvStability.DUPLICATE; + instrumentation['_delegate']['_semconvStability'] = + SemconvStability.DUPLICATE; }); it('should add server/client duration metrics', async () => { diff --git a/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts b/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts index 30b5160113a..199a442a21c 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/platform/node/create-instrumentation.ts @@ -68,9 +68,9 @@ export function createInstrumentation( const diagLogger = diag.createComponentLogger({ namespace: delegate.name }); delegate.setConfig(delegateConfig); delegate.setDiag(diagLogger); - delegate.setTracer(trace.getTracer(delegate.name, delegate.version)); - delegate.setMeter(metrics.getMeter(delegate.name, delegate.version)); - delegate.setLogger(logs.getLogger(delegate.name, delegate.version)); + delegate.setTracer?.(trace.getTracer(delegate.name, delegate.version)); + delegate.setMeter?.(metrics.getMeter(delegate.name, delegate.version)); + delegate.setLogger?.(logs.getLogger(delegate.name, delegate.version)); // Keep the diagLogger set(delegate, _kOtDiag, diagLogger); @@ -105,13 +105,17 @@ export function createInstrumentation( delegate.disable?.(); }, setTracerProvider(traceProv) { - delegate.setTracer(traceProv.getTracer(delegate.name, delegate.version)); + delegate.setTracer?.( + traceProv.getTracer(delegate.name, delegate.version) + ); }, setMeterProvider(meterProv) { - delegate.setMeter(meterProv.getMeter(delegate.name, delegate.version)); + delegate.setMeter?.(meterProv.getMeter(delegate.name, delegate.version)); }, setLoggerProvider(loggerProv) { - delegate.setLogger(loggerProv.getLogger(delegate.name, delegate.version)); + delegate.setLogger?.( + loggerProv.getLogger(delegate.name, delegate.version) + ); }, } as Instrumentation; } diff --git a/experimental/packages/opentelemetry-instrumentation/src/types.ts b/experimental/packages/opentelemetry-instrumentation/src/types.ts index 50e2259570d..450cfc4c5b5 100644 --- a/experimental/packages/opentelemetry-instrumentation/src/types.ts +++ b/experimental/packages/opentelemetry-instrumentation/src/types.ts @@ -62,10 +62,10 @@ export interface InstrumentationDelegate< ConfigType extends InstrumentationConfig = InstrumentationConfig, > { /** Instrumentation Name */ - name: string; + readonly name: string; /** Instrumentation Version */ - version: string; + readonly version: string; /** Method to set instrumentation config */ setConfig(config: ConfigType): void; @@ -73,10 +73,16 @@ export interface InstrumentationDelegate< /** Method to get instrumentation config */ getConfig(): ConfigType; // TODO: is it necessary? + /** method to set the internal logger */ setDiag(diag: DiagLogger): void; - setTracer(tracer: Tracer): void; - setMeter(meter: Meter): void; // this should handle update instruments - setLogger(logger: Logger): void; + + // TODO: should be optional? sometimes a instrumentation does not use a tracer, meter, logger + /** method to set the tracer that will be used by the instrumentation */ + setTracer?: (tracer: Tracer) => void; + /** method to set the meter that will be used by the instrumentation (updateInstruments!!!) */ + setMeter?: (meter: Meter) => void; + /** method to set the logger that will be used by the instrumentation */ + setLogger?: (logger: Logger) => void; /** Method to initialize instrumentation config */ init(shimmer: Shimmer): InstrumentationModuleDefinition[] | undefined; From 851ec2b6d31da8f2b1df5fa06cbfbe8f99adeed9 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 25 Nov 2025 17:55:58 +0100 Subject: [PATCH 7/7] chore: update changelog --- experimental/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index fbe412e4f8d..5a1f826aa9c 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -20,6 +20,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 * refactor(configuration): dont have a default value for node resource detectors [#6131](https://github.com/open-telemetry/opentelemetry-js/pull/6131) @maryliag * feat(configuration): doesnt set meter,tracer,logger provider by default [#6130](https://github.com/open-telemetry/opentelemetry-js/pull/6130) @maryliag * feat(opentelemetry-sdk-node): set instrumentation and propagators for experimental start [#6148](https://github.com/open-telemetry/opentelemetry-js/pull/6148) @maryliag +* feat(opentelemetry-instrumentation): add `createInstrumentation` factory function [#6163](https://github.com/open-telemetry/opentelemetry-js/pull/6163) @david-luna + ### :bug: Bug Fixes