From 4b6b56a305c1d16e2530f37996b751a78858d175 Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Fri, 26 Dec 2025 14:26:19 +0000 Subject: [PATCH 1/5] feat(node): add ClickHouse client instrumentation --- .../tracing/clickhouse/clickhouse.ts | 40 ++ .../integrations/tracing/clickhouse/index.ts | 3 + .../tracing/clickhouse/instrumentation.ts | 51 +++ .../integrations/tracing/clickhouse/patch.ts | 373 ++++++++++++++++++ .../integrations/tracing/clickhouse/types.ts | 45 +++ .../node/src/integrations/tracing/index.ts | 3 + .../integrations/tracing/clickhouse.test.ts | 304 ++++++++++++++ 7 files changed, 819 insertions(+) create mode 100644 packages/node/src/integrations/tracing/clickhouse/clickhouse.ts create mode 100644 packages/node/src/integrations/tracing/clickhouse/index.ts create mode 100644 packages/node/src/integrations/tracing/clickhouse/instrumentation.ts create mode 100644 packages/node/src/integrations/tracing/clickhouse/patch.ts create mode 100644 packages/node/src/integrations/tracing/clickhouse/types.ts create mode 100644 packages/node/test/integrations/tracing/clickhouse.test.ts 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..0b65a7b1557c --- /dev/null +++ b/packages/node/src/integrations/tracing/clickhouse/patch.ts @@ -0,0 +1,373 @@ +import type { Span, 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'; + +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; +} + +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'; + +// 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. + */ +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. + */ +function sanitizeQueryText(queryText: string, maxLength: number): string { + if (queryText.length <= maxLength) { + return queryText; + } + return `${queryText.substring(0, maxLength)}...`; +} + +/** + * Extracts ClickHouse summary from response headers. + */ +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. + */ +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 + } +} + +// 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; + + // Helper to patch standard query methods + 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 }; + }), + ); + }; + + // Helper to patch insert specifically + const patchInsert = (): void => { + wrap( + prototype, + 'insert', + createPatchHandler('insert', tracer, getConfig, isEnabled, args => { + const params = (args[0] || {}) as ClickHouseInsertParams; + const table = params.table as string; + 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', // Explicitly force INSERT operation + }; + }), + ); + }; + + patchGeneric('query'); + patchGeneric('exec'); + patchGeneric('command'); + patchInsert(); + + return moduleExports; +} + +/** + * A generic patch handler factory that handles the boilerplate + * of span creation, context wrapping, execution, and error handling. + */ +// patch.ts (Partial update - replace the createPatchHandler function) + +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 (e) { + 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)); + } + + // Connection Attributes Logic: + // 1. Prefer explicit config + if (config.peerName) { + span.setAttribute(SEMATTRS_NET_PEER_NAME, config.peerName); + } + if (config.peerPort) { + span.setAttribute(SEMATTRS_NET_PEER_PORT, config.peerPort); + } + + // 2. Fallback to auto-discovery if attributes are missing + 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) { + // Ensure port is stored as a number + 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) { + config.responseHook(span, response); + } + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + return response; + }; + + const onError = (error: Error): never => { + 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) { + 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/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..79113a5724fa --- /dev/null +++ b/packages/node/test/integrations/tracing/clickhouse.test.ts @@ -0,0 +1,304 @@ +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('extracts operation from various SQL statements', async () => { + const testCases = [ + { 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' }, + ]; + + for (const { query } of testCases) { + await client.query({ query }); + } + + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(testCases.length); + + testCases.forEach((testCase, index) => { + expect(spans[index]!.attributes['db.operation']).toBe(testCase.expectedOp); + expect(spans[index]!.name).toBe(`${testCase.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(); + }); +}); From 14c06002ce95be159239325ceea2ad26286f1345 Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Fri, 26 Dec 2025 19:40:59 +0100 Subject: [PATCH 2/5] fix(node): ensure ClickHouse spans have sentry.origin on error paths (#15966) --- .../node/src/integrations/tracing/clickhouse/patch.ts | 10 ++++++---- .../node/test/integrations/tracing/clickhouse.test.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/node/src/integrations/tracing/clickhouse/patch.ts b/packages/node/src/integrations/tracing/clickhouse/patch.ts index 0b65a7b1557c..b31e9a8d77f6 100644 --- a/packages/node/src/integrations/tracing/clickhouse/patch.ts +++ b/packages/node/src/integrations/tracing/clickhouse/patch.ts @@ -330,6 +330,11 @@ function createPatchHandler( } return context.with(trace.setSpan(context.active(), span), () => { + // Call responseHook early to ensure sentry.origin is set for both success and error cases + if (config.responseHook) { + config.responseHook(span, undefined); + } + const onSuccess = (response: ClickHouseResponse): ClickHouseResponse => { if (config.captureExecutionStats !== false && response) { const headers = response.response_headers || response.headers; @@ -340,9 +345,6 @@ function createPatchHandler( } } } - if (config.responseHook) { - config.responseHook(span, response); - } span.setStatus({ code: SpanStatusCode.OK }); span.end(); return response; @@ -365,7 +367,7 @@ function createPatchHandler( } return onSuccess(result as ClickHouseResponse); } catch (error) { - onError(error as Error); + return onError(error as Error); } }); }; diff --git a/packages/node/test/integrations/tracing/clickhouse.test.ts b/packages/node/test/integrations/tracing/clickhouse.test.ts index 79113a5724fa..55bb607da7b4 100644 --- a/packages/node/test/integrations/tracing/clickhouse.test.ts +++ b/packages/node/test/integrations/tracing/clickhouse.test.ts @@ -221,14 +221,15 @@ describe('ClickHouseInstrumentation - Functional Tests', () => { const hook = vi.fn(); instrumentation.setConfig({ responseHook: hook }); - const result = await client.query({ query: 'SELECT 1' }); + await client.query({ query: 'SELECT 1' }); expect(hook).toHaveBeenCalledTimes(1); + // responseHook is called early with undefined to ensure sentry.origin is set for both success and error cases expect(hook).toHaveBeenCalledWith( expect.objectContaining({ spanContext: expect.any(Function), }), - result + undefined ); }); From 5b7755fccb517ae8fdab406968f06dcf4aaa2eee Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Fri, 26 Dec 2025 20:11:35 +0100 Subject: [PATCH 3/5] fix(node): pass actual response to ClickHouse responseHook and handle missing table (#15966) --- .../integrations/tracing/clickhouse/patch.ts | 20 +++++++++---------- .../integrations/tracing/clickhouse.test.ts | 5 ++--- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/node/src/integrations/tracing/clickhouse/patch.ts b/packages/node/src/integrations/tracing/clickhouse/patch.ts index b31e9a8d77f6..49eb6c131566 100644 --- a/packages/node/src/integrations/tracing/clickhouse/patch.ts +++ b/packages/node/src/integrations/tracing/clickhouse/patch.ts @@ -223,7 +223,7 @@ export function patchClickHouseClient( 'insert', createPatchHandler('insert', tracer, getConfig, isEnabled, args => { const params = (args[0] || {}) as ClickHouseInsertParams; - const table = params.table as string; + const table = params.table || ''; const format = params.format || 'JSONCompactEachRow'; let statement = `INSERT INTO ${table}`; @@ -330,11 +330,6 @@ function createPatchHandler( } return context.with(trace.setSpan(context.active(), span), () => { - // Call responseHook early to ensure sentry.origin is set for both success and error cases - if (config.responseHook) { - config.responseHook(span, undefined); - } - const onSuccess = (response: ClickHouseResponse): ClickHouseResponse => { if (config.captureExecutionStats !== false && response) { const headers = response.response_headers || response.headers; @@ -345,17 +340,22 @@ function createPatchHandler( } } } + // Call responseHook with the actual response + if (config.responseHook) { + config.responseHook(span, response); + } span.setStatus({ code: SpanStatusCode.OK }); span.end(); return response; }; const onError = (error: Error): never => { + // Call responseHook to ensure sentry.origin is set for error cases + if (config.responseHook) { + config.responseHook(span, undefined); + } span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: error.message, - }); + span.setStatus({code: SpanStatusCode.ERROR, message: error.message}); span.end(); throw error; }; diff --git a/packages/node/test/integrations/tracing/clickhouse.test.ts b/packages/node/test/integrations/tracing/clickhouse.test.ts index 55bb607da7b4..79113a5724fa 100644 --- a/packages/node/test/integrations/tracing/clickhouse.test.ts +++ b/packages/node/test/integrations/tracing/clickhouse.test.ts @@ -221,15 +221,14 @@ describe('ClickHouseInstrumentation - Functional Tests', () => { const hook = vi.fn(); instrumentation.setConfig({ responseHook: hook }); - await client.query({ query: 'SELECT 1' }); + const result = await client.query({ query: 'SELECT 1' }); expect(hook).toHaveBeenCalledTimes(1); - // responseHook is called early with undefined to ensure sentry.origin is set for both success and error cases expect(hook).toHaveBeenCalledWith( expect.objectContaining({ spanContext: expect.any(Function), }), - undefined + result ); }); From 8cbc18e868ed6b0942136055573ffb1dc66acff8 Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Fri, 26 Dec 2025 20:24:03 +0100 Subject: [PATCH 4/5] test(node): use it.each for ClickHouse SQL operation tests (#15966) --- .../integrations/tracing/clickhouse.test.ts | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/node/test/integrations/tracing/clickhouse.test.ts b/packages/node/test/integrations/tracing/clickhouse.test.ts index 79113a5724fa..a27dc6800693 100644 --- a/packages/node/test/integrations/tracing/clickhouse.test.ts +++ b/packages/node/test/integrations/tracing/clickhouse.test.ts @@ -264,29 +264,24 @@ describe('ClickHouseInstrumentation - Functional Tests', () => { expect(spans[0]!.attributes['net.peer.port']).toBe(9000); }); - it('extracts operation from various SQL statements', async () => { - const testCases = [ - { 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' }, - ]; - - for (const { query } of testCases) { - await client.query({ query }); - } + 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(testCases.length); + expect(spans).toHaveLength(1); - testCases.forEach((testCase, index) => { - expect(spans[index]!.attributes['db.operation']).toBe(testCase.expectedOp); - expect(spans[index]!.name).toBe(`${testCase.expectedOp} clickhouse`); - }); + const span = spans[0]!; + expect(span.attributes['db.operation']).toBe(expectedOp); + expect(span.name).toBe(`${expectedOp} clickhouse`); }); it('handles captureExecutionStats option', async () => { From 568eedd4d2aeec0427b6c33d889d6f7ae3e9de8e Mon Sep 17 00:00:00 2001 From: Mohamed Hamed Date: Fri, 26 Dec 2025 21:01:23 +0100 Subject: [PATCH 5/5] fix(node): wrap ClickHouse responseHook calls in try-catch to prevent orphaned spans (#15966) --- .../integrations/tracing/clickhouse/patch.ts | 200 +++--------------- .../integrations/tracing/clickhouse/utils.ts | 120 +++++++++++ 2 files changed, 149 insertions(+), 171 deletions(-) create mode 100644 packages/node/src/integrations/tracing/clickhouse/utils.ts diff --git a/packages/node/src/integrations/tracing/clickhouse/patch.ts b/packages/node/src/integrations/tracing/clickhouse/patch.ts index 49eb6c131566..54f58095e0e6 100644 --- a/packages/node/src/integrations/tracing/clickhouse/patch.ts +++ b/packages/node/src/integrations/tracing/clickhouse/patch.ts @@ -1,19 +1,9 @@ -import type { Span, Tracer } from '@opentelemetry/api'; +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'; - -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; -} +import { addExecutionStats, extractOperation, extractSummary, sanitizeQueryText } from './utils'; export interface ClickHouseModuleExports { ClickHouseClient: unknown; @@ -35,132 +25,14 @@ const SEMATTRS_DB_NAME = 'db.name'; const SEMATTRS_NET_PEER_NAME = 'net.peer.name'; const SEMATTRS_NET_PEER_PORT = 'net.peer.port'; -// 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. - */ -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. - */ -function sanitizeQueryText(queryText: string, maxLength: number): string { - if (queryText.length <= maxLength) { - return queryText; - } - return `${queryText.substring(0, maxLength)}...`; -} - -/** - * Extracts ClickHouse summary from response headers. - */ -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. - */ -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 - } -} - // Type definitions for ClickHouse client internals interface ClickHouseClientInstance { query: unknown; insert: unknown; exec: unknown; command: unknown; - connection_params?: { - url?: string; - }; - options?: { - url?: string; - }; + connection_params?: { url?: string }; + options?: { url?: string }; } interface ClickHouseQueryParams { @@ -191,19 +63,18 @@ export function patchClickHouseClient( const { wrap, tracer, getConfig, isEnabled } = options; const ClickHouseClient = moduleExports.ClickHouseClient; - if (!ClickHouseClient || typeof ClickHouseClient !== 'function' || !('prototype' in 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 ClickHouseClientCtor = ClickHouseClient as new () => { + query: unknown; + insert: unknown; + exec: unknown; + command: unknown; + }; + const prototype = ClickHouseClientCtor.prototype; - // Helper to patch standard query methods const patchGeneric = (methodName: string): void => { wrap( prototype, @@ -216,7 +87,6 @@ export function patchClickHouseClient( ); }; - // Helper to patch insert specifically const patchInsert = (): void => { wrap( prototype, @@ -226,7 +96,6 @@ export function patchClickHouseClient( 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(', ')})`; @@ -235,11 +104,7 @@ export function patchClickHouseClient( } } statement += ` FORMAT ${format}`; - - return { - queryText: statement, - operation: 'INSERT', // Explicitly force INSERT operation - }; + return { queryText: statement, operation: 'INSERT' }; }), ); }; @@ -252,12 +117,6 @@ export function patchClickHouseClient( return moduleExports; } -/** - * A generic patch handler factory that handles the boilerplate - * of span creation, context wrapping, execution, and error handling. - */ -// patch.ts (Partial update - replace the createPatchHandler function) - function createPatchHandler( methodName: string, tracer: Tracer, @@ -275,7 +134,7 @@ function createPatchHandler( let extraction; try { extraction = attributesExtractor(args); - } catch (e) { + } catch { extraction = { queryText: '' }; } @@ -295,22 +154,16 @@ function createPatchHandler( 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)); } - - // Connection Attributes Logic: - // 1. Prefer explicit config if (config.peerName) { span.setAttribute(SEMATTRS_NET_PEER_NAME, config.peerName); } if (config.peerPort) { span.setAttribute(SEMATTRS_NET_PEER_PORT, config.peerPort); } - - // 2. Fallback to auto-discovery if attributes are missing if (!config.peerName || !config.peerPort) { try { const clientConfig = this.connection_params || this.options; @@ -320,7 +173,6 @@ function createPatchHandler( span.setAttribute(SEMATTRS_NET_PEER_NAME, url.hostname); } if (!config.peerPort) { - // Ensure port is stored as a number span.setAttribute(SEMATTRS_NET_PEER_PORT, parseInt(url.port, 10) || 8123); } } @@ -334,15 +186,18 @@ function createPatchHandler( if (config.captureExecutionStats !== false && response) { const headers = response.response_headers || response.headers; if (headers) { - const summary = extractSummary(headers); - if (summary) { - addExecutionStats(span, summary); - } + const summary = extractSummary(headers); + if (summary) { + addExecutionStats(span, summary); + } } } - // Call responseHook with the actual response if (config.responseHook) { - config.responseHook(span, response); + try { + config.responseHook(span, response); + } catch { + // Ignore errors from user-provided hooks + } } span.setStatus({ code: SpanStatusCode.OK }); span.end(); @@ -350,12 +205,15 @@ function createPatchHandler( }; const onError = (error: Error): never => { - // Call responseHook to ensure sentry.origin is set for error cases if (config.responseHook) { - config.responseHook(span, undefined); + try { + config.responseHook(span, undefined); + } catch { + // Ignore errors from user-provided hooks + } } span.recordException(error); - span.setStatus({code: SpanStatusCode.ERROR, message: error.message}); + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); span.end(); throw error; }; 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 + } +}