diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index d8262ba8c28a..1098efdb425a 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -159,7 +159,9 @@ "**/integrations/tracing/fastify/vendored/**/*.ts" ], "rules": { - "typescript/no-explicit-any": "off" + "typescript/no-explicit-any": "off", + "no-unsafe-member-access": "off", + "no-this-alias": "off" } }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs index 9b6d0279e58d..ace21a7a5f76 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs @@ -17,6 +17,10 @@ mysql async _ => { await connection.query('SELECT 1 + 1 AS solution'); await connection.query('SELECT NOW()', ['1', '2']); + // `execute` is instrumented the same way as `query` + await connection.execute('SELECT 42 AS answer'); + // a failing query should produce a span with an error status + await connection.query('SELECT * FROM does_not_exist').catch(() => {}); }, ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts index fef931399723..458b91ccdc52 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts @@ -12,8 +12,10 @@ describe('mysql2 auto instrumentation', () => { expect.objectContaining({ description: 'SELECT 1 + 1 AS solution', op: 'db', + origin: 'auto.db.otel.mysql2', data: expect.objectContaining({ 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', @@ -22,13 +24,36 @@ describe('mysql2 auto instrumentation', () => { expect.objectContaining({ description: 'SELECT NOW()', op: 'db', + origin: 'auto.db.otel.mysql2', data: expect.objectContaining({ 'db.system': 'mysql', + 'db.statement': 'SELECT NOW()', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', }), }), + // `execute` is instrumented the same way as `query` + expect.objectContaining({ + description: 'SELECT 42 AS answer', + op: 'db', + origin: 'auto.db.otel.mysql2', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 42 AS answer', + }), + }), + // a failing query produces a span with an error status + expect.objectContaining({ + description: 'SELECT * FROM does_not_exist', + op: 'db', + status: 'internal_error', + origin: 'auto.db.otel.mysql2', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT * FROM does_not_exist', + }), + }), ]), }; diff --git a/packages/node/src/integrations/tracing/mysql2/index.ts b/packages/node/src/integrations/tracing/mysql2/index.ts index 7210622d268f..f02c6db7b589 100644 --- a/packages/node/src/integrations/tracing/mysql2/index.ts +++ b/packages/node/src/integrations/tracing/mysql2/index.ts @@ -1,19 +1,11 @@ import { MySQL2Instrumentation } from './vendored/instrumentation'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { generateInstrumentOnce } from '@sentry/node-core'; const INTEGRATION_NAME = 'Mysql2'; -export const instrumentMysql2 = generateInstrumentOnce( - INTEGRATION_NAME, - () => - new MySQL2Instrumentation({ - responseHook(span) { - addOriginToSpan(span, 'auto.db.otel.mysql2'); - }, - }), -); +export const instrumentMysql2 = generateInstrumentOnce(INTEGRATION_NAME, () => new MySQL2Instrumentation()); const _mysql2Integration = (() => { return { diff --git a/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts index e1effc2aad8b..7c36d232a176 100644 --- a/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts @@ -18,67 +18,51 @@ * - Upstream version: @opentelemetry/instrumentation-mysql2@0.64.0 * - Types from 'mysql2' inlined as simplified interfaces * - Minor TypeScript strictness adjustments for this repository's compiler settings + * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs */ -/* eslint-disable */ - -import * as api from '@opentelemetry/api'; -import { SDK_VERSION } from '@sentry/core'; -import { - InstrumentationBase, - InstrumentationNodeModuleDefinition, - isWrapped, - safeExecuteInTheMiddle, - SemconvStability, - semconvStabilityFromStr, -} from '@opentelemetry/instrumentation'; + +import { SpanKind } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition, isWrapped } from '@opentelemetry/instrumentation'; +import type { SpanAttributes } from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core'; import { InstrumentationNodeModuleFile } from '../../InstrumentationNodeModuleFile'; -import { DB_SYSTEM_VALUE_MYSQL, ATTR_DB_STATEMENT, ATTR_DB_SYSTEM } from './semconv'; -import { addSqlCommenterComment } from '../../utils/sql-common'; -import type { Connection, Query, QueryOptions, QueryError, FieldPacket, FormatFunction } from './mysql2-types'; -import { MySQL2InstrumentationConfig } from './types'; +import type { Connection, FormatFunction, Query, QueryError, QueryOptions } from './mysql2-types'; +import { ATTR_DB_STATEMENT, ATTR_DB_SYSTEM, DB_SYSTEM_VALUE_MYSQL } from './semconv'; import { getConnectionAttributes, getConnectionPrototypeToInstrument, getQueryText, getSpanName, once } from './utils'; -import { - ATTR_DB_QUERY_TEXT, - ATTR_DB_SYSTEM_NAME, - DB_SYSTEM_NAME_VALUE_MYSQL, -} from '@opentelemetry/semantic-conventions'; const PACKAGE_NAME = '@sentry/instrumentation-mysql2'; +const ORIGIN = 'auto.db.otel.mysql2'; const supportedVersions = ['>=1.4.2 <4']; -export class MySQL2Instrumentation extends InstrumentationBase { - private _netSemconvStability!: SemconvStability; - private _dbSemconvStability!: SemconvStability; +// The raw imported `mysql2` module exposes the `format` helper used to render +// parameterized queries. Typed shallowly since it is only read internally. +type MySQL2Module = { format?: FormatFunction; [key: string]: unknown }; - constructor(config: MySQL2InstrumentationConfig = {}) { +export class MySQL2Instrumentation extends InstrumentationBase { + public constructor(config: InstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); - this._setSemconvStabilityFromEnv(); - } - - private _setSemconvStabilityFromEnv() { - this._netSemconvStability = semconvStabilityFromStr('http', process.env.OTEL_SEMCONV_STABILITY_OPT_IN); - this._dbSemconvStability = semconvStabilityFromStr('database', process.env.OTEL_SEMCONV_STABILITY_OPT_IN); } - protected init() { + protected init(): InstrumentationNodeModuleDefinition[] { let format: FormatFunction | undefined; - function setFormatFunction(moduleExports: any) { + function setFormatFunction(moduleExports: MySQL2Module): void { if (!format && moduleExports.format) { format = moduleExports.format; } } - const patch = (ConnectionPrototype: Connection) => { + const patch = (ConnectionPrototype: Connection): void => { if (isWrapped(ConnectionPrototype.query)) { this._unwrap(ConnectionPrototype, 'query'); } - this._wrap(ConnectionPrototype, 'query', this._patchQuery(format, false) as any); + this._wrap(ConnectionPrototype, 'query', this._patchQuery(format) as any); if (isWrapped(ConnectionPrototype.execute)) { this._unwrap(ConnectionPrototype, 'execute'); } - this._wrap(ConnectionPrototype, 'execute', this._patchQuery(format, true) as any); + this._wrap(ConnectionPrototype, 'execute', this._patchQuery(format) as any); }; - const unpatch = (ConnectionPrototype: Connection) => { + const unpatch = (ConnectionPrototype: Connection): void => { this._unwrap(ConnectionPrototype, 'query'); this._unwrap(ConnectionPrototype, 'execute'); }; @@ -86,7 +70,7 @@ export class MySQL2Instrumentation extends InstrumentationBase { + (moduleExports: MySQL2Module) => { setFormatFunction(moduleExports); return moduleExports; }, @@ -95,7 +79,7 @@ export class MySQL2Instrumentation extends InstrumentationBase { + (moduleExports: MySQL2Module) => { setFormatFunction(moduleExports); return moduleExports; }, @@ -120,9 +104,9 @@ export class MySQL2Instrumentation extends InstrumentationBase { - const thisPlugin = this; return function query( this: Connection, query: string | Query | QueryOptions, @@ -135,61 +119,24 @@ export class MySQL2Instrumentation extends InstrumentationBase { + const endSpan = once((err?: QueryError | null) => { if (err) { - span.setStatus({ - code: api.SpanStatusCode.ERROR, - message: err.message, - }); - } else { - if (typeof responseHook === 'function') { - safeExecuteInTheMiddle( - () => { - responseHook(span, { - queryResults: results, - }); - }, - err => { - if (err) { - thisPlugin._diag.warn('Failed executing responseHook', err); - } - }, - true, - ); - } + span.setStatus({ code: SPAN_STATUS_ERROR, message: err.message }); } - span.end(); }); @@ -204,8 +151,8 @@ export class MySQL2Instrumentation extends InstrumentationBase { endSpan(err); }) - .once('result', (results: any) => { - endSpan(undefined, results); + .once('result', () => { + endSpan(); }); return streamableQuery; @@ -222,11 +169,11 @@ export class MySQL2Instrumentation extends InstrumentationBase void) { return (originalCallback: Function) => { - return function (err: QueryError | null, results?: any, _fields?: FieldPacket[]) { - endSpan(err, results); - return originalCallback(...arguments); + return function (...args: [err: QueryError | null, ...rest: unknown[]]) { + endSpan(args[0]); + return originalCallback(...args); }; }; } diff --git a/packages/node/src/integrations/tracing/mysql2/vendored/semconv.ts b/packages/node/src/integrations/tracing/mysql2/vendored/semconv.ts index 1ac348ca8742..dbdcbd3dd5bd 100644 --- a/packages/node/src/integrations/tracing/mysql2/vendored/semconv.ts +++ b/packages/node/src/integrations/tracing/mysql2/vendored/semconv.ts @@ -17,7 +17,6 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mysql2 * - Upstream version: @opentelemetry/instrumentation-mysql2@0.64.0 */ -/* eslint-disable */ export const ATTR_DB_CONNECTION_STRING = 'db.connection_string' as const; export const ATTR_DB_NAME = 'db.name' as const; diff --git a/packages/node/src/integrations/tracing/mysql2/vendored/types.ts b/packages/node/src/integrations/tracing/mysql2/vendored/types.ts deleted file mode 100644 index d36f9f634b56..000000000000 --- a/packages/node/src/integrations/tracing/mysql2/vendored/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mysql2 - * - Upstream version: @opentelemetry/instrumentation-mysql2@0.64.0 - */ -/* eslint-disable */ - -import { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import type { Span } from '@opentelemetry/api'; - -export interface MySQL2ResponseHookInformation { - queryResults: any; -} - -export interface MySQL2InstrumentationExecutionResponseHook { - (span: Span, responseHookInfo: MySQL2ResponseHookInformation): void; -} - -export interface MySQL2InstrumentationQueryMaskingHook { - (query: string): string; -} - -export interface MySQL2InstrumentationConfig extends InstrumentationConfig { - maskStatement?: boolean; - maskStatementHook?: MySQL2InstrumentationQueryMaskingHook; - responseHook?: MySQL2InstrumentationExecutionResponseHook; - addSqlCommenterCommentToQueries?: boolean; -} diff --git a/packages/node/src/integrations/tracing/mysql2/vendored/utils.ts b/packages/node/src/integrations/tracing/mysql2/vendored/utils.ts index 1d2d4d2c90b1..4e01e9a2e189 100644 --- a/packages/node/src/integrations/tracing/mysql2/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mysql2/vendored/utils.ts @@ -17,10 +17,11 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mysql2 * - Upstream version: @opentelemetry/instrumentation-mysql2@0.64.0 * - Types from 'mysql2' inlined as simplified interfaces + * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs */ -/* eslint-disable */ -import { Attributes } from '@opentelemetry/api'; +import type { SpanAttributes } from '@sentry/core'; +import type { FormatFunction } from './mysql2-types'; import { ATTR_DB_CONNECTION_STRING, ATTR_DB_NAME, @@ -28,10 +29,6 @@ import { ATTR_NET_PEER_NAME, ATTR_NET_PEER_PORT, } from './semconv'; -import type { FormatFunction } from './mysql2-types'; -import { MySQL2InstrumentationQueryMaskingHook } from './types'; -import { SemconvStability } from '@opentelemetry/instrumentation'; -import { ATTR_DB_NAMESPACE, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT } from '@opentelemetry/semantic-conventions'; interface QueryOptions { sql: string; @@ -50,42 +47,26 @@ interface Config { connectionConfig?: Config; } -export function getConnectionAttributes( - config: Config, - dbSemconvStability: SemconvStability, - netSemconvStability: SemconvStability, -): Attributes { +export function getConnectionAttributes(config: Config): SpanAttributes { const { host, port, database, user } = getConfig(config); - const attrs: Attributes = {}; - if (dbSemconvStability & SemconvStability.OLD) { - attrs[ATTR_DB_CONNECTION_STRING] = getJDBCString(host, port, database); - attrs[ATTR_DB_NAME] = database; - attrs[ATTR_DB_USER] = user; - } - if (dbSemconvStability & SemconvStability.STABLE) { - attrs[ATTR_DB_NAMESPACE] = database; - } + const attrs: SpanAttributes = { + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, port, database), + [ATTR_DB_NAME]: database, + [ATTR_DB_USER]: user, + [ATTR_NET_PEER_NAME]: host, + }; const portNumber = parseInt(port, 10); - if (netSemconvStability & SemconvStability.OLD) { - attrs[ATTR_NET_PEER_NAME] = host; - if (!isNaN(portNumber)) { - attrs[ATTR_NET_PEER_PORT] = portNumber; - } - } - if (netSemconvStability & SemconvStability.STABLE) { - attrs[ATTR_SERVER_ADDRESS] = host; - if (!isNaN(portNumber)) { - attrs[ATTR_SERVER_PORT] = portNumber; - } + if (!isNaN(portNumber)) { + attrs[ATTR_NET_PEER_PORT] = portNumber; } return attrs; } function getConfig(config: any) { - const { host, port, database, user } = (config && config.connectionConfig) || config || {}; + const { host, port, database, user } = config?.connectionConfig || config || {}; return { host, port, database, user }; } @@ -103,32 +84,20 @@ function getJDBCString(host: string | undefined, port: number | undefined, datab return jdbcString; } -export function getQueryText( - query: string | Query | QueryOptions, - format?: FormatFunction, - values?: any[], - maskStatement = false, - maskStatementHook: MySQL2InstrumentationQueryMaskingHook = defaultMaskingHook, -): string { +export function getQueryText(query: string | Query | QueryOptions, format?: FormatFunction, values?: any[]): string { const [querySql, queryValues] = typeof query === 'string' ? [query, values] : [query.sql, hasValues(query) ? values || query.values : values]; try { - if (maskStatement) { - return maskStatementHook(querySql); - } else if (format && queryValues) { + if (format && queryValues) { return format(querySql, queryValues); } else { return querySql; } - } catch (e) { - return 'Could not determine the query due to an error in masking or formatting'; + } catch { + return 'Could not determine the query due to an error in formatting'; } } -function defaultMaskingHook(query: string): string { - return query.replace(/\b\d+\b/g, '?').replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, '?'); -} - function hasValues(obj: Query | QueryOptions): obj is QueryOptions { return 'values' in obj; } diff --git a/packages/node/test/integrations/tracing/mysql2.test.ts b/packages/node/test/integrations/tracing/mysql2.test.ts new file mode 100644 index 000000000000..5d3d2bc42f58 --- /dev/null +++ b/packages/node/test/integrations/tracing/mysql2.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { getConnectionPrototypeToInstrument } from '../../../src/integrations/tracing/mysql2/vendored/utils'; + +// The instrumentation patches `query`/`execute` on whichever prototype actually owns them. +// mysql2's layout differs across major versions: older versions define them directly on +// `Connection.prototype`, newer versions inherit them from a base class. This is the only +// version-sensitive logic in the instrumentation, so it's covered here as a pure unit. +// The end-to-end span behavior is covered by the real-package integration suite. +describe('getConnectionPrototypeToInstrument', () => { + it('returns the connection prototype when query/execute live on it directly', () => { + class Connection {} + (Connection.prototype as any).query = (): void => {}; + (Connection.prototype as any).execute = (): void => {}; + + expect(getConnectionPrototypeToInstrument(Connection)).toBe(Connection.prototype); + }); + + it('returns the base prototype when query/execute are inherited from a base class', () => { + class Base {} + (Base.prototype as any).query = (): void => {}; + (Base.prototype as any).execute = (): void => {}; + class Connection extends Base {} + + expect(getConnectionPrototypeToInstrument(Connection)).toBe(Base.prototype); + }); + + it('falls back to the connection prototype when the base lacks query/execute', () => { + class Base {} + (Base.prototype as any).query = (): void => {}; + // base only has `query`, not `execute` -> not a valid instrumentation target + class Connection extends Base {} + (Connection.prototype as any).query = (): void => {}; + (Connection.prototype as any).execute = (): void => {}; + + expect(getConnectionPrototypeToInstrument(Connection)).toBe(Connection.prototype); + }); +});