Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- `oauthScopes` is now honored on the Thrift backend: OAuth client-secret M2M sends the configured scopes instead of always `all-apis`, so scoped service-principal secrets can authenticate, and U2M uses them in place of `sql offline_access`. On both backends, entries are split on whitespace and blank values fall back to those defaults. A token read from `persistence` that records different scopes from the ones the client would request is no longer reused or refreshed; the client acquires a new one (for U2M, through a new browser login). Tokens that don't record scopes are still reused. (#540)
- Fix `DECIMAL` parameters losing their fractional digits on the Thrift backend: `"123.45"` was bound as `decimal(3,0)` and came back as `123`. Both backends now send `DECIMAL(p,s)` derived from the value (also for the `DEC` / `NUMERIC` synonyms), stringify `Int64` values beyond 2^53 exactly instead of as `Infinity`, accept exponent forms such as `1e-7` (rewritten as a plain numeral), and throw a `ParameterError` for non-numeric values or values that need more than 38 digits. (#474)
- `customHeaders` are now sent on every Thrift request (session, statement and metadata calls), as the kernel backend already did, instead of only on telemetry and feature-flag requests. On these requests, caller values for driver-managed headers (`Authorization`, `User-Agent`, `Content-Type`, `Content-Length`, `Transfer-Encoding`, `Connection`) are ignored on both backends, and the Thrift backend rejects header names or values that cannot be sent at connect time. (#491)
- Error telemetry now reports only the error class name and the server SQLSTATE. It no longer includes the error message or stack trace, which could contain table and column names from the query. On the Thrift backend, `statement.complete` telemetry now also reports the result delivery format (inline Arrow, inline columnar, or CloudFetch) and whether the server LZ4-compressed the result. (#508)

## 3.0.0

Expand Down
9 changes: 5 additions & 4 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ same auth as your queries.
version, and boolean feature toggles (CloudFetch, LZ4, Arrow, direct results) plus numeric
configs (socket timeout, retry max, CloudFetch concurrency).
- **Statement** (`statement.start` / `statement.complete`): randomly generated statement and
session UUIDs, operation type (e.g. `SELECT`), latency, result format, poll count, chunk
count, bytes downloaded.
session UUIDs, operation type (e.g. `SELECT`), latency, result delivery format
(`INLINE_ARROW`, `COLUMNAR_INLINE`, or `EXTERNAL_LINKS`), whether the server compressed the
result, poll count, chunk count, bytes downloaded.
- **CloudFetch chunk** (`cloudfetch.chunk`): chunk index, download latency, byte size,
compressed flag.
- **Error**: error class name, sanitized message (no PII), HTTP status, terminal-vs-retryable
flag. Stack traces are not transmitted.
- **Error**: error class name, server SQLSTATE (e.g. `42P01`) when present, terminal-vs-retryable
flag. Error messages and stack traces are not collected because they can quote SQL text.

Correlation IDs (session ID, statement ID) are random UUIDs and are not tied to user identity.
Workspace ID is included for aggregation.
Expand Down
25 changes: 10 additions & 15 deletions lib/DBSQLOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import ThriftOperationBackend from './thrift-backend/ThriftOperationBackend';
import { synthesizeThriftStatus, synthesizeThriftResultSetMetadata } from './thrift-backend/wireSynthesis';
import { mapOperationTypeToTelemetryType } from './telemetry/telemetryTypeMappers';
import ExceptionClassifier from './telemetry/ExceptionClassifier';
import { safeEmit } from './telemetry/telemetryUtils';
import { describeErrorForTelemetry, safeEmit } from './telemetry/telemetryUtils';
// Declares `Symbol.asyncDispose` for the emitted typings of `[Symbol.asyncDispose]()` below
import './contracts/asyncDispose';

Expand Down Expand Up @@ -451,14 +451,15 @@ export default class DBSQLOperation implements IOperation {
// throws if the operation is already in an error/closed state, which
// would then fire spurious error telemetry from `getMetadata`'s error
// wrapper.
const resultFormat = mapNeutralResultFormatToTelemetryType(this.metadata?.resultFormat);
const metadata = this.backend.getCachedResultMetadata?.() ?? this.metadata;
const latencyMs = Date.now() - this.startTime;

emitter.emitStatementComplete({
statementId: this.id,
sessionId: this.sessionId,
latencyMs,
resultFormat,
resultFormat: mapNeutralResultFormatToTelemetryType(metadata?.resultFormat),
compressed: metadata ? Boolean(metadata.lz4Compressed) : undefined,
pollCount: this.pollCount,
});

Expand All @@ -467,24 +468,18 @@ export default class DBSQLOperation implements IOperation {
}

/**
* Emit a telemetry error event for an exception thrown by an operation.
* Terminal errors (per `ExceptionClassifier`) trigger an immediate flush
* in the aggregator; retryable errors are buffered until the statement
* completes. All exceptions from this method itself are swallowed at
* debug level — telemetry must never break the driver.
*
* Pass `deferToCompletion` when the caller completes the statement right
* afterwards: the error is then buffered (not terminal) so the statement's
* aggregation is finalized once, by that completion.
* Report only the error class name and SQLSTATE. Terminal errors flush
* immediately; retryable errors are buffered until statement completion.
* `deferToCompletion` buffers a terminal error when the caller completes
* the statement next, so aggregation is finalized once.
* Telemetry failures are swallowed at debug level.
*/
private emitErrorEvent(error: Error, options?: { deferToCompletion?: boolean }): void {
safeEmit(this.context, (emitter) => {
emitter.emitError({
statementId: this.id,
sessionId: this.sessionId,
errorName: error.name || 'Error',
errorMessage: error.message || 'Unknown error',
errorStack: error.stack,
...describeErrorForTelemetry(error),
isTerminal: options?.deferToCompletion ? false : ExceptionClassifier.isTerminal(error),
});
});
Expand Down
7 changes: 7 additions & 0 deletions lib/contracts/IOperationBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export default interface IOperationBackend {
/** Fetch result-set metadata (schema, format, lz4 flag, arrow schema, staging flag). */
getResultMetadata(): Promise<ResultMetadata>;

/**
* Result-set metadata if it is already on hand (direct results or an earlier
* fetch), else `undefined`. MUST NOT issue an RPC or throw — statement
* telemetry reads it at close time.
*/
getCachedResultMetadata?(): ResultMetadata | undefined;

/** Cancel the operation. */
cancel(): Promise<Status>;

Expand Down
14 changes: 7 additions & 7 deletions lib/telemetry/DatabricksTelemetryExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import {
hasAuthorization,
normalizeHeaders,
redactSensitive,
sanitizeErrorName,
sanitizeProcessName,
sanitizeSqlState,
} from './telemetryUtils';
import buildUserAgentString from '../utils/buildUserAgentString';

Expand Down Expand Up @@ -456,14 +458,12 @@ export default class DatabricksTelemetryExporter {
}
}
} else if (metric.metricType === 'error') {
const stackOrMessage = metric.errorStack ?? metric.errorMessage ?? '';
// Primary redaction site is TelemetryEventEmitter.emitError — this
// second pass is defence-in-depth in case a metric reached the
// exporter via a path that bypassed the emitter (programmatic injection
// in tests, future call sites). On the unauth path we ship nothing.
// Only the class name and SQLSTATE leave the process (the SQLSTATE in
// `stack_trace`, matching the kernel); message/stack are never read here
// even if a metric bypassed the emitter. Unauth ships no SQLSTATE.
log.entry.sql_driver_log.error_info = {
error_name: metric.errorName || 'UnknownError',
stack_trace: includeCorrelation ? redactSensitive(stackOrMessage) : '',
error_name: sanitizeErrorName(metric.errorName) ?? 'UnknownError',
stack_trace: includeCorrelation ? sanitizeSqlState(metric.sqlState) ?? '' : '',
};
}

Expand Down
92 changes: 39 additions & 53 deletions lib/telemetry/MetricsAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,7 @@ export default class MetricsAggregator {
this.pushBoundedError(details, event);
this.completeStatement(event.statementId);
} else {
const metric: TelemetryMetric = {
metricType: 'error',
timestamp: event.timestamp,
sessionId: event.sessionId,
statementId: event.statementId,
workspaceId: event.workspaceId,
errorName: event.errorName,
errorMessage: event.errorMessage,
errorStack: event.errorStack,
};
this.addPendingMetric(metric);
this.addPendingMetric(MetricsAggregator.errorMetric(event, event));
}

// Fire-and-forget on the terminal-error path so customer code doesn't
Expand All @@ -233,6 +223,25 @@ export default class MetricsAggregator {
}
}

/**
* The only place error metrics are built: copies the sanitized descriptor
* (name + SQLSTATE) and never a message or stack, whatever the event carries.
*/
private static errorMetric(
errorEvent: TelemetryEvent,
ids: { sessionId?: string; statementId?: string; workspaceId?: string },
): TelemetryMetric {
return {
metricType: 'error',
timestamp: errorEvent.timestamp,
sessionId: ids.sessionId,
statementId: ids.statementId,
workspaceId: ids.workspaceId,
errorName: errorEvent.errorName,
sqlState: errorEvent.sqlState,
};
}

private pushBoundedError(details: StatementTelemetryDetails, event: TelemetryEvent): void {
if (details.errors.length >= this.maxErrorsPerStatement) {
details.errors.shift();
Expand All @@ -252,6 +261,7 @@ export default class MetricsAggregator {
case TelemetryEventType.STATEMENT_COMPLETE:
details.executionLatencyMs = event.latencyMs;
details.resultFormat = event.resultFormat;
MetricsAggregator.mergeCompressed(details, event.compressed);
// STATEMENT_COMPLETE may not carry chunk counts (the operation
// doesn't always know them at close time); only override when the
// emit explicitly supplied a value, otherwise the values accumulated
Expand All @@ -270,17 +280,7 @@ export default class MetricsAggregator {
case TelemetryEventType.CLOUDFETCH_CHUNK:
details.chunkCount += 1;
details.bytesDownloaded += event.bytes ?? 0;
// `compressionEnabled` is a sticky OR across all chunks in the
// statement — any compressed chunk flips it true and it stays true.
// Previously we copied the last chunk's value, which silently lied
// for mixed-compression batches (compressed chunk 1, uncompressed
// chunk 2 → compressionEnabled=false). True-on-any matches the
// dashboard contract "did this statement benefit from compression".
if (event.compressed === true) {
details.compressionEnabled = true;
} else if (event.compressed === false && details.compressionEnabled === undefined) {
details.compressionEnabled = false;
}
MetricsAggregator.mergeCompressed(details, event.compressed);
// Per-chunk timing aggregation. Only record positive latencies — keeps
// prefetched/cached pages out of the timing stats.
if (event.latencyMs !== undefined && event.latencyMs > 0) {
Expand All @@ -299,6 +299,20 @@ export default class MetricsAggregator {
}
}

/**
* `compressionEnabled` is a sticky OR across the statement's chunks and its
* completion event — any compressed report flips it true and it stays true.
* True-on-any matches the dashboard contract "did this statement benefit
* from compression".
*/
private static mergeCompressed(details: StatementTelemetryDetails, compressed: boolean | undefined): void {
if (compressed === true) {
details.compressionEnabled = true;
} else if (compressed === false && details.compressionEnabled === undefined) {
details.compressionEnabled = false;
}
}

private getOrCreateStatementDetails(event: TelemetryEvent): StatementTelemetryDetails {
const statementId = event.statementId!;

Expand Down Expand Up @@ -336,16 +350,7 @@ export default class MetricsAggregator {
const id = oldest.value;
const details = this.statementMetrics.get(id)!;
for (const errorEvent of details.errors) {
this.addPendingMetric({
metricType: 'error',
timestamp: errorEvent.timestamp,
sessionId: details.sessionId,
statementId: details.statementId,
workspaceId: details.workspaceId,
errorName: errorEvent.errorName,
errorMessage: errorEvent.errorMessage,
errorStack: errorEvent.errorStack,
});
this.addPendingMetric(MetricsAggregator.errorMetric(errorEvent, details));
}
this.statementMetrics.delete(id);
this.evictedStatements += 1;
Expand All @@ -366,16 +371,7 @@ export default class MetricsAggregator {
for (const [id, details] of this.statementMetrics) {
if (details.startTime < cutoff) {
for (const errorEvent of details.errors) {
this.addPendingMetric({
metricType: 'error',
timestamp: errorEvent.timestamp,
sessionId: details.sessionId,
statementId: details.statementId,
workspaceId: details.workspaceId,
errorName: errorEvent.errorName,
errorMessage: errorEvent.errorMessage,
errorStack: errorEvent.errorStack,
});
this.addPendingMetric(MetricsAggregator.errorMetric(errorEvent, details));
}
this.statementMetrics.delete(id);
evicted += 1;
Expand Down Expand Up @@ -476,17 +472,7 @@ export default class MetricsAggregator {
this.addPendingMetric(metric);

for (const errorEvent of details.errors) {
const errorMetric: TelemetryMetric = {
metricType: 'error',
timestamp: errorEvent.timestamp,
sessionId: details.sessionId,
statementId: details.statementId,
workspaceId: details.workspaceId,
errorName: errorEvent.errorName,
errorMessage: errorEvent.errorMessage,
errorStack: errorEvent.errorStack,
};
this.addPendingMetric(errorMetric);
this.addPendingMetric(MetricsAggregator.errorMetric(errorEvent, details));
}

this.statementMetrics.delete(statementId);
Expand Down
31 changes: 14 additions & 17 deletions lib/telemetry/TelemetryEventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { EventEmitter } from 'events';
import IClientContext from '../contracts/IClientContext';
import { LogLevel } from '../contracts/IDBSQLLogger';
import { TelemetryEvent, TelemetryEventType, DriverConfiguration } from './types';
import { redactSensitive } from './telemetryUtils';
import { sanitizeErrorName, sanitizeSqlState } from './telemetryUtils';

/**
* Typed map of event-type → listener payload shape. Keeps `on`/`off` calls
Expand Down Expand Up @@ -135,6 +135,7 @@ export default class TelemetryEventEmitter extends EventEmitter {
sessionId?: string;
latencyMs?: number;
resultFormat?: string;
compressed?: boolean;
chunkCount?: number;
bytesDownloaded?: number;
pollCount?: number;
Expand All @@ -146,6 +147,7 @@ export default class TelemetryEventEmitter extends EventEmitter {
sessionId: data.sessionId,
latencyMs: data.latencyMs,
resultFormat: data.resultFormat,
compressed: data.compressed,
chunkCount: data.chunkCount,
bytesDownloaded: data.bytesDownloaded,
pollCount: data.pollCount,
Expand All @@ -171,24 +173,20 @@ export default class TelemetryEventEmitter extends EventEmitter {
}

/**
* Emit an error event.
*
* Redaction happens HERE — not at the exporter — so any in-process listener
* (this class extends `EventEmitter` and `getTelemetryEmitter()` is reachable
* from any consumer of `@databricks/sql`) sees the same redacted strings
* the export pipeline does. `redactSensitive` strips Bearer/Basic, Databricks
* token prefixes, JWTs, JSON-encoded secrets, URL userinfo, and common
* username-bearing filesystem paths, then caps length.
*
* `errorMessage` is also redacted, not only `errorStack` — operation error
* messages can carry query fragments, table names, parameter values that the
* SECRET_PATTERNS regex must scrub before they're emitted anywhere.
* Emit an error event carrying only a sanitized descriptor: the error class
* name and the server SQLSTATE. Error messages and stacks quote SQL
* identifiers and literals, so they are never emitted — sanitizing here (not
* at the exporter) keeps in-process listeners on `getTelemetryEmitter()`
* equally clean.
*/
emitError(data: {
statementId?: string;
sessionId?: string;
errorName: string;
errorMessage: string;
sqlState?: string;
/** @deprecated Ignored; error messages are never emitted. */
errorMessage?: string;
/** @deprecated Ignored; error stacks are never emitted. */
errorStack?: string;
isTerminal: boolean;
}): void {
Expand All @@ -197,9 +195,8 @@ export default class TelemetryEventEmitter extends EventEmitter {
timestamp: Date.now(),
statementId: data.statementId,
sessionId: data.sessionId,
errorName: data.errorName,
errorMessage: redactSensitive(data.errorMessage),
errorStack: data.errorStack === undefined ? undefined : redactSensitive(data.errorStack),
errorName: sanitizeErrorName(data.errorName) ?? 'Error',
sqlState: sanitizeSqlState(data.sqlState),
isTerminal: data.isTerminal,
}));
}
Expand Down
36 changes: 36 additions & 0 deletions lib/telemetry/telemetryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,42 @@ export function redactSensitive(value: string | undefined, maxLen = 2048): strin
return redacted;
}

// A class-like identifier, optionally two words (e.g. `StatusError`'s 'Status Error').
const ERROR_NAME_PATTERN = /^[A-Za-z_$][\w$]*(?: [A-Za-z_$][\w$]*)?$/;

const SQL_STATE_PATTERN = /^[0-9A-Z]{5}$/;

/**
* Returns `name` when it looks like an error class name, otherwise `undefined`.
* Error telemetry carries only this and the SQLSTATE — never a message or stack,
* which routinely quote SQL identifiers and literals.
*/
export function sanitizeErrorName(name: unknown): string | undefined {
return typeof name === 'string' && name.length <= 64 && ERROR_NAME_PATTERN.test(name) ? name : undefined;
}

/** Returns `sqlState` when it is a well-formed 5-character SQLSTATE, otherwise `undefined`. */
export function sanitizeSqlState(sqlState: unknown): string | undefined {
return typeof sqlState === 'string' && SQL_STATE_PATTERN.test(sqlState) ? sqlState : undefined;
}

/**
* Sanitized telemetry descriptor for a thrown value: its class name and
* SQLSTATE. `HiveDriverError` subclasses inherit `name === 'Error'`, so the
* constructor name is used for them.
*/
export function describeErrorForTelemetry(error: unknown): { errorName: string; sqlState?: string } {
const candidate = error as { name?: unknown; sqlState?: unknown } | null | undefined;
let name = candidate?.name;
if ((name === undefined || name === '' || name === 'Error') && typeof error === 'object' && error !== null) {
name = error.constructor?.name;
}
return {
errorName: sanitizeErrorName(name) ?? 'Error',
sqlState: sanitizeSqlState(candidate?.sqlState),
};
}

/**
* Normalises any `HeadersInit` shape (`Headers`, `[string,string][]`, or
* `Record<string, string>`) into a plain string dictionary. Non-string
Expand Down
Loading
Loading