fix(telemetry): preserve driver error class and code in connection_test (#260)

Native connector test failures were flattened to `new Error(message)`,
collapsing every driver's error class to `Error` and dropping `.code` /
`.number`. connection_test telemetry could therefore not tell a SQL Server
login rejection (ELOGIN / 18456) apart from a network or TLS error, and the
only field that varied was a raw message.

Connectors now return `connectorTestFailure(error)`, which preserves the
original driver error as `cause`, and `testNativeConnection` re-throws that
cause. `scrubErrorClass` then records the real class (e.g. ConnectionError)
and `formatErrorDetail` keeps the code prefix (e.g. "ELOGIN: ..."). The
helper is the single source of truth for the failure shape across all seven
native connectors. User-facing terminal output is unchanged.
This commit is contained in:
Andrey Avtomonov 2026-06-04 14:51:14 +02:00 committed by GitHub
parent c2beaf7d55
commit ec7edf8f50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 82 additions and 18 deletions

View file

@ -74,6 +74,12 @@ async function testNativeConnection(
}
const result = await connector.testConnection();
if (!result.success) {
// Re-throw the driver's original error so connection_test telemetry records
// its real class (e.g. ConnectionError) and code (e.g. ELOGIN) instead of
// collapsing every native failure to a generic Error with no code.
if (result.cause instanceof Error) {
throw result.cause;
}
throw new Error(result.error ?? 'connection test failed');
}
return { driver: connector.driver };

View file

@ -5,7 +5,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@ -320,7 +322,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
this.id = `bigquery:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
const client = this.getClient();
await client.getDatasets({ maxResults: 1 });
@ -329,7 +331,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
}
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -1,7 +1,7 @@
import { createClient } from '@clickhouse/client';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import { readFileSync } from 'node:fs';
import { Agent as HttpsAgent } from 'node:https';
@ -317,12 +317,12 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
this.id = `clickhouse:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -11,7 +11,9 @@ import {
} from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@ -413,12 +415,12 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
this.id = `mysql:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -6,7 +6,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@ -442,12 +444,12 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
this.id = `postgres:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -7,7 +7,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@ -464,7 +466,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}
@ -573,7 +575,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
}
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
return this.getDriver().test();
}

View file

@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
export interface KtxSqliteConnectionConfig {
@ -167,7 +167,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.id = `sqlite:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
return { success: false, error: `File not found: ${this.dbPath}` };
@ -175,7 +175,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.database().prepare('SELECT 1').get();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -3,7 +3,9 @@ import { getDialectForDriver } from '../../context/connections/dialects.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@ -384,12 +386,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
this.id = `sqlserver:${options.connectionId}`;
}
async testConnection(): Promise<{ success: boolean; error?: string }> {
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
return connectorTestFailure(error);
}
}

View file

@ -303,9 +303,29 @@ export interface KtxTableListEntry {
kind: 'table' | 'view';
}
interface KtxConnectorTestResult {
export interface KtxConnectorTestResult {
success: boolean;
error?: string;
/**
* The original error thrown by the driver, preserved unflattened so the
* connection-test path can re-throw it. Keeping the real error object lets
* telemetry record the driver's actual error class (e.g. `ConnectionError`)
* and `.code` (e.g. `ELOGIN`) instead of collapsing every failure to `Error`.
*/
cause?: unknown;
}
/**
* Single source of truth for a failed connector test result. Captures the
* driver's message for display while preserving the original error as `cause`
* so callers can surface its real class and code.
*/
export function connectorTestFailure(error: unknown): KtxConnectorTestResult {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
cause: error,
};
}
export interface KtxScanConnector {

View file

@ -38,7 +38,7 @@ function makeIo() {
function nativeConnector(
driver: KtxConnectionDriver,
testResult: { success: true } | { success: false; error: string } = { success: true },
testResult: { success: true } | { success: false; error: string; cause?: unknown } = { success: true },
) {
const testConnection = vi.fn(async () => testResult);
const cleanup = vi.fn(async () => undefined);
@ -183,6 +183,34 @@ describe('runKtxConnection', () => {
expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"');
});
it('preserves the driver error class and code in connection_test telemetry', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
warehouse: { driver: 'sqlserver', host: 'db.example.test', database: 'analytics', username: 'svc_ro' },
});
class ConnectionError extends Error {
readonly code = 'ELOGIN';
}
const driverError = new ConnectionError("Login failed for user 'svc_ro'.");
const { connector } = nativeConnector('sqlserver', {
success: false,
error: driverError.message,
cause: driverError,
});
const io = makeIo();
const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
createScanConnector: vi.fn(async () => connector),
});
expect(code).toBe(1);
expect(io.stderr()).toContain('"errorClass":"ConnectionError"');
expect(io.stderr()).toContain('"errorDetail":"ELOGIN: Login failed for user \'svc_ro\'."');
});
it('reports the connector error and still cleans up when native testConnection fails', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });