Skip to content

Commit e30d820

Browse files
committed
Add SPOG support: x-databricks-org-id header for telemetry + feature-flag
SPOG (Single Panel of Glass) replaces workspace-specific hostnames with account-level vanity URLs. When httpPath carries `?o=<workspaceId>`, endpoints that don't include the workspace in their URL path (telemetry, feature flags) need the workspace conveyed via the `x-databricks-org-id` header instead. Changes: - Parse `?o=<digits>` out of httpPath in DBSQLClient.connect() and stash the org-id as `x-databricks-org-id` on a new `ClientConfig.customHeaders` field. A user-supplied `customHeaders` entry (case-insensitive) takes precedence. - DatabricksTelemetryExporter spreads `config.customHeaders` into the outgoing POST headers. Auth headers still win on collision. - FeatureFlagCache does the same for the feature-flag GET. Not applicable to this driver (vs JDBC port in databricks/databricks-jdbc#1316): - httpPath property parser fix — Node.js passes `options.path` through unmodified. - Warehouse ID regex fix for SEA — driver uses Thrift only. - DBFS Volume header injection — driver exposes no Volume API. OAuth/OIDC token requests deliberately do NOT receive customHeaders. Co-authored-by: Isaac
1 parent 5f1728a commit e30d820

8 files changed

Lines changed: 184 additions & 0 deletions

File tree

lib/DBSQLClient.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,27 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I
330330
return match ? match[1] : undefined;
331331
}
332332

333+
/**
334+
* Build the customHeaders map applied to telemetry POSTs and feature-flag
335+
* GETs (SPOG / Single Panel of Glass support). When `httpPath` carries
336+
* `?o=<workspaceId>` — account-level vanity URL routing — endpoints that
337+
* don't include the workspace in their path need the workspace conveyed via
338+
* the `x-databricks-org-id` header instead. A user-supplied value in
339+
* `options.customHeaders` (case-insensitively keyed) wins over the parsed
340+
* value.
341+
*/
342+
private buildCustomHeaders(options: ConnectionOptions): Record<string, string> | undefined {
343+
const merged: Record<string, string> = { ...(options.customHeaders ?? {}) };
344+
const hasOrgIdAlready = Object.keys(merged).some((k) => k.toLowerCase() === 'x-databricks-org-id');
345+
if (!hasOrgIdAlready) {
346+
const orgId = this.extractWorkspaceId();
347+
if (orgId) {
348+
merged['x-databricks-org-id'] = orgId;
349+
}
350+
}
351+
return Object.keys(merged).length > 0 ? merged : undefined;
352+
}
353+
333354
/**
334355
* Build driver configuration for telemetry reporting.
335356
* @returns DriverConfiguration object with current driver settings
@@ -561,6 +582,11 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I
561582
this.config.userAgentEntry = options.userAgentEntry;
562583
}
563584

585+
// SPOG: parse `?o=<workspaceId>` out of httpPath and stash it as
586+
// `x-databricks-org-id` for the telemetry + feature-flag clients, which
587+
// hit endpoints that don't carry the workspace in their URL path.
588+
this.config.customHeaders = this.buildCustomHeaders(options);
589+
564590
this.authProvider = this.createAuthProvider(options, authProvider);
565591

566592
this.connectionProvider = this.createConnectionProvider(options);

lib/contracts/IClientContext.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ export interface ClientConfig {
5252
*/
5353
telemetryFlushOnExit?: boolean;
5454
userAgentEntry?: string;
55+
56+
/**
57+
* Extra HTTP headers attached to driver-owned out-of-band requests
58+
* (telemetry, feature flags). Populated by `DBSQLClient.connect()` from
59+
* `ConnectionOptions.customHeaders` plus an `x-databricks-org-id` header
60+
* derived from the `?o=` query parameter on `httpPath` when present, to
61+
* support SPOG (Single Panel of Glass) account-level routing on endpoints
62+
* that don't carry `?o=` in their URL path. NOT applied to Thrift or
63+
* OAuth/OIDC requests.
64+
*/
65+
customHeaders?: Record<string, string>;
5566
}
5667

5768
export default interface IClientContext {

lib/contracts/IDBSQLClient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export type ConnectionOptions = {
5555
proxy?: ProxyOptions;
5656
enableMetricViewMetadata?: boolean;
5757

58+
/**
59+
* Extra HTTP headers attached to driver-owned out-of-band requests
60+
* (telemetry POSTs and feature-flag GETs). Not applied to the primary
61+
* Thrift transport or to OAuth/OIDC token requests.
62+
*
63+
* When `path` contains `?o=<workspaceId>` (SPOG account-level routing),
64+
* the driver automatically injects an `x-databricks-org-id` header unless
65+
* one is already present in this map.
66+
*/
67+
customHeaders?: Record<string, string>;
68+
5869
/**
5970
* Whether the driver emits telemetry events (connection / statement /
6071
* cloud-fetch / error). Defaults to `true`.

lib/telemetry/DatabricksTelemetryExporter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export default class DatabricksTelemetryExporter {
249249
let headers: Record<string, string> = {
250250
'Content-Type': 'application/json',
251251
'User-Agent': userAgent,
252+
...(config.customHeaders ?? {}),
252253
};
253254

254255
if (authenticatedExport) {

lib/telemetry/FeatureFlagCache.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export default class FeatureFlagCache {
140140
const headers: Record<string, string> = {
141141
'Content-Type': 'application/json',
142142
'User-Agent': this.userAgent,
143+
...(this.context.getConfig().customHeaders ?? {}),
143144
...(await this.getAuthHeaders()),
144145
};
145146

tests/unit/DBSQLClient.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ describe('DBSQLClient.connect', () => {
103103

104104
logSpy.restore();
105105
});
106+
107+
it('populates config.customHeaders with org-id parsed from ?o= (SPOG)', async () => {
108+
const client = new DBSQLClient();
109+
await client.connect({ ...connectOptions, path: '/sql/1.0/warehouses/abc?o=12345678901234' });
110+
expect(client.getConfig().customHeaders).to.deep.equal({ 'x-databricks-org-id': '12345678901234' });
111+
});
112+
113+
it('leaves config.customHeaders undefined when path has no ?o= and none supplied', async () => {
114+
const client = new DBSQLClient();
115+
await client.connect({ ...connectOptions, path: '/sql/1.0/warehouses/abc' });
116+
expect(client.getConfig().customHeaders).to.be.undefined;
117+
});
106118
});
107119

108120
describe('DBSQLClient.openSession', () => {
@@ -785,6 +797,49 @@ describe('DBSQLClient telemetry paths', () => {
785797
});
786798
});
787799

800+
describe('buildCustomHeaders (SPOG)', () => {
801+
it('injects x-databricks-org-id from ?o= in httpPath', () => {
802+
const client = new DBSQLClient();
803+
(client as any).httpPath = '/sql/1.0/warehouses/abc?o=12345678901234';
804+
const headers = (client as any).buildCustomHeaders({ path: '/sql/1.0/warehouses/abc?o=12345678901234' });
805+
expect(headers).to.deep.equal({ 'x-databricks-org-id': '12345678901234' });
806+
});
807+
808+
it('returns undefined when no ?o= and no user-supplied customHeaders', () => {
809+
const client = new DBSQLClient();
810+
(client as any).httpPath = '/sql/1.0/warehouses/abc';
811+
const headers = (client as any).buildCustomHeaders({ path: '/sql/1.0/warehouses/abc' });
812+
expect(headers).to.be.undefined;
813+
});
814+
815+
it('preserves user-supplied customHeaders alongside parsed org-id', () => {
816+
const client = new DBSQLClient();
817+
(client as any).httpPath = '/sql/1.0/warehouses/abc?o=42';
818+
const headers = (client as any).buildCustomHeaders({
819+
path: '/sql/1.0/warehouses/abc?o=42',
820+
customHeaders: { 'x-trace-id': 'tid-001' },
821+
});
822+
expect(headers).to.deep.equal({ 'x-trace-id': 'tid-001', 'x-databricks-org-id': '42' });
823+
});
824+
825+
it('user-supplied x-databricks-org-id wins over ?o= parsed value (case-insensitive)', () => {
826+
const client = new DBSQLClient();
827+
(client as any).httpPath = '/sql/1.0/warehouses/abc?o=42';
828+
const headers = (client as any).buildCustomHeaders({
829+
path: '/sql/1.0/warehouses/abc?o=42',
830+
customHeaders: { 'X-Databricks-Org-Id': '999' },
831+
});
832+
expect(headers).to.deep.equal({ 'X-Databricks-Org-Id': '999' });
833+
});
834+
835+
it('does not inject org-id when ?o= value is non-numeric', () => {
836+
const client = new DBSQLClient();
837+
(client as any).httpPath = '/sql/1.0/warehouses/abc?o=tenant_xyz';
838+
const headers = (client as any).buildCustomHeaders({ path: '/sql/1.0/warehouses/abc?o=tenant_xyz' });
839+
expect(headers).to.be.undefined;
840+
});
841+
});
842+
788843
describe('telemetry refcount on reconnect', () => {
789844
it('releases the prior refcount when connect() is called twice', async () => {
790845
const client = new DBSQLClient();

tests/unit/telemetry/DatabricksTelemetryExporter.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,46 @@ describe('DatabricksTelemetryExporter', () => {
120120
}
121121
expect(threw).to.be.false;
122122
});
123+
124+
it('should attach config.customHeaders to the POST (SPOG)', async () => {
125+
const context = new ClientContextStub({
126+
customHeaders: { 'x-databricks-org-id': '12345678901234' },
127+
} as any);
128+
const registry = new CircuitBreakerRegistry(context);
129+
const exporter = new DatabricksTelemetryExporter(context, 'host.example.com', registry, fakeAuthProvider);
130+
const sendRequestStub = sinon.stub(exporter as any, 'sendRequest').returns(makeOkResponse());
131+
132+
await exporter.export([makeMetric()]);
133+
134+
const init = sendRequestStub.firstCall.args[1] as { headers: Record<string, string> };
135+
expect(init.headers['x-databricks-org-id']).to.equal('12345678901234');
136+
});
137+
138+
it('auth headers win over customHeaders on key collision', async () => {
139+
const context = new ClientContextStub({
140+
customHeaders: { Authorization: 'Bearer not-the-real-token' },
141+
} as any);
142+
const registry = new CircuitBreakerRegistry(context);
143+
const exporter = new DatabricksTelemetryExporter(context, 'host.example.com', registry, fakeAuthProvider);
144+
const sendRequestStub = sinon.stub(exporter as any, 'sendRequest').returns(makeOkResponse());
145+
146+
await exporter.export([makeMetric()]);
147+
148+
const init = sendRequestStub.firstCall.args[1] as { headers: Record<string, string> };
149+
expect(init.headers.Authorization).to.equal('Bearer test-token');
150+
});
151+
152+
it('does not attach customHeaders when none are configured', async () => {
153+
const context = new ClientContextStub();
154+
const registry = new CircuitBreakerRegistry(context);
155+
const exporter = new DatabricksTelemetryExporter(context, 'host.example.com', registry, fakeAuthProvider);
156+
const sendRequestStub = sinon.stub(exporter as any, 'sendRequest').returns(makeOkResponse());
157+
158+
await exporter.export([makeMetric()]);
159+
160+
const init = sendRequestStub.firstCall.args[1] as { headers: Record<string, string> };
161+
expect(init.headers).to.not.have.property('x-databricks-org-id');
162+
});
123163
});
124164

125165
describe('export() - retry logic', () => {

tests/unit/telemetry/FeatureFlagCache.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,43 @@ describe('FeatureFlagCache', () => {
317317
fetchStub.restore();
318318
});
319319
});
320+
321+
describe('customHeaders propagation (SPOG)', () => {
322+
function makeJsonResponse(body: unknown) {
323+
return Promise.resolve({
324+
ok: true,
325+
status: 200,
326+
statusText: 'OK',
327+
json: () => Promise.resolve(body),
328+
text: () => Promise.resolve(''),
329+
});
330+
}
331+
332+
it('attaches config.customHeaders to the feature-flag GET', async () => {
333+
const context = new ClientContextStub({
334+
customHeaders: { 'x-databricks-org-id': '12345678901234' },
335+
} as any);
336+
const cache = new FeatureFlagCache(context);
337+
const stub = sinon.stub(cache as any, 'fetchWithRetry').returns(makeJsonResponse({ flags: [] }));
338+
339+
await (cache as any).fetchFeatureFlag('host.example.com');
340+
341+
expect(stub.calledOnce).to.be.true;
342+
const init = stub.firstCall.args[1] as { headers: Record<string, string> };
343+
expect(init.headers['x-databricks-org-id']).to.equal('12345678901234');
344+
stub.restore();
345+
});
346+
347+
it('does not set x-databricks-org-id when customHeaders is empty', async () => {
348+
const context = new ClientContextStub();
349+
const cache = new FeatureFlagCache(context);
350+
const stub = sinon.stub(cache as any, 'fetchWithRetry').returns(makeJsonResponse({ flags: [] }));
351+
352+
await (cache as any).fetchFeatureFlag('host.example.com');
353+
354+
const init = stub.firstCall.args[1] as { headers: Record<string, string> };
355+
expect(init.headers).to.not.have.property('x-databricks-org-id');
356+
stub.restore();
357+
});
358+
});
320359
});

0 commit comments

Comments
 (0)