Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
db640fa
Add telemetry infrastructure: CircuitBreaker and FeatureFlagCache
samikshya-db Jan 28, 2026
211f91c
Add authentication support for REST API calls
samikshya-db Jan 29, 2026
c3779af
Fix feature flag and telemetry export endpoints
samikshya-db Jan 29, 2026
a105777
Match JDBC telemetry payload format
samikshya-db Jan 29, 2026
1cdb716
Fix lint errors
samikshya-db Jan 29, 2026
8721993
Add telemetry infrastructure: CircuitBreaker and FeatureFlagCache
samikshya-db Jan 28, 2026
fd10a69
Add telemetry client management: TelemetryClient and Provider
samikshya-db Jan 28, 2026
dd2bac8
Add authentication support for REST API calls
samikshya-db Jan 29, 2026
4437ae9
Fix feature flag and telemetry export endpoints
samikshya-db Jan 29, 2026
e9c3138
Match JDBC telemetry payload format
samikshya-db Jan 29, 2026
32003e9
Fix lint errors
samikshya-db Jan 29, 2026
689e561
Add missing getAuthHeaders method to ClientContextStub
samikshya-db Jan 29, 2026
4df6ce0
Add missing getAuthHeaders method to ClientContextStub
samikshya-db Jan 29, 2026
2d41e2d
Fix prettier formatting
samikshya-db Jan 29, 2026
589e062
Fix prettier formatting
samikshya-db Jan 29, 2026
e474256
Add DRIVER_NAME constant for nodejs-sql-driver
samikshya-db Jan 30, 2026
d7d2cec
Add DRIVER_NAME constant for nodejs-sql-driver
samikshya-db Jan 30, 2026
a3c9042
Add missing telemetry fields to match JDBC
samikshya-db Jan 30, 2026
5b47e7e
Add missing telemetry fields to match JDBC
samikshya-db Jan 30, 2026
6110797
Fix TypeScript compilation: add missing fields to system_configuratio…
samikshya-db Jan 30, 2026
7c5c16c
Fix TypeScript compilation: add missing fields to system_configuratio…
samikshya-db Jan 30, 2026
f9bd330
Merge branch 'telemetry-2-infrastructure' into telemetry-3-client-man…
samikshya-db Jan 30, 2026
42540bc
Fix telemetry PR review comments from #325
samikshya-db Feb 5, 2026
99eb2ab
Merge PR #325 fixes into PR #326
samikshya-db Feb 5, 2026
11590f3
Add proxy support to feature flag fetching
samikshya-db Feb 5, 2026
bda2cac
Merge telemetry-2-infrastructure proxy fix into telemetry-3-client-ma…
samikshya-db Feb 5, 2026
fce4499
Merge latest main into telemetry-3-client-management
samikshya-db Mar 5, 2026
de17f1b
Merge latest main into telemetry-2-infrastructure
samikshya-db Mar 5, 2026
363e2fc
Merge branch 'telemetry-2-infrastructure' into telemetry-3-client-man…
samikshya-db Apr 2, 2026
38e9d03
Merge main into telemetry-3-client-management; prefer main versions f…
samikshya-db Apr 21, 2026
e81ad15
Merge main — take main's DatabricksTelemetryExporter, FeatureFlagCach…
samikshya-db Apr 21, 2026
d009957
fix: prettier formatting and add coverage/ to .prettierignore
samikshya-db Apr 21, 2026
990ec15
fix: delete host entry before awaiting close to prevent stale-client …
samikshya-db Apr 21, 2026
2749751
refactor: simplify TelemetryClient.close() and TelemetryClientProvider
samikshya-db Apr 21, 2026
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 .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
.nyc_output
coverage_e2e
coverage_unit
coverage
.clinic

dist
Expand Down
65 changes: 65 additions & 0 deletions lib/telemetry/TelemetryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Databricks Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import IClientContext from '../contracts/IClientContext';
import { LogLevel } from '../contracts/IDBSQLLogger';

/**
* Telemetry client for a specific host.
* Managed by TelemetryClientProvider with reference counting.
* One client instance is shared across all connections to the same host.
*/
class TelemetryClient {
private closed: boolean = false;

constructor(private context: IClientContext, private host: string) {
const logger = context.getLogger();
logger.log(LogLevel.debug, `Created TelemetryClient for host: ${host}`);
}

/**
* Gets the host associated with this client.
*/
getHost(): string {
return this.host;
}

/**
* Checks if the client has been closed.
*/
isClosed(): boolean {
return this.closed;
}

/**
* Closes the telemetry client and releases resources.
* Should only be called by TelemetryClientProvider when reference count reaches zero.
*/
close(): void {
if (this.closed) {
return;
}
try {
this.context.getLogger().log(LogLevel.debug, `Closing TelemetryClient for host: ${this.host}`);
} catch {
// swallow
} finally {
this.closed = true;
}
}
}

export default TelemetryClient;
131 changes: 131 additions & 0 deletions lib/telemetry/TelemetryClientProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (c) 2025 Databricks Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import IClientContext from '../contracts/IClientContext';
import { LogLevel } from '../contracts/IDBSQLLogger';
import TelemetryClient from './TelemetryClient';

/**
* Holds a telemetry client and its reference count.
* The reference count tracks how many connections are using this client.
*/
interface TelemetryClientHolder {
client: TelemetryClient;
refCount: number;
}

/**
* Manages one telemetry client per host.
* Prevents rate limiting by sharing clients across connections to the same host.
* Instance-based (not singleton), stored in DBSQLClient.
*
* Reference counts are incremented synchronously so there are no async races
* on the count itself. The map entry is deleted before awaiting close() so a
* concurrent getOrCreateClient call always gets a fresh instance.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F10] "deleted before awaiting close()" comment is false — close() is sync — Severity: Medium

The header comment at lines 35-37 says:

Reference counts are incremented synchronously so there are no async races on the count itself. The map entry is deleted before awaiting close() so a concurrent getOrCreateClient call always gets a fresh instance.

And the inline comment at 96-98 says:

Delete from map before awaiting close so a concurrent getOrCreateClient creates a fresh client rather than receiving this closing one.

Both describe a concurrency model the code isn't implementing: TelemetryClient.close() is sync (TelemetryClient.ts:51), and holder.client.close() is called synchronously right after this.clients.delete(host). Nothing is awaited. When close() becomes async in [5/7] (HTTP flush / abort / drain), the code will still not await — and the race the comment claims is handled will actually materialize: new getOrCreateClient returns a fresh instance while the old one's async close is still flushing.

Fix: Either

  1. Make close() return Promise<void> now; change releaseClient to async; await holder.client.close() after the map delete. Callers (including [5/7]) can be written to the final shape today.
  2. Or correct the comments to describe the current sync semantics — drop the "awaiting" language, note the async-close invariant must be re-established when close() becomes async.

Posted by code-review-squad • flagged by devils-advocate, architecture, language, maintainability.

*/
class TelemetryClientProvider {
private clients: Map<string, TelemetryClientHolder>;

constructor(private context: IClientContext) {
this.clients = new Map();
const logger = context.getLogger();
logger.log(LogLevel.debug, 'Created TelemetryClientProvider');
}

/**
* Gets or creates a telemetry client for the specified host.
* Increments the reference count for the client.
*
* @param host The host identifier (e.g., "workspace.cloud.databricks.com")
* @returns The telemetry client for the host
*/
getOrCreateClient(host: string): TelemetryClient {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F6] Host used as map key without normalization — alias confusion + unbounded growth — Severity: Medium

getOrCreateClient(host) and releaseClient(host) use the raw string as the Map key. example.com, https://example.com, Example.COM, example.com:443, example.com/, example.com. (trailing dot), and zero-width-padded variants all create distinct entries for one logical host. This:

  • Defeats the stated "share clients across connections to the same host" purpose when callers are inconsistent.
  • Leaks memory under host-variant input (long-running daemons iterating over per-env warehouses).
  • Orphans holders whose refcount never reaches zero (get under one form, release under another).

No size cap either — no upper bound on how big clients can grow.

Fix:

  1. Normalize on entry (lowercase, strip protocol, strip default port, strip trailing slash/dot, reject whitespace/zero-width/invalid). buildTelemetryUrl in telemetryUtils.ts already does the parse+validate — reuse it.
  2. Use the canonical form as the map key; keep raw input only for logging.
  3. Add a bounded-size guard (e.g., warn-log once at a soft threshold of 128).

Posted by code-review-squad • flagged by security, ops.

const logger = this.context.getLogger();
let holder = this.clients.get(host);

if (!holder) {
// Create new client for this host
const client = new TelemetryClient(this.context, host);
holder = {
client,
refCount: 0,
};
this.clients.set(host, holder);
logger.log(LogLevel.debug, `Created new TelemetryClient for host: ${host}`);
}

// Increment reference count
holder.refCount += 1;
logger.log(LogLevel.debug, `TelemetryClient reference count for ${host}: ${holder.refCount}`);

return holder.client;
}

/**
* Releases a telemetry client for the specified host.
* Decrements the reference count and closes the client when it reaches zero.
*
* @param host The host identifier
*/
releaseClient(host: string): void {
const logger = this.context.getLogger();
const holder = this.clients.get(host);

if (!holder) {
logger.log(LogLevel.debug, `No TelemetryClient found for host: ${host}`);
return;
}

// Decrement reference count
holder.refCount -= 1;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F5] Refcount underflow is silent; double-release swaps a live client — Severity: High

holder.refCount -= 1 then if (<= 0) has no guard. A double-release (caller finally-path bug, mismatched get/release convention) drives the count to -1 and trips the close+delete branch while another legitimate caller still holds a reference. The next getOrCreateClient(host) creates a fresh instance; the original caller is now operating on a closed object. Once [5/7] adds real HTTP, writes will silently no-op or throw on the stale reference.

Also: header comment at lines 35-37 says "The map entry is deleted before awaiting close() so a concurrent getOrCreateClient call always gets a fresh instance." — but close() is sync (TelemetryClient.ts:51). Nothing is awaited. The comment describes a concurrency model the code isn't implementing.

Fix:

if (holder.refCount <= 0) {
  logger.log(LogLevel.warn, `Unbalanced release for ${host}`);
  return;
}
holder.refCount -= 1;

And either make close() return Promise<void> now and await it after the delete, or rewrite the "before awaiting close()" comment to match the current sync semantics.

Add tests:

  • should not throw or corrupt state when release called more times than get
  • getOrCreateClient after full release returns a fresh non-closed client

Posted by code-review-squad • flagged by devils-advocate, ops, security, architecture, language, test.

logger.log(LogLevel.debug, `TelemetryClient reference count for ${host}: ${holder.refCount}`);

// Close and remove client when reference count reaches zero.
// Delete from map before awaiting close so a concurrent getOrCreateClient
// creates a fresh client rather than receiving this closing one.
if (holder.refCount <= 0) {
this.clients.delete(host);
try {
holder.client.close();
logger.log(LogLevel.debug, `Closed and removed TelemetryClient for host: ${host}`);
} catch (error: any) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[F9] catch (error: any) + error.message crashes on non-Error throws — Severity: Medium

throw null / throw undefined / throw "string" cause TypeError on .message access, escaping the "Swallow all exceptions per requirement" contract claimed in the comment.

Also: catch (error: any) annotation is non-idiomatic in this codebase — every other catch is untyped (see DBSQLClient.ts:253, OAuthManager.ts:133, FederationProvider.ts:106, BaseCommand.ts:19,65). tsconfig doesn't enable useUnknownInCatchVariables so the default already makes error behave as any.

Fix:

} catch (error) {
  const msg = error instanceof Error ? error.message : String(error);
  logger.log(LogLevel.debug, `Error releasing TelemetryClient: ${msg}`);
}

Same fix applies to TelemetryClient.ts:55-61.

Posted by code-review-squad • flagged by devils-advocate, security, language.

// Swallow all exceptions per requirement
logger.log(LogLevel.debug, `Error releasing TelemetryClient: ${error.message}`);
}
}
}

/**
* @internal Exposed for testing only.
*/
getRefCount(host: string): number {
const holder = this.clients.get(host);
return holder ? holder.refCount : 0;
}

/**
* @internal Exposed for testing only.
*/
getActiveClients(): Map<string, TelemetryClient> {
const result = new Map<string, TelemetryClient>();
for (const [host, holder] of this.clients.entries()) {
result.set(host, holder.client);
}
return result;
}
}

export default TelemetryClientProvider;
31 changes: 31 additions & 0 deletions lib/telemetry/urlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Databricks Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Build full URL from host and path, handling protocol correctly.
* @param host The hostname (with or without protocol)
* @param path The path to append (should start with /)
* @returns Full URL with protocol
*/
// eslint-disable-next-line import/prefer-default-export
export function buildUrl(host: string, path: string): string {
// Check if host already has protocol
if (host.startsWith('http://') || host.startsWith('https://')) {
return `${host}${path}`;
}
// Add https:// if no protocol present
return `https://${host}${path}`;
}
155 changes: 155 additions & 0 deletions tests/unit/telemetry/TelemetryClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Copyright (c) 2025 Databricks Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect } from 'chai';
import sinon from 'sinon';
import TelemetryClient from '../../../lib/telemetry/TelemetryClient';
import ClientContextStub from '../.stubs/ClientContextStub';
import { LogLevel } from '../../../lib/contracts/IDBSQLLogger';

describe('TelemetryClient', () => {
const HOST = 'workspace.cloud.databricks.com';

describe('Constructor', () => {
it('should create client with host', () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

expect(client.getHost()).to.equal(HOST);
expect(client.isClosed()).to.be.false;
});

it('should log creation at debug level', () => {
const context = new ClientContextStub();
const logSpy = sinon.spy(context.logger, 'log');

new TelemetryClient(context, HOST);

expect(logSpy.calledWith(LogLevel.debug, `Created TelemetryClient for host: ${HOST}`)).to.be.true;
});
});

describe('getHost', () => {
it('should return the host identifier', () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

expect(client.getHost()).to.equal(HOST);
});
});

describe('isClosed', () => {
it('should return false initially', () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

expect(client.isClosed()).to.be.false;
});

it('should return true after close', async () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

client.close();

expect(client.isClosed()).to.be.true;
});
});

describe('close', () => {
it('should set closed flag', async () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

client.close();

expect(client.isClosed()).to.be.true;
});

it('should log closure at debug level', async () => {
const context = new ClientContextStub();
const logSpy = sinon.spy(context.logger, 'log');
const client = new TelemetryClient(context, HOST);

client.close();

expect(logSpy.calledWith(LogLevel.debug, `Closing TelemetryClient for host: ${HOST}`)).to.be.true;
});

it('should be idempotent', async () => {
const context = new ClientContextStub();
const logSpy = sinon.spy(context.logger, 'log');
const client = new TelemetryClient(context, HOST);

client.close();
const firstCallCount = logSpy.callCount;

client.close();

// Should not log again on second close
expect(logSpy.callCount).to.equal(firstCallCount);
expect(client.isClosed()).to.be.true;
});

it('should swallow all exceptions', async () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

// Force an error by stubbing the logger
const error = new Error('Logger error');
sinon.stub(context.logger, 'log').throws(error);

// Should not throw
client.close();
// If we get here without throwing, the test passes
expect(true).to.be.true;
});

it('should still set closed when logger throws', () => {
const context = new ClientContextStub();
const client = new TelemetryClient(context, HOST);

sinon.stub(context.logger, 'log').throws(new Error('Logger error'));

client.close();

expect(client.isClosed()).to.be.true;
});
});

describe('Context usage', () => {
it('should use logger from context', () => {
const context = new ClientContextStub();
const logSpy = sinon.spy(context.logger, 'log');

new TelemetryClient(context, HOST);

expect(logSpy.called).to.be.true;
});

it('should log all messages at debug level only', async () => {
const context = new ClientContextStub();
const logSpy = sinon.spy(context.logger, 'log');
const client = new TelemetryClient(context, HOST);

client.close();

logSpy.getCalls().forEach((call) => {
expect(call.args[0]).to.equal(LogLevel.debug);
});
});
});
});
Loading
Loading