diff --git a/packages/node/package.json b/packages/node/package.json index fcfc3b2a5137..441b64797d9a 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -76,7 +76,6 @@ "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", @@ -86,7 +85,6 @@ "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis/index.ts similarity index 91% rename from packages/node/src/integrations/tracing/redis.ts rename to packages/node/src/integrations/tracing/redis/index.ts index f8be12352ae0..654b19a2759e 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis/index.ts @@ -1,7 +1,4 @@ import type { Span } from '@opentelemetry/api'; -import type { RedisResponseCustomAttributeFunction } from '@opentelemetry/instrumentation-ioredis'; -import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; -import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, @@ -21,7 +18,10 @@ import { getCacheOperation, isInCommands, shouldConsiderForCache, -} from '../../utils/redisCache'; +} from '../../../utils/redisCache'; +import type { IORedisInstrumentationConfig } from './vendored/types'; +import { IORedisInstrumentation } from './vendored/ioredis-instrumentation'; +import { RedisInstrumentation } from './vendored/redis-instrumentation'; interface RedisOptions { /** @@ -46,11 +46,11 @@ const INTEGRATION_NAME = 'Redis'; export let _redisOptions: RedisOptions = {}; /* Only exported for testing purposes */ -export const cacheResponseHook: RedisResponseCustomAttributeFunction = ( +export const cacheResponseHook: IORedisInstrumentationConfig['responseHook'] = ( span: Span, - redisCommand, - cmdArgs, - response, + redisCommand: string, + cmdArgs: any[], + response: unknown, ) => { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); diff --git a/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts new file mode 100644 index 000000000000..3b2c975f34df --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/ioredis-instrumentation.ts @@ -0,0 +1,252 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-ioredis-v0.62.0/packages/instrumentation-ioredis + * - Upstream version: @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-ioredis */ + +import { context, diag, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { ATTR_DB_QUERY_TEXT, ATTR_DB_SYSTEM_NAME, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT } from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { IORedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-ioredis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- utils ---- + +function endSpan(span: Span, err: Error | null | undefined): void { + if (err) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +// ---- IORedisInstrumentation ---- + +const DEFAULT_CONFIG: IORedisInstrumentationConfig = { + requireParentSpan: true, +}; + +export class IORedisInstrumentation extends InstrumentationBase { + _netSemconvStability!: SemconvStability; + _dbSemconvStability!: SemconvStability; + + constructor(config: IORedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); + this._setSemconvStabilityFromEnv(); + } + + _setSemconvStabilityFromEnv(): void { + this._netSemconvStability = semconvStabilityFromStr('http', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + this._dbSemconvStability = semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: IORedisInstrumentationConfig = {}): void { + super.setConfig({ ...DEFAULT_CONFIG, ...config }); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'ioredis', + ['>=2.0.0 <6'], + (module: any, moduleVersion?: string) => { + const moduleExports = module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + if (isWrapped(moduleExports.prototype.sendCommand)) { + this._unwrap(moduleExports.prototype, 'sendCommand'); + } + this._wrap(moduleExports.prototype, 'sendCommand', this._patchSendCommand(moduleVersion)); + if (isWrapped(moduleExports.prototype.connect)) { + this._unwrap(moduleExports.prototype, 'connect'); + } + this._wrap(moduleExports.prototype, 'connect', this._patchConnection()); + return module; + }, + (module: any) => { + if (module === undefined) return; + const moduleExports = module[Symbol.toStringTag] === 'Module' + ? module.default // ESM + : module; // CommonJS + this._unwrap(moduleExports.prototype, 'sendCommand'); + this._unwrap(moduleExports.prototype, 'connect'); + }, + ), + ]; + } + + private _patchSendCommand(moduleVersion?: string) { + return (original: Function) => { + return this._traceSendCommand(original, moduleVersion); + }; + } + + private _patchConnection() { + return (original: Function) => { + return this._traceConnection(original); + }; + } + + private _traceSendCommand(original: Function, moduleVersion?: string) { + const instrumentation = this; + return function (this: any, cmd: any) { + if (arguments.length < 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const dbStatementSerializer = config.dbStatementSerializer || defaultDbStatementSerializer; + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + const dbQueryText = dbStatementSerializer(cmd.name, cmd.args); + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = dbQueryText; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = dbQueryText; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + const span = instrumentation.tracer.startSpan(cmd.name, { + kind: SpanKind.CLIENT, + attributes, + }); + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => + requestHook(span, { + moduleVersion, + cmdName: cmd.name, + cmdArgs: cmd.args, + }), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: request hook failed', e); + } + }, + true, + ); + } + try { + const result = original.apply(this, arguments); + const origResolve = cmd.resolve; + cmd.resolve = function (result: unknown) { + safeExecuteInTheMiddle( + () => config.responseHook?.(span, cmd.name, cmd.args, result), + (e: Error | undefined) => { + if (e) { + diag.error('ioredis instrumentation: response hook failed', e); + } + }, + true, + ); + endSpan(span, null); + origResolve(result); + }; + const origReject = cmd.reject; + cmd.reject = function (err: Error) { + endSpan(span, err); + origReject(err); + }; + return result; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } + + private _traceConnection(original: Function) { + const instrumentation = this; + return function (this: any) { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (instrumentation.getConfig().requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const attributes: Record = {}; + const { host, port } = this.options; + if (instrumentation._dbSemconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_SYSTEM] = DB_SYSTEM_VALUE_REDIS; + attributes[ATTR_DB_STATEMENT] = 'connect'; + attributes[ATTR_DB_CONNECTION_STRING] = `redis://${host}:${port}`; + } + if (instrumentation._dbSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_REDIS; + attributes[ATTR_DB_QUERY_TEXT] = 'connect'; + } + if (instrumentation._netSemconvStability & SemconvStability.OLD) { + attributes[ATTR_NET_PEER_NAME] = host; + attributes[ATTR_NET_PEER_PORT] = port; + } + if (instrumentation._netSemconvStability & SemconvStability.STABLE) { + attributes[ATTR_SERVER_ADDRESS] = host; + attributes[ATTR_SERVER_PORT] = port; + } + const span = instrumentation.tracer.startSpan('connect', { + kind: SpanKind.CLIENT, + attributes, + }); + try { + const client = original.apply(this, arguments); + endSpan(span, null); + return client; + } catch (error) { + endSpan(span, error as Error); + throw error; + } + }; + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts new file mode 100644 index 000000000000..58612eb740ed --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-common.ts @@ -0,0 +1,66 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common + * - Upstream version: @opentelemetry/redis-common@0.38.2 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/redis-common */ + +/** + * List of regexes and the number of arguments that should be serialized for matching commands. + * For example, HSET should serialize which key and field it's operating on, but not its value. + * Setting the subset to -1 will serialize all arguments. + * Commands without a match will have their first argument serialized. + * + * Refer to https://redis.io/commands/ for the full list. + */ +const serializationSubsets = [ + { + regex: /^ECHO/i, + args: 0, + }, + { + regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i, + args: 1, + }, + { + regex: /^(HSET|HMSET|LSET|LINSERT)/i, + args: 2, + }, + { + regex: + /^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i, + args: -1, + }, +]; + +/** + * Given the redis command name and arguments, return a combination of the + * command name + the allowed arguments according to `serializationSubsets`. + */ +export const defaultDbStatementSerializer = (cmdName: string, cmdArgs: Array): string => { + if (Array.isArray(cmdArgs) && cmdArgs.length) { + const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0; + const argsToSerialize: Array = + nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice(); + if (cmdArgs.length > argsToSerialize.length) { + argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`); + } + return `${cmdName} ${argsToSerialize.join(' ')}`; + } + return cmdName; +}; diff --git a/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts new file mode 100644 index 000000000000..355a483f713e --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/redis-instrumentation.ts @@ -0,0 +1,723 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { DiagLogger, Span, TracerProvider } from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, + SemconvStability, + semconvStabilityFromStr, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_OPERATION_NAME, + ATTR_DB_QUERY_TEXT, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; + +import { defaultDbStatementSerializer } from './redis-common'; +import { + ATTR_DB_CONNECTION_STRING, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + DB_SYSTEM_NAME_VALUE_REDIS, + DB_SYSTEM_VALUE_REDIS, +} from './semconv'; +import type { RedisInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@opentelemetry/instrumentation-redis'; +const PACKAGE_VERSION = '0.62.0'; + +// ---- Internal types ---- + +interface RedisPluginClientTypes { + connection_options?: { + port?: string | number; + host?: string; + }; + address?: string; +} + +interface RedisCommand { + command: string; + args: string[]; + buffer_args: boolean; + callback: (err: Error | null, reply: unknown) => void; + call_on_write: boolean; +} + +interface MultiErrorReply extends Error { + replies: unknown[]; + errorIndexes: Array; +} + +interface OpenSpanInfo { + span: Span; + commandName: string; + commandArgs: Array; +} + +const OTEL_OPEN_SPANS = Symbol('opentelemetry.instrumentation.redis.open_spans'); +const MULTI_COMMAND_OPTIONS = Symbol('opentelemetry.instrumentation.redis.multi_command_options'); + +// ---- v4-v5 utils ---- + +function removeCredentialsFromDBConnectionStringAttribute(diagLogger: DiagLogger, url: string | undefined): string | undefined { + if (typeof url !== 'string' || !url) { + return undefined; + } + try { + const u = new URL(url); + u.searchParams.delete('user_pwd'); + u.username = ''; + u.password = ''; + return u.href; + } catch (err) { + diagLogger.error('failed to sanitize redis connection url', err); + } + return undefined; +} + +function getClientAttributes(diagLogger: DiagLogger, options: any, semconvStability: SemconvStability): Record { + const attributes: Record = {}; + if (semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_NET_PEER_NAME]: options?.socket?.host, + [ATTR_NET_PEER_PORT]: options?.socket?.port, + [ATTR_DB_CONNECTION_STRING]: removeCredentialsFromDBConnectionStringAttribute(diagLogger, options?.url), + }); + } + if (semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_SERVER_ADDRESS]: options?.socket?.host, + [ATTR_SERVER_PORT]: options?.socket?.port, + }); + } + return attributes; +} + +// ---- v2-v3 utils ---- + +function endSpanV2(span: Span, err: Error | null | undefined): void { + if (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + } + span.end(); +} + +function getTracedCreateClient(original: Function): Function { + return function createClientTrace(this: any) { + const client = original.apply(this, arguments); + return context.bind(context.active(), client); + }; +} + +function getTracedCreateStreamTrace(original: Function): Function { + return function create_stream_trace(this: any) { + if (!Object.prototype.hasOwnProperty.call(this, 'stream')) { + Object.defineProperty(this, 'stream', { + get() { + return this._patched_redis_stream; + }, + set(val: any) { + context.bind(context.active(), val); + this._patched_redis_stream = val; + }, + }); + } + return original.apply(this, arguments); + }; +} + +// ---- RedisInstrumentationV2_V3 ---- + +class RedisInstrumentationV2_V3 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'redis', + ['>=2.6.0 <4'], + (moduleExports: any) => { + if (isWrapped(moduleExports.RedisClient.prototype['internal_send_command'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + } + this._wrap( + moduleExports.RedisClient.prototype, + 'internal_send_command', + this._getPatchInternalSendCommand(), + ); + if (isWrapped(moduleExports.RedisClient.prototype['create_stream'])) { + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + } + this._wrap(moduleExports.RedisClient.prototype, 'create_stream', this._getPatchCreateStream()); + if (isWrapped(moduleExports.createClient)) { + this._unwrap(moduleExports, 'createClient'); + } + this._wrap(moduleExports, 'createClient', this._getPatchCreateClient()); + return moduleExports; + }, + (moduleExports: any) => { + if (moduleExports === undefined) return; + this._unwrap(moduleExports.RedisClient.prototype, 'internal_send_command'); + this._unwrap(moduleExports.RedisClient.prototype, 'create_stream'); + this._unwrap(moduleExports, 'createClient'); + }, + ), + ]; + } + + private _getPatchInternalSendCommand() { + const instrumentation = this; + return function internal_send_command(original: Function) { + return function internal_send_command_trace(this: RedisPluginClientTypes, cmd: RedisCommand) { + if (arguments.length !== 1 || typeof cmd !== 'object') { + return original.apply(this, arguments); + } + const config = instrumentation.getConfig(); + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (config.requireParentSpan === true && hasNoParentSpan) { + return original.apply(this, arguments); + } + const dbStatementSerializer = config?.dbStatementSerializer || defaultDbStatementSerializer; + const attributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_REDIS, + [ATTR_DB_STATEMENT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(attributes, { + [ATTR_DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + [ATTR_DB_OPERATION_NAME]: cmd.command, + [ATTR_DB_QUERY_TEXT]: dbStatementSerializer(cmd.command, cmd.args), + }); + } + const span = instrumentation.tracer.startSpan(`${RedisInstrumentationV2_V3.COMPONENT}-${cmd.command}`, { + kind: SpanKind.CLIENT, + attributes, + }); + if (this.connection_options) { + const connectionAttributes: Record = {}; + if (instrumentation._semconvStability & SemconvStability.OLD) { + Object.assign(connectionAttributes, { + [ATTR_NET_PEER_NAME]: this.connection_options.host, + [ATTR_NET_PEER_PORT]: this.connection_options.port, + }); + } + if (instrumentation._semconvStability & SemconvStability.STABLE) { + Object.assign(connectionAttributes, { + [ATTR_SERVER_ADDRESS]: this.connection_options.host, + [ATTR_SERVER_PORT]: this.connection_options.port, + }); + } + span.setAttributes(connectionAttributes); + } + if (this.address && instrumentation._semconvStability & SemconvStability.OLD) { + span.setAttribute(ATTR_DB_CONNECTION_STRING, `redis://${this.address}`); + } + const originalCallback = arguments[0].callback; + if (originalCallback) { + const originalContext = context.active(); + arguments[0].callback = function callback(this: any, err: Error | null, reply: unknown) { + if (config?.responseHook) { + const responseHook = config.responseHook; + safeExecuteInTheMiddle( + () => { + responseHook(span, cmd.command, cmd.args, reply); + }, + (e: Error | undefined) => { + if (e) { + instrumentation._diag.error('Error executing responseHook', e); + } + }, + true, + ); + } + endSpanV2(span, err); + return context.with(originalContext, originalCallback, this, ...arguments); + }; + } + try { + return original.apply(this, arguments); + } catch (rethrow) { + endSpanV2(span, rethrow as Error); + throw rethrow; + } + }; + }; + } + + private _getPatchCreateClient() { + return function createClient(original: Function) { + return getTracedCreateClient(original); + }; + } + + private _getPatchCreateStream() { + return function createReadStream(original: Function) { + return getTracedCreateStreamTrace(original); + }; + } +} + +// ---- RedisInstrumentationV4_V5 ---- + +class RedisInstrumentationV4_V5 extends InstrumentationBase { + static COMPONENT = 'redis'; + _semconvStability: SemconvStability; + + constructor(config: RedisInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + super.setConfig(config); + this._semconvStability = config.semconvStability + ? config.semconvStability + : semconvStabilityFromStr('database', process.env['OTEL_SEMCONV_STABILITY_OPT_IN']); + } + + init() { + return [ + this._getInstrumentationNodeModuleDefinition('@redis/client'), + this._getInstrumentationNodeModuleDefinition('@node-redis/client'), + ]; + } + + private _getInstrumentationNodeModuleDefinition(basePackageName: string) { + const commanderModuleFile = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/commander.js`, + ['^1.0.0'], + (moduleExports: any, moduleVersion?: string) => { + const transformCommandArguments = moduleExports.transformCommandArguments; + if (!transformCommandArguments) { + this._diag.error('internal instrumentation error, missing transformCommandArguments function'); + return moduleExports; + } + const functionToPatch = moduleVersion?.startsWith('1.0.') ? 'extendWithCommands' : 'attachCommands'; + if (isWrapped(moduleExports?.[functionToPatch])) { + this._unwrap(moduleExports, functionToPatch); + } + this._wrap(moduleExports, functionToPatch, this._getPatchExtendWithCommands(transformCommandArguments)); + return moduleExports; + }, + (moduleExports: any) => { + if (isWrapped(moduleExports?.extendWithCommands)) { + this._unwrap(moduleExports, 'extendWithCommands'); + } + if (isWrapped(moduleExports?.attachCommands)) { + this._unwrap(moduleExports, 'attachCommands'); + } + }, + ); + + const multiCommanderModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/multi-command.js`, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + this._wrap(redisClientMultiCommandPrototype, 'exec', this._getPatchMultiCommandsExec(false)); + if (isWrapped(redisClientMultiCommandPrototype?.execAsPipeline)) { + this._unwrap(redisClientMultiCommandPrototype, 'execAsPipeline'); + } + this._wrap(redisClientMultiCommandPrototype, 'execAsPipeline', this._getPatchMultiCommandsExec(true)); + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + this._wrap(redisClientMultiCommandPrototype, 'addCommand', this._getPatchMultiCommandsAddCommand()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientMultiCommandPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientMultiCommandPrototype?.exec)) { + this._unwrap(redisClientMultiCommandPrototype, 'exec'); + } + if (isWrapped(redisClientMultiCommandPrototype?.addCommand)) { + this._unwrap(redisClientMultiCommandPrototype, 'addCommand'); + } + }, + ); + + const clientIndexModule = new InstrumentationNodeModuleFile( + `${basePackageName}/dist/lib/client/index.js`, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (redisClientPrototype?.multi) { + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + this._wrap(redisClientPrototype, 'multi', this._getPatchRedisClientMulti()); + } + if (redisClientPrototype?.MULTI) { + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + this._wrap(redisClientPrototype, 'MULTI', this._getPatchRedisClientMulti()); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + this._wrap(redisClientPrototype, 'sendCommand', this._getPatchRedisClientSendCommand()); + this._wrap(redisClientPrototype, 'connect', this._getPatchedClientConnect()); + return moduleExports; + }, + (moduleExports: any) => { + const redisClientPrototype = moduleExports?.default?.prototype; + if (isWrapped(redisClientPrototype?.multi)) { + this._unwrap(redisClientPrototype, 'multi'); + } + if (isWrapped(redisClientPrototype?.MULTI)) { + this._unwrap(redisClientPrototype, 'MULTI'); + } + if (isWrapped(redisClientPrototype?.sendCommand)) { + this._unwrap(redisClientPrototype, 'sendCommand'); + } + }, + ); + + return new InstrumentationNodeModuleDefinition( + basePackageName, + ['^1.0.0', '^5.0.0'], + (moduleExports: any) => moduleExports, + () => {}, + [commanderModuleFile, multiCommanderModule, clientIndexModule], + ); + } + + private _getPatchExtendWithCommands(transformCommandArguments: Function) { + const plugin = this; + return function extendWithCommandsPatchWrapper(original: Function) { + return function extendWithCommandsPatch(this: any, config: any) { + if (config?.BaseClass?.name !== 'RedisClient') { + return original.apply(this, arguments); + } + const origExecutor = config.executor; + config.executor = function (this: any, command: any, args: any) { + const redisCommandArguments = transformCommandArguments(command, args).args; + return plugin._traceClientCommand(origExecutor, this, arguments, redisCommandArguments); + }; + return original.apply(this, arguments); + }; + }; + } + + private _getPatchMultiCommandsExec(isPipeline: boolean) { + const plugin = this; + return function execPatchWrapper(original: Function) { + return function execPatch(this: any) { + const execRes = original.apply(this, arguments); + if (typeof execRes?.then !== 'function') { + plugin._diag.error('non-promise result when patching exec/execAsPipeline'); + return execRes; + } + return execRes + .then((redisRes: unknown[]) => { + const openSpans: OpenSpanInfo[] = (this as any)[OTEL_OPEN_SPANS]; + plugin._endSpansWithRedisReplies(openSpans, redisRes, isPipeline); + return redisRes; + }) + .catch((err: any) => { + const openSpans: OpenSpanInfo[] = (this as any)[OTEL_OPEN_SPANS]; + if (!openSpans) { + plugin._diag.error('cannot find open spans to end for multi/pipeline'); + } else { + const replies = + err.constructor.name === 'MultiErrorReply' + ? (err as MultiErrorReply).replies + : new Array(openSpans.length).fill(err); + plugin._endSpansWithRedisReplies(openSpans, replies, isPipeline); + } + return Promise.reject(err); + }); + }; + }; + } + + private _getPatchMultiCommandsAddCommand() { + const plugin = this; + return function addCommandWrapper(original: Function) { + return function addCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchRedisClientMulti() { + return function multiPatchWrapper(original: Function) { + return function multiPatch(this: any) { + const multiRes: any = original.apply(this, arguments); + multiRes[MULTI_COMMAND_OPTIONS] = this.options; + return multiRes; + }; + }; + } + + private _getPatchRedisClientSendCommand() { + const plugin = this; + return function sendCommandWrapper(original: Function) { + return function sendCommandPatch(this: any, args: any) { + return plugin._traceClientCommand(original, this, arguments, args); + }; + }; + } + + private _getPatchedClientConnect() { + const plugin = this; + return function connectWrapper(original: Function) { + return function patchedConnect(this: any) { + const options = this.options; + const attributes = getClientAttributes(plugin._diag, options, plugin._semconvStability); + const span = plugin.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-connect`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return original.apply(this); + }); + return res + .then((result: any) => { + span.end(); + return result; + }) + .catch((error: Error) => { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + return Promise.reject(error); + }); + }; + }; + } + + _traceClientCommand( + origFunction: Function, + origThis: any, + origArguments: IArguments, + redisCommandArguments: Array, + ): any { + const hasNoParentSpan = trace.getSpan(context.active()) === undefined; + if (hasNoParentSpan && this.getConfig().requireParentSpan) { + return origFunction.apply(origThis, origArguments); + } + const clientOptions = origThis.options || origThis[MULTI_COMMAND_OPTIONS]; + const commandName = redisCommandArguments[0] as string; + const commandArgs = redisCommandArguments.slice(1) as Array; + const dbStatementSerializer = this.getConfig().dbStatementSerializer || defaultDbStatementSerializer; + const attributes = getClientAttributes(this._diag, clientOptions, this._semconvStability); + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_OPERATION_NAME] = commandName; + } + try { + const dbStatement = dbStatementSerializer(commandName, commandArgs); + if (dbStatement != null) { + if (this._semconvStability & SemconvStability.OLD) { + attributes[ATTR_DB_STATEMENT] = dbStatement; + } + if (this._semconvStability & SemconvStability.STABLE) { + attributes[ATTR_DB_QUERY_TEXT] = dbStatement; + } + } + } catch (e) { + this._diag.error('dbStatementSerializer throw an exception', e, { commandName }); + } + const span = this.tracer.startSpan(`${RedisInstrumentationV4_V5.COMPONENT}-${commandName}`, { + kind: SpanKind.CLIENT, + attributes, + }); + const res = context.with(trace.setSpan(context.active(), span), () => { + return origFunction.apply(origThis, origArguments); + }); + if (typeof res?.then === 'function') { + res.then( + (redisRes: unknown) => { + this._endSpanWithResponse(span, commandName, commandArgs, redisRes, undefined); + }, + (err: Error) => { + this._endSpanWithResponse(span, commandName, commandArgs, null, err); + }, + ); + } else { + const redisClientMultiCommand: any = res; + redisClientMultiCommand[OTEL_OPEN_SPANS] = redisClientMultiCommand[OTEL_OPEN_SPANS] || []; + redisClientMultiCommand[OTEL_OPEN_SPANS].push({ + span, + commandName, + commandArgs, + }); + } + return res; + } + + _endSpansWithRedisReplies(openSpans: OpenSpanInfo[] | undefined, replies: unknown[], isPipeline = false): void { + if (!openSpans) { + return this._diag.error('cannot find open spans to end for redis multi/pipeline'); + } + if (replies.length !== openSpans.length) { + return this._diag.error('number of multi command spans does not match response from redis'); + } + const allCommands = openSpans.map(s => s.commandName); + const allSameCommand = allCommands.every(cmd => cmd === allCommands[0]); + const operationName = allSameCommand + ? (isPipeline ? 'PIPELINE ' : 'MULTI ') + allCommands[0] + : isPipeline + ? 'PIPELINE' + : 'MULTI'; + for (let i = 0; i < openSpans.length; i++) { + const { span, commandArgs } = openSpans[i]!; + const currCommandRes = replies[i]; + const [res, err] = + currCommandRes instanceof Error ? [null, currCommandRes] : [currCommandRes, undefined]; + if (this._semconvStability & SemconvStability.STABLE) { + span.setAttribute(ATTR_DB_OPERATION_NAME, operationName); + } + this._endSpanWithResponse(span, allCommands[i]!, commandArgs, res, err); + } + } + + _endSpanWithResponse( + span: Span, + commandName: string, + commandArgs: Array, + response: unknown, + error: Error | null | undefined, + ): void { + const { responseHook } = this.getConfig(); + if (!error && responseHook) { + try { + responseHook(span, commandName, commandArgs, response); + } catch (err) { + this._diag.error('responseHook throw an exception', err); + } + } + if (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); + } + span.end(); + } +} + +// ---- RedisInstrumentation (wrapper) ---- + +const DEFAULT_CONFIG: RedisInstrumentationConfig = { + requireParentSpan: false, +}; + +export class RedisInstrumentation extends InstrumentationBase { + private instrumentationV2_V3: RedisInstrumentationV2_V3; + private instrumentationV4_V5: RedisInstrumentationV4_V5; + private initialized = false; + + constructor(config: RedisInstrumentationConfig = {}) { + const resolvedConfig = { ...DEFAULT_CONFIG, ...config }; + super(PACKAGE_NAME, PACKAGE_VERSION, resolvedConfig); + this.instrumentationV2_V3 = new RedisInstrumentationV2_V3(this.getConfig()); + this.instrumentationV4_V5 = new RedisInstrumentationV4_V5(this.getConfig()); + this.initialized = true; + } + + override setConfig(config: RedisInstrumentationConfig = {}): void { + const newConfig = { ...DEFAULT_CONFIG, ...config }; + super.setConfig(newConfig); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setConfig(newConfig); + this.instrumentationV4_V5.setConfig(newConfig); + } + + init() {} + + override getModuleDefinitions() { + return [ + ...this.instrumentationV2_V3.getModuleDefinitions(), + ...this.instrumentationV4_V5.getModuleDefinitions(), + ]; + } + + override setTracerProvider(tracerProvider: TracerProvider): void { + super.setTracerProvider(tracerProvider); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.setTracerProvider(tracerProvider); + this.instrumentationV4_V5.setTracerProvider(tracerProvider); + } + + override enable(): void { + super.enable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.enable(); + this.instrumentationV4_V5.enable(); + } + + override disable(): void { + super.disable(); + if (!this.initialized) { + return; + } + this.instrumentationV2_V3.disable(); + this.instrumentationV4_V5.disable(); + } +} diff --git a/packages/node/src/integrations/tracing/redis/vendored/semconv.ts b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts new file mode 100644 index 000000000000..ab26f76282d9 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/semconv.ts @@ -0,0 +1,36 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by the vendored redis/ioredis instrumentations. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +// Deprecated constants kept for backwards compatibility with older semconv +export const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +export const ATTR_DB_STATEMENT = 'db.statement'; +export const ATTR_DB_SYSTEM = 'db.system'; +export const ATTR_NET_PEER_NAME = 'net.peer.name'; +export const ATTR_NET_PEER_PORT = 'net.peer.port'; +export const DB_SYSTEM_NAME_VALUE_REDIS = 'redis'; +export const DB_SYSTEM_VALUE_REDIS = 'redis'; diff --git a/packages/node/src/integrations/tracing/redis/vendored/types.ts b/packages/node/src/integrations/tracing/redis/vendored/types.ts new file mode 100644 index 000000000000..24b3817857d5 --- /dev/null +++ b/packages/node/src/integrations/tracing/redis/vendored/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/instrumentation-redis + * - Upstream version: @opentelemetry/instrumentation-redis@0.62.0 and @opentelemetry/instrumentation-ioredis@0.62.0 + * - Minor TypeScript adjustments for this repository's compiler settings + */ +/* eslint-disable -- vendored @opentelemetry/instrumentation-redis */ + +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig, SemconvStability } from '@opentelemetry/instrumentation'; + +// ---- redis types ---- + +/** + * Function that can be used to serialize db.statement tag + * @param cmdName - The name of the command (eg. set, get, mset) + * @param cmdArgs - Array of arguments passed to the command + * @returns serialized string that will be used as the db.statement attribute. + */ +export type DbStatementSerializer = (cmdName: string, cmdArgs: Array) => string; + +/** + * Function that can be used to add custom attributes to span on response from redis server + */ +export interface RedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: Array, response: unknown): void; +} + +export interface RedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: DbStatementSerializer; + /** Function for adding custom attributes on db response */ + responseHook?: RedisResponseCustomAttributeFunction; + /** Require parent to create redis span, default when unset is false */ + requireParentSpan?: boolean; + /** + * Controls which semantic-convention attributes are emitted on spans. + * Default: 'OLD'. + */ + semconvStability?: SemconvStability; +} + +// ---- ioredis types ---- + +export type CommandArgs = Array; + +/** + * Function that can be used to serialize db.statement tag for ioredis + */ +export type IORedisDbStatementSerializer = (cmdName: string, cmdArgs: CommandArgs) => string; + +export interface IORedisRequestHookInformation { + moduleVersion?: string; + cmdName: string; + cmdArgs: CommandArgs; +} + +export interface RedisRequestCustomAttributeFunction { + (span: Span, requestInfo: IORedisRequestHookInformation): void; +} + +/** + * Function that can be used to add custom attributes to span on response from redis server (ioredis) + */ +export interface IORedisResponseCustomAttributeFunction { + (span: Span, cmdName: string, cmdArgs: CommandArgs, response: unknown): void; +} + +export interface IORedisInstrumentationConfig extends InstrumentationConfig { + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: IORedisDbStatementSerializer; + /** Function for adding custom attributes on db request */ + requestHook?: RedisRequestCustomAttributeFunction; + /** Function for adding custom attributes on db response */ + responseHook?: IORedisResponseCustomAttributeFunction; + /** Require parent to create ioredis span, default when unset is true */ + requireParentSpan?: boolean; +} diff --git a/packages/node/src/utils/redisCache.ts b/packages/node/src/utils/redisCache.ts index 476a257fbc6d..2d833f2aa727 100644 --- a/packages/node/src/utils/redisCache.ts +++ b/packages/node/src/utils/redisCache.ts @@ -1,4 +1,4 @@ -import type { CommandArgs as IORedisCommandArgs } from '@opentelemetry/instrumentation-ioredis'; +type IORedisCommandArgs = Array; const SINGLE_ARG_COMMANDS = ['get', 'set', 'setex']; diff --git a/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts new file mode 100644 index 000000000000..b90e1c00130e --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/ioredis-instrumentation.test.ts @@ -0,0 +1,155 @@ +/* + * Tests ported from @opentelemetry/instrumentation-ioredis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis + * Licensed under the Apache License, Version 2.0 + */ + +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IORedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/ioredis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('IORedisInstrumentation', () => { + let instrumentation: IORedisInstrumentation; + + beforeEach(() => { + instrumentation = new IORedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config (requireParentSpan = true)', () => { + const inst = new IORedisInstrumentation(); + expect(inst).toBeInstanceOf(IORedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should create an instance with custom config', () => { + const inst = new IORedisInstrumentation({ requireParentSpan: false }); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('setConfig', () => { + it('should preserve default requireParentSpan = true when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(true); + }); + + it('should allow overriding requireParentSpan', () => { + instrumentation.setConfig({ requireParentSpan: false }); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + }); + + describe('init', () => { + it('should return module definitions for ioredis', () => { + const defs = instrumentation.init(); + expect(Array.isArray(defs)).toBe(true); + expect(defs).toHaveLength(1); + expect(defs[0]!.name).toBe('ioredis'); + }); + + it('should support ioredis versions >=2.0.0 <6', () => { + const defs = instrumentation.init(); + const supportedVersions = defs[0]!.supportedVersions; + expect(supportedVersions).toContain('>=2.0.0 <6'); + }); + }); + + describe('_patchSendCommand', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue(Promise.resolve('OK')); + + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { + options: { host: 'localhost', port: 6379 }, + }; + const fakeCmd = { + name: 'get', + args: ['mykey'], + resolve: vi.fn(), + reject: vi.fn(), + }; + + patched.call(fakeThis, fakeCmd); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when called with less than 1 argument', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + + it('should not trace when cmd is not an object', () => { + const original = vi.fn().mockReturnValue(undefined); + const patchFn = (instrumentation as any)._patchSendCommand(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis, 'not-an-object'); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('_patchConnection', () => { + it('should skip tracing when no parent span and requireParentSpan is true', () => { + instrumentation.setConfig({ requireParentSpan: true }); + const original = vi.fn().mockReturnValue({ connected: true }); + + const patchFn = (instrumentation as any)._patchConnection(); + const patched = patchFn(original); + + const fakeThis = { options: { host: 'localhost', port: 6379 } }; + + patched.call(fakeThis); + + expect(original).toHaveBeenCalled(); + expect(memoryExporter.getFinishedSpans()).toHaveLength(0); + }); + }); + + describe('semconv stability', () => { + it('should initialize semconv stability from env', () => { + const inst = new IORedisInstrumentation(); + expect((inst as any)._netSemconvStability).toBeDefined(); + expect((inst as any)._dbSemconvStability).toBeDefined(); + }); + + it('should allow resetting semconv stability', () => { + const inst = new IORedisInstrumentation(); + const originalNet = (inst as any)._netSemconvStability; + inst._setSemconvStabilityFromEnv(); + expect((inst as any)._netSemconvStability).toBe(originalNet); + }); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/node/test/integrations/tracing/redis/redis-common.test.ts new file mode 100644 index 000000000000..f0adb6400c39 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-common.test.ts @@ -0,0 +1,92 @@ +/* + * Tests ported from @opentelemetry/redis-common@0.38.2 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/redis-common + * Licensed under the Apache License, Version 2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { defaultDbStatementSerializer } from '../../../../src/integrations/tracing/redis/vendored/redis-common'; + +describe('defaultDbStatementSerializer()', () => { + const testCases: Array<{ + cmdName: string; + cmdArgs: Array; + expected: string; + }> = [ + { + cmdName: 'UNKNOWN', + cmdArgs: ['something'], + expected: 'UNKNOWN [1 other arguments]', + }, + { + cmdName: 'ECHO', + cmdArgs: ['echo'], + expected: 'ECHO [1 other arguments]', + }, + { + cmdName: 'LPUSH', + cmdArgs: ['list', 'value'], + expected: 'LPUSH list [1 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['hash', 'field', 'value'], + expected: 'HSET hash field [1 other arguments]', + }, + { + cmdName: 'INCRBY', + cmdArgs: ['key', 5], + expected: 'INCRBY key 5', + }, + { + cmdName: 'GET', + cmdArgs: ['mykey'], + expected: 'GET mykey', + }, + { + cmdName: 'SET', + cmdArgs: ['mykey', 'myvalue'], + expected: 'SET mykey [1 other arguments]', + }, + { + cmdName: 'MSET', + cmdArgs: ['key1', 'val1', 'key2', 'val2'], + expected: 'MSET key1 [3 other arguments]', + }, + { + cmdName: 'HSET', + cmdArgs: ['myhash', 'field1', 'Hello'], + expected: 'HSET myhash field1 [1 other arguments]', + }, + { + cmdName: 'SET', + cmdArgs: [], + expected: 'SET', + }, + { + cmdName: 'DEL', + cmdArgs: ['key1', 'key2'], + expected: 'DEL key1 key2', + }, + { + cmdName: 'ZADD', + cmdArgs: ['myset', '1', 'one', '2', 'two'], + expected: 'ZADD myset [4 other arguments]', + }, + ]; + + for (const { cmdName, cmdArgs, expected } of testCases) { + it(`should serialize the correct number of arguments for ${cmdName}`, () => { + expect(defaultDbStatementSerializer(cmdName, cmdArgs as any)).toBe(expected); + }); + } + + it('should handle empty args array', () => { + expect(defaultDbStatementSerializer('GET', [])).toBe('GET'); + }); + + it('should handle Buffer arguments', () => { + const result = defaultDbStatementSerializer('GET', [Buffer.from('mykey')]); + expect(result).toBe('GET mykey'); + }); +}); diff --git a/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts new file mode 100644 index 000000000000..5e14ceb3fc98 --- /dev/null +++ b/packages/node/test/integrations/tracing/redis/redis-instrumentation.test.ts @@ -0,0 +1,197 @@ +/* + * Tests ported from @opentelemetry/instrumentation-redis@0.62.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-redis + * Licensed under the Apache License, Version 2.0 + */ + +import { SpanStatusCode } from '@opentelemetry/api'; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RedisInstrumentation } from '../../../../src/integrations/tracing/redis/vendored/redis-instrumentation'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memoryExporter)] }); + +describe('RedisInstrumentation', () => { + let instrumentation: RedisInstrumentation; + + beforeEach(() => { + instrumentation = new RedisInstrumentation(); + instrumentation.setTracerProvider(provider); + memoryExporter.reset(); + }); + + afterEach(() => { + instrumentation.disable(); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default config', () => { + const inst = new RedisInstrumentation(); + expect(inst).toBeInstanceOf(RedisInstrumentation); + expect(inst.getConfig().requireParentSpan).toBe(false); + }); + + it('should create an instance with custom config', () => { + const inst = new RedisInstrumentation({ requireParentSpan: true }); + expect(inst.getConfig().requireParentSpan).toBe(true); + }); + + it('should enable and disable without throwing', () => { + const inst = new RedisInstrumentation(); + expect(() => inst.enable()).not.toThrow(); + expect(() => inst.disable()).not.toThrow(); + }); + }); + + describe('setConfig', () => { + it('should keep requireParentSpan default as false when config is empty', () => { + instrumentation.setConfig({}); + expect(instrumentation.getConfig().requireParentSpan).toBe(false); + }); + + it('should propagate config updates', () => { + const responseHook = vi.fn(); + instrumentation.setConfig({ responseHook }); + expect(instrumentation.getConfig().responseHook).toBe(responseHook); + }); + }); + + describe('getModuleDefinitions', () => { + it('should return module definitions from both v2-v3 and v4-v5 instrumentations', () => { + const defs = instrumentation.getModuleDefinitions(); + // v2-v3 instruments 'redis', v4-v5 instruments '@redis/client' and '@node-redis/client' + expect(defs.length).toBeGreaterThanOrEqual(3); + const moduleNames = defs.map((d: any) => d.name); + expect(moduleNames).toContain('redis'); + expect(moduleNames).toContain('@redis/client'); + expect(moduleNames).toContain('@node-redis/client'); + }); + }); + + describe('setTracerProvider', () => { + it('should accept a tracer provider', () => { + expect(() => instrumentation.setTracerProvider(provider)).not.toThrow(); + }); + }); + + describe('_endSpanWithResponse (v4-v5)', () => { + it('should call responseHook when no error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], 'myvalue', undefined); + + expect(responseHook).toHaveBeenCalledWith(span, 'GET', ['mykey'], 'myvalue'); + }); + + it('should not call responseHook when error occurs', () => { + const responseHook = vi.fn(); + const inst = new RedisInstrumentation({ responseHook }); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + expect(responseHook).not.toHaveBeenCalled(); + }); + + it('should set error status on span when error occurs', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + + const span = provider.getTracer('test').startSpan('test-span'); + const v4v5 = (inst as any).instrumentationV4_V5; + const error = new Error('connection failed'); + v4v5._endSpanWithResponse(span, 'GET', ['mykey'], null, error); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + expect(exportedSpans[0]!.status.message).toBe('connection failed'); + }); + }); + + describe('_endSpansWithRedisReplies (v4-v5 multi/pipeline)', () => { + it('should end all spans with their corresponding replies', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + const span2 = tracer.startSpan('redis-GET'); + + const openSpans = [ + { span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }, + { span: span2, commandName: 'GET', commandArgs: ['key1'] }, + ]; + + v4v5._endSpansWithRedisReplies(openSpans, ['OK', 'value1'], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(2); + exportedSpans.forEach(s => { + expect(s.status.code).not.toBe(SpanStatusCode.ERROR); + }); + }); + + it('should handle error replies in multi commands', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-SET'); + + const openSpans = [{ span: span1, commandName: 'SET', commandArgs: ['key1', 'value1'] }]; + const error = new Error('command error'); + + v4v5._endSpansWithRedisReplies(openSpans, [error], false); + + const exportedSpans = memoryExporter.getFinishedSpans(); + expect(exportedSpans).toHaveLength(1); + expect(exportedSpans[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it('should log error when openSpans is undefined', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + v4v5._endSpansWithRedisReplies(undefined, [], false); + + expect(diagSpy).toHaveBeenCalled(); + }); + + it('should log error when replies length does not match open spans', () => { + const inst = new RedisInstrumentation(); + inst.setTracerProvider(provider); + const v4v5 = (inst as any).instrumentationV4_V5; + const diagSpy = vi.spyOn(v4v5._diag, 'error'); + + const tracer = provider.getTracer('test'); + const span1 = tracer.startSpan('redis-GET'); + + v4v5._endSpansWithRedisReplies( + [{ span: span1, commandName: 'GET', commandArgs: ['key'] }], + [], // wrong number of replies + false, + ); + + expect(diagSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index bda61b7744ca..5d6757498ffe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6175,15 +6175,6 @@ "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz#4fd1775577132de5d92165caee6bbc0ae16a8c8a" - integrity sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.33.0" - "@opentelemetry/instrumentation-kafkajs@0.23.0": version "0.23.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz#6b7d449d88d674ddc295a0d0cf2156f0f7d5889f" @@ -6271,15 +6262,6 @@ "@types/pg" "8.15.6" "@types/pg-pool" "2.0.7" -"@opentelemetry/instrumentation-redis@0.62.0": - version "0.62.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz#ecde90337fa49fec8d243bcbb8d470ce1a9ee7a1" - integrity sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ== - dependencies: - "@opentelemetry/instrumentation" "^0.214.0" - "@opentelemetry/redis-common" "^0.38.2" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/instrumentation-tedious@0.33.0": version "0.33.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz#00f6698f8afae1b350bf0c463a59eeae3c8d25d7" @@ -6337,11 +6319,6 @@ "@opentelemetry/sdk-trace-base" "2.6.1" protobufjs "^7.0.0" -"@opentelemetry/redis-common@^0.38.2": - version "0.38.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" - integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== - "@opentelemetry/resources@2.6.1", "@opentelemetry/resources@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.1.tgz#e1b02772c5f65c0e074d59e4743188f7575e97c7"