diff --git a/packages/node/src/integrations/tracing/clickhouse/clickhouse.ts b/packages/node/src/integrations/tracing/clickhouse/clickhouse.ts new file mode 100644 index 000000000000..4898bf40c31b --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/clickhouse.ts @@ -0,0 +1,40 @@ +import type { Span } from '@opentelemetry/api'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { ClickHouseInstrumentation } from './instrumentation' + +const INTEGRATION_NAME = 'Clickhouse'; + +export const instrumentClickhouse = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new ClickHouseInstrumentation({ + responseHook(span: Span) { + addOriginToSpan(span, 'auto.db.otel.clickhouse'); + }, + }), +); + +const _clickhouseIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentClickhouse(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the [ClickHouse](https://www.npmjs.com/package/@clickhouse/client) library. + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [Sentry.clickhouseIntegration()], + * }); + * ``` + */ +export const clickhouseIntegration = defineIntegration(_clickhouseIntegration); diff --git a/packages/node/src/integrations/tracing/clickhouse/index.ts b/packages/node/src/integrations/tracing/clickhouse/index.ts new file mode 100644 index 000000000000..69bca014bba1 --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/index.ts @@ -0,0 +1,3 @@ +export { clickhouseIntegration, instrumentClickhouse } from './clickhouse'; +export { ClickHouseInstrumentation } from './instrumentation'; +export type { ClickHouseInstrumentationConfig } from './types'; diff --git a/packages/node/src/integrations/tracing/clickhouse/instrumentation.ts b/packages/node/src/integrations/tracing/clickhouse/instrumentation.ts new file mode 100644 index 000000000000..99a12b1b8a4e --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/instrumentation.ts @@ -0,0 +1,51 @@ +import type { InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { SDK_VERSION } from '@sentry/core'; +import { type ClickHouseModuleExports,patchClickHouseClient } from './patch'; +import type { ClickHouseInstrumentationConfig } from './types'; + +const PACKAGE_NAME = '@sentry/instrumentation-clickhouse'; +const supportedVersions = ['>=0.0.1']; + +/** + * + */ +export class ClickHouseInstrumentation extends InstrumentationBase { + public constructor(config: ClickHouseInstrumentationConfig = {}) { + super(PACKAGE_NAME, SDK_VERSION, config); + } + + /** + * + */ + public override init(): InstrumentationModuleDefinition { + return new InstrumentationNodeModuleDefinition( + '@clickhouse/client', + supportedVersions, + moduleExports => + patchClickHouseClient(moduleExports as ClickHouseModuleExports, { + wrap: this._wrap.bind(this), + unwrap: this._unwrap.bind(this), + tracer: this.tracer, + getConfig: this.getConfig.bind(this), + isEnabled: this.isEnabled.bind(this), + }), + moduleExports => { + const moduleExportsTyped = moduleExports as ClickHouseModuleExports; + const ClickHouseClient = moduleExportsTyped.ClickHouseClient; + if (ClickHouseClient && typeof ClickHouseClient === 'function' && 'prototype' in ClickHouseClient) { + const ClickHouseClientCtor = ClickHouseClient as new () => { + query: unknown; + insert: unknown; + exec: unknown; + command: unknown; + }; + this._unwrap(ClickHouseClientCtor.prototype, 'query'); + this._unwrap(ClickHouseClientCtor.prototype, 'insert'); + this._unwrap(ClickHouseClientCtor.prototype, 'exec'); + this._unwrap(ClickHouseClientCtor.prototype, 'command'); + } + }, + ); + } +} diff --git a/packages/node/src/integrations/tracing/clickhouse/patch.ts b/packages/node/src/integrations/tracing/clickhouse/patch.ts new file mode 100644 index 000000000000..54f58095e0e6 --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/patch.ts @@ -0,0 +1,233 @@ +import type { Tracer } from '@opentelemetry/api'; +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { InstrumentationBase } from '@opentelemetry/instrumentation'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import type { ClickHouseInstrumentationConfig } from './types'; +import { addExecutionStats, extractOperation, extractSummary, sanitizeQueryText } from './utils'; + +export interface ClickHouseModuleExports { + ClickHouseClient: unknown; +} + +export interface PatchClickHouseOptions { + getConfig: () => ClickHouseInstrumentationConfig; + isEnabled: () => boolean; + tracer: Tracer; + unwrap: InstrumentationBase['_unwrap']; + wrap: InstrumentationBase['_wrap']; +} + +// ClickHouse-specific semantic attributes +const SEMATTRS_DB_SYSTEM = 'db.system'; +const SEMATTRS_DB_OPERATION = 'db.operation'; +const SEMATTRS_DB_STATEMENT = 'db.statement'; +const SEMATTRS_DB_NAME = 'db.name'; +const SEMATTRS_NET_PEER_NAME = 'net.peer.name'; +const SEMATTRS_NET_PEER_PORT = 'net.peer.port'; + +// Type definitions for ClickHouse client internals +interface ClickHouseClientInstance { + query: unknown; + insert: unknown; + exec: unknown; + command: unknown; + connection_params?: { url?: string }; + options?: { url?: string }; +} + +interface ClickHouseQueryParams { + [key: string]: unknown; + query?: string; +} + +interface ClickHouseInsertParams { + [key: string]: unknown; + table?: string; + format?: string; + columns?: string[] | { except?: string[] }; +} + +interface ClickHouseResponse { + [key: string]: unknown; + response_headers?: Record; + headers?: Record; +} + +/** + * Patches the ClickHouse client to add OpenTelemetry instrumentation. + */ +export function patchClickHouseClient( + moduleExports: ClickHouseModuleExports, + options: PatchClickHouseOptions, +): ClickHouseModuleExports { + const { wrap, tracer, getConfig, isEnabled } = options; + const ClickHouseClient = moduleExports.ClickHouseClient; + + if (!ClickHouseClient || typeof ClickHouseClient !== 'function' || !('prototype' in ClickHouseClient)) { + return moduleExports; + } + + const ClickHouseClientCtor = ClickHouseClient as new () => { + query: unknown; + insert: unknown; + exec: unknown; + command: unknown; + }; + const prototype = ClickHouseClientCtor.prototype; + + const patchGeneric = (methodName: string): void => { + wrap( + prototype, + methodName, + createPatchHandler(methodName, tracer, getConfig, isEnabled, args => { + const params = (args[0] || {}) as ClickHouseQueryParams; + const queryText = params.query || (typeof params === 'string' ? params : ''); + return { queryText }; + }), + ); + }; + + const patchInsert = (): void => { + wrap( + prototype, + 'insert', + createPatchHandler('insert', tracer, getConfig, isEnabled, args => { + const params = (args[0] || {}) as ClickHouseInsertParams; + const table = params.table || ''; + const format = params.format || 'JSONCompactEachRow'; + let statement = `INSERT INTO ${table}`; + if (params.columns) { + if (Array.isArray(params.columns)) { + statement += ` (${params.columns.join(', ')})`; + } else if (params.columns.except) { + statement += ` (* EXCEPT (${params.columns.except.join(', ')}))`; + } + } + statement += ` FORMAT ${format}`; + return { queryText: statement, operation: 'INSERT' }; + }), + ); + }; + + patchGeneric('query'); + patchGeneric('exec'); + patchGeneric('command'); + patchInsert(); + + return moduleExports; +} + +function createPatchHandler( + methodName: string, + tracer: Tracer, + getConfig: () => ClickHouseInstrumentationConfig, + isEnabled: () => boolean, + attributesExtractor: (args: unknown[]) => { queryText: string; operation?: string }, +) { + return function (original: (...args: unknown[]) => unknown) { + return function (this: ClickHouseClientInstance, ...args: unknown[]): unknown { + if (!isEnabled()) { + return original.apply(this, args); + } + + const config = getConfig(); + let extraction; + try { + extraction = attributesExtractor(args); + } catch { + extraction = { queryText: '' }; + } + + const { queryText, operation: explicitOp } = extraction; + const operation = explicitOp || (queryText ? extractOperation(queryText) : methodName.toUpperCase()); + const spanName = operation ? `${operation} clickhouse` : `${methodName} clickhouse`; + + const span = tracer.startSpan(spanName, { + kind: SpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: 'clickhouse', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + [SEMATTRS_DB_OPERATION]: operation, + }, + }); + + if (config.dbName) { + span.setAttribute(SEMATTRS_DB_NAME, config.dbName); + } + if (config.captureQueryText !== false && queryText) { + const maxLength = config.maxQueryLength || 1000; + span.setAttribute(SEMATTRS_DB_STATEMENT, sanitizeQueryText(queryText, maxLength)); + } + if (config.peerName) { + span.setAttribute(SEMATTRS_NET_PEER_NAME, config.peerName); + } + if (config.peerPort) { + span.setAttribute(SEMATTRS_NET_PEER_PORT, config.peerPort); + } + if (!config.peerName || !config.peerPort) { + try { + const clientConfig = this.connection_params || this.options; + if (clientConfig?.url) { + const url = new URL(clientConfig.url); + if (!config.peerName) { + span.setAttribute(SEMATTRS_NET_PEER_NAME, url.hostname); + } + if (!config.peerPort) { + span.setAttribute(SEMATTRS_NET_PEER_PORT, parseInt(url.port, 10) || 8123); + } + } + } catch { + // ignore failures in auto-discovery + } + } + + return context.with(trace.setSpan(context.active(), span), () => { + const onSuccess = (response: ClickHouseResponse): ClickHouseResponse => { + if (config.captureExecutionStats !== false && response) { + const headers = response.response_headers || response.headers; + if (headers) { + const summary = extractSummary(headers); + if (summary) { + addExecutionStats(span, summary); + } + } + } + if (config.responseHook) { + try { + config.responseHook(span, response); + } catch { + // Ignore errors from user-provided hooks + } + } + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + return response; + }; + + const onError = (error: Error): never => { + if (config.responseHook) { + try { + config.responseHook(span, undefined); + } catch { + // Ignore errors from user-provided hooks + } + } + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); + span.end(); + throw error; + }; + + try { + const result = original.apply(this, args) as unknown; + if (result && typeof result === 'object' && 'then' in result && typeof result.then === 'function') { + return (result as Promise).then(onSuccess, onError); + } + return onSuccess(result as ClickHouseResponse); + } catch (error) { + return onError(error as Error); + } + }); + }; + }; +} diff --git a/packages/node/src/integrations/tracing/clickhouse/types.ts b/packages/node/src/integrations/tracing/clickhouse/types.ts new file mode 100644 index 000000000000..b763ee6cee8b --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/types.ts @@ -0,0 +1,45 @@ +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +export interface ClickHouseInstrumentationConfig extends InstrumentationConfig { + /** + * Hook called before the span ends. Can be used to add custom attributes. + */ + responseHook?: (span: Span, result: unknown) => void; + + /** + * Database name to include in spans. + */ + dbName?: string; + + /** + * Whether to capture full SQL query text in spans. + * Defaults to true. + */ + captureQueryText?: boolean; + + /** + * Maximum length for captured query text. Queries longer than this will be truncated. + * Defaults to 1000 characters. + */ + maxQueryLength?: number; + + /** + * Remote hostname or IP address of the ClickHouse server. + * Example: "clickhouse.example.com" or "192.168.1.100" + */ + peerName?: string; + + /** + * Remote port number of the ClickHouse server. + * Example: 8123 for HTTP, 9000 for native protocol + */ + peerPort?: number; + + /** + * Whether to capture ClickHouse execution statistics from response headers. + * This includes read/written rows, bytes, elapsed time, etc. + * Defaults to true. + */ + captureExecutionStats?: boolean; +} diff --git a/packages/node/src/integrations/tracing/clickhouse/utils.ts b/packages/node/src/integrations/tracing/clickhouse/utils.ts new file mode 100644 index 000000000000..dd0bf630e968 --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/utils.ts @@ -0,0 +1,120 @@ +import type { Span } from '@opentelemetry/api'; + +export interface ClickHouseSummary { + [key: string]: unknown; + elapsed_ns?: string; + read_bytes?: string; + read_rows?: string; + result_bytes?: string; + result_rows?: string; + written_bytes?: string; + written_rows?: string; +} + +// ClickHouse execution statistics attributes +const SEMATTRS_CLICKHOUSE_READ_ROWS = 'clickhouse.read_rows'; +const SEMATTRS_CLICKHOUSE_READ_BYTES = 'clickhouse.read_bytes'; +const SEMATTRS_CLICKHOUSE_WRITTEN_ROWS = 'clickhouse.written_rows'; +const SEMATTRS_CLICKHOUSE_WRITTEN_BYTES = 'clickhouse.written_bytes'; +const SEMATTRS_CLICKHOUSE_RESULT_ROWS = 'clickhouse.result_rows'; +const SEMATTRS_CLICKHOUSE_RESULT_BYTES = 'clickhouse.result_bytes'; +const SEMATTRS_CLICKHOUSE_ELAPSED_NS = 'clickhouse.elapsed_ns'; + +/** + * Extracts the SQL operation (SELECT, INSERT, etc.) from query text. + */ +export function extractOperation(queryText: string): string | undefined { + const trimmed = queryText.trim(); + const match = /^(?\w+)/u.exec(trimmed); + return match?.groups?.op?.toUpperCase(); +} + +/** + * Sanitizes and truncates query text for safe inclusion in spans. + */ +export function sanitizeQueryText(queryText: string, maxLength: number): string { + if (queryText.length <= maxLength) { + return queryText; + } + return `${queryText.substring(0, maxLength)}...`; +} + +/** + * Extracts ClickHouse summary from response headers. + */ +export function extractSummary(headers: Record): ClickHouseSummary | undefined { + if (!headers) { + return undefined; + } + + const summary = headers['x-clickhouse-summary'] as string | undefined; + if (summary && typeof summary === 'string') { + try { + return JSON.parse(summary); + } catch { + return undefined; + } + } + + if ('read_rows' in headers || 'result_rows' in headers || 'elapsed_ns' in headers) { + return headers; + } + + return undefined; +} + +/** + * Adds ClickHouse execution statistics to span attributes. + */ +export function addExecutionStats(span: Span, summary: ClickHouseSummary): void { + if (!summary) { + return; + } + + try { + if (summary.read_rows !== undefined) { + const readRows = parseInt(summary.read_rows, 10); + if (!isNaN(readRows)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_READ_ROWS, readRows); + } + } + if (summary.read_bytes !== undefined) { + const readBytes = parseInt(summary.read_bytes, 10); + if (!isNaN(readBytes)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_READ_BYTES, readBytes); + } + } + if (summary.written_rows !== undefined) { + const writtenRows = parseInt(summary.written_rows, 10); + if (!isNaN(writtenRows)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_WRITTEN_ROWS, writtenRows); + } + } + if (summary.written_bytes !== undefined) { + const writtenBytes = parseInt(summary.written_bytes, 10); + if (!isNaN(writtenBytes)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_WRITTEN_BYTES, writtenBytes); + } + } + if (summary.result_rows !== undefined) { + const resultRows = parseInt(summary.result_rows, 10); + if (!isNaN(resultRows)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_RESULT_ROWS, resultRows); + } + } + if (summary.result_bytes !== undefined) { + const resultBytes = parseInt(summary.result_bytes, 10); + if (!isNaN(resultBytes)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_RESULT_BYTES, resultBytes); + } + } + if (summary.elapsed_ns !== undefined) { + const elapsedNs = parseInt(summary.elapsed_ns, 10); + if (!isNaN(elapsedNs)) { + span.setAttribute(SEMATTRS_CLICKHOUSE_ELAPSED_NS, elapsedNs); + } + } + } catch { + // Silently ignore errors in stats extraction + } +} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dcd2efa5595c..cb27dc2f1890 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { instrumentOtelHttp, instrumentSentryHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; +import { clickhouseIntegration, instrumentClickhouse } from './clickhouse'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; @@ -43,6 +44,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { mysql2Integration(), redisIntegration(), postgresIntegration(), + clickhouseIntegration(), prismaIntegration(), hapiIntegration(), koaIntegration(), @@ -87,6 +89,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentMysql, instrumentMysql2, instrumentPostgres, + instrumentClickhouse, instrumentHapi, instrumentGraphql, instrumentRedis, diff --git a/packages/node/test/integrations/tracing/clickhouse.test.ts b/packages/node/test/integrations/tracing/clickhouse.test.ts new file mode 100644 index 000000000000..a27dc6800693 --- /dev/null +++ b/packages/node/test/integrations/tracing/clickhouse.test.ts @@ -0,0 +1,299 @@ +import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClickHouseInstrumentation } from '../../../src/integrations/tracing/clickhouse/instrumentation'; + +// Mock ClickHouse client for testing +class MockClickHouseClient { + public connection_params = { url: 'http://localhost:8123' }; + + async query(_params: any) { + return { + query_id: 'test-query-id', + response_headers: { + 'x-clickhouse-summary': JSON.stringify({ + read_rows: '100', + read_bytes: '1024', + elapsed_ns: '5000000', + }), + }, + }; + } + + async insert(_params: any) { + return { + query_id: 'test-insert-id', + response_headers: { + 'x-clickhouse-summary': JSON.stringify({ + written_rows: '50', + written_bytes: '512', + }), + }, + }; + } + + async exec(_params: any) { + return { + query_id: 'test-exec-id', + response_headers: {}, + }; + } + + async command(_params: any) { + return { + query_id: 'test-command-id', + response_headers: {}, + }; + } +} + +describe('ClickHouseInstrumentation - Functional Tests', () => { + let contextManager: AsyncHooksContextManager; + let provider: BasicTracerProvider; + let exporter: InMemorySpanExporter; + let instrumentation: ClickHouseInstrumentation; + let client: MockClickHouseClient; + + beforeAll(() => { + contextManager = new AsyncHooksContextManager(); + contextManager.enable(); + }); + + afterAll(() => { + contextManager.disable(); + }); + + beforeEach(() => { + // Setup OpenTelemetry test harness + exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + + // Create real instrumentation instance (not mocked) + instrumentation = new ClickHouseInstrumentation(); + instrumentation.setTracerProvider(provider); + + // Manually trigger the patch logic on our Mock Client + const moduleExports = { ClickHouseClient: MockClickHouseClient }; + + // @ts-expect-error - Accessing protected method for testing + const patchResult = instrumentation.init().patch(moduleExports, '0.0.1'); + + // Instantiate the patched client + client = new patchResult.ClickHouseClient(); + }); + + afterEach(() => { + exporter.reset(); + instrumentation.disable(); + vi.clearAllMocks(); + }); + + it('instruments query method and creates span with correct attributes', async () => { + await client.query({ query: 'SELECT * FROM users' }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.name).toBe('SELECT clickhouse'); + expect(span.kind).toBe(SpanKind.CLIENT); + expect(span.attributes['db.system']).toBe('clickhouse'); + expect(span.attributes['db.operation']).toBe('SELECT'); + expect(span.attributes['db.statement']).toBe('SELECT * FROM users'); + expect(span.attributes['sentry.op']).toBe('db.query'); + + // Check connection attributes extracted from client instance + expect(span.attributes['net.peer.name']).toBe('localhost'); + expect(span.attributes['net.peer.port']).toBe(8123); + + // Check execution stats from headers + expect(span.attributes['clickhouse.read_rows']).toBe(100); + expect(span.attributes['clickhouse.elapsed_ns']).toBe(5000000); + }); + + it('instruments insert method and reconstructs statement', async () => { + await client.insert({ + table: 'logs', + values: [{ id: 1 }], + format: 'JSONEachRow', + }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.name).toBe('INSERT clickhouse'); + expect(span.attributes['db.operation']).toBe('INSERT'); + expect(span.attributes['db.statement']).toBe('INSERT INTO logs FORMAT JSONEachRow'); + expect(span.attributes['clickhouse.written_rows']).toBe(50); + }); + + it('handles insert with specific columns', async () => { + await client.insert({ + table: 'metrics', + columns: ['name', 'value'], + values: [], + }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.attributes['db.statement']).toBe('INSERT INTO metrics (name, value) FORMAT JSONCompactEachRow'); + }); + + it('instruments exec method', async () => { + await client.exec({ query: 'CREATE TABLE test (id Int32)' }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.name).toBe('CREATE clickhouse'); + expect(span.attributes['db.statement']).toBe('CREATE TABLE test (id Int32)'); + }); + + it('instruments command method', async () => { + await client.command({ query: 'SYSTEM DROP DNS CACHE' }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.name).toBe('SYSTEM clickhouse'); + }); + + it('sanitizes long queries when maxQueryLength is set', async () => { + instrumentation.setConfig({ maxQueryLength: 10 }); + await client.query({ query: 'SELECT * FROM very_long_table_name' }); + + const spans = exporter.getFinishedSpans(); + expect(spans[0]!.attributes['db.statement']).toBe('SELECT * F...'); + }); + + it('suppresses query text when captureQueryText is false', async () => { + instrumentation.setConfig({ captureQueryText: false }); + await client.query({ query: 'SELECT * FROM secrets' }); + + const spans = exporter.getFinishedSpans(); + expect(spans[0]!.attributes['db.statement']).toBeUndefined(); + }); + + it('records errors with correct span status', async () => { + // Create a client that throws synchronously + class ErrorClient { + public connection_params = { url: 'http://localhost:8123' }; + + async query(_params: any) { + throw new Error('Connection failed'); + } + } + + const moduleExports = { ClickHouseClient: ErrorClient }; + // @ts-expect-error - Accessing protected method for testing + const patchResult = instrumentation.init().patch(moduleExports, '0.0.1'); + const errorClient = new patchResult.ClickHouseClient(); + + exporter.reset(); + await expect(errorClient.query({ query: 'SELECT 1' })).rejects.toThrow('Connection failed'); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.status.code).toBe(SpanStatusCode.ERROR); + expect(span.status.message).toBe('Connection failed'); + expect(span.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'exception', + attributes: expect.objectContaining({ + 'exception.message': 'Connection failed', + }), + }), + ]) + ); + }); + + it('calls responseHook when configured', async () => { + const hook = vi.fn(); + instrumentation.setConfig({ responseHook: hook }); + + const result = await client.query({ query: 'SELECT 1' }); + + expect(hook).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ + spanContext: expect.any(Function), + }), + result + ); + }); + + it('uses custom dbName when configured', async () => { + instrumentation.setConfig({ dbName: 'my_database' }); + + await client.query({ query: 'SELECT 1' }); + + const spans = exporter.getFinishedSpans(); + expect(spans[0]!.attributes['db.name']).toBe('my_database'); + }); + + it('uses custom peer name and port when auto-discovery fails', async () => { + // Create a client without connection_params to test fallback + class ClientWithoutParams { + async query(_params: any) { + return { query_id: 'test' }; + } + } + + instrumentation.setConfig({ peerName: 'custom-host', peerPort: 9000 }); + + const moduleExports = { ClickHouseClient: ClientWithoutParams }; + // @ts-expect-error - Accessing protected method for testing + const patchResult = instrumentation.init().patch(moduleExports, '0.0.1'); + const customClient = new patchResult.ClickHouseClient(); + + exporter.reset(); + await customClient.query({ query: 'SELECT 1' }); + + const spans = exporter.getFinishedSpans(); + expect(spans[0]!.attributes['net.peer.name']).toBe('custom-host'); + expect(spans[0]!.attributes['net.peer.port']).toBe(9000); + }); + + it.each([ + { query: 'SELECT * FROM users', expectedOp: 'SELECT' }, + { query: 'INSERT INTO logs VALUES (1)', expectedOp: 'INSERT' }, + { query: 'UPDATE users SET name = ?', expectedOp: 'UPDATE' }, + { query: 'DELETE FROM logs WHERE id = 1', expectedOp: 'DELETE' }, + { query: 'CREATE TABLE test (id Int32)', expectedOp: 'CREATE' }, + { query: 'DROP TABLE test', expectedOp: 'DROP' }, + { query: 'ALTER TABLE test ADD COLUMN name String', expectedOp: 'ALTER' }, + { query: 'TRUNCATE TABLE logs', expectedOp: 'TRUNCATE' }, + ])('extracts $expectedOp operation from "$query"', async ({ query, expectedOp }) => { + await client.query({ query }); + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + + const span = spans[0]!; + expect(span.attributes['db.operation']).toBe(expectedOp); + expect(span.name).toBe(`${expectedOp} clickhouse`); + }); + + it('handles captureExecutionStats option', async () => { + instrumentation.setConfig({ captureExecutionStats: false }); + + await client.query({ query: 'SELECT * FROM users' }); + + const spans = exporter.getFinishedSpans(); + const span = spans[0]!; + + // Stats should not be captured + expect(span.attributes['clickhouse.read_rows']).toBeUndefined(); + expect(span.attributes['clickhouse.elapsed_ns']).toBeUndefined(); + }); +});