Skip to content

Commit d72104e

Browse files
os-zhuangclaude
andauthored
fix(rest): resolve the ENVIRONMENT's analytics service for dataset queries (#1707)
The /analytics/dataset/query route resolved its service through the host provider, which ignores environmentId and returns the HOST kernel's analytics — whose strategy bridges are bound to the host data engine. A tenant's dataset query therefore aggregated over the host's (empty) tables: totals of 0 and empty groups on populated objects, while CRUD (per-env protocol resolution) saw the real data. - Extract resolveProtocol's env resolution (hostname → X-Environment-Id → single-project default) into resolveRequestEnvironmentId, shared so per-environment service resolutions can never disagree about a request's kernel. - The analytics route now resolves the env kernel's own 'analytics' service first (its bridges bind the env engine) and only falls back to the host provider. rest: 100 tests green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce9b6eb commit d72104e

1 file changed

Lines changed: 39 additions & 12 deletions

File tree

packages/rest/src/rest-server.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,14 +600,21 @@ export class RestServer {
600600
return result;
601601
}
602602

603-
private async resolveProtocol(environmentId?: string, req?: any): Promise<ObjectStackProtocol> {
604-
if (environmentId === 'platform') return this.protocol;
605-
if (!environmentId && req && this.envRegistry && this.kernelManager) {
603+
/**
604+
* Resolve the environment a request targets: explicit id → tenant hostname
605+
* → `X-Environment-Id` header → single-project default. Returns undefined
606+
* for control-plane requests. Shared by every per-environment service
607+
* resolution (protocol, analytics, …) so they can never disagree about
608+
* which kernel a request belongs to.
609+
*/
610+
private async resolveRequestEnvironmentId(environmentId?: string, req?: any): Promise<string | undefined> {
611+
if (environmentId) return environmentId;
612+
if (req && this.envRegistry && this.kernelManager) {
606613
const host = this.extractHostname(req);
607614
if (host) {
608615
try {
609616
const result = await this.resolveHostnameCached(host);
610-
if (result?.environmentId) environmentId = result.environmentId;
617+
if (result?.environmentId) return result.environmentId;
611618
} catch {
612619
// fall through to next strategy
613620
}
@@ -618,12 +625,12 @@ export class RestServer {
618625
// serving multiple compiled bundles via OS_PROJECT_ARTIFACTS).
619626
// We validate the id through the env registry to avoid
620627
// routing to a non-existent kernel.
621-
if (!environmentId && typeof this.envRegistry.resolveById === 'function') {
628+
if (typeof this.envRegistry.resolveById === 'function') {
622629
const headerVal = this.extractProjectIdHeader(req);
623630
if (headerVal) {
624631
try {
625632
const driver = await this.envRegistry.resolveById(headerVal);
626-
if (driver) environmentId = headerVal;
633+
if (driver) return headerVal;
627634
} catch {
628635
// fall through to default fallback
629636
}
@@ -635,14 +642,20 @@ export class RestServer {
635642
// (no `/projects/<id>` prefix, no hostname mapping, no header)
636643
// resolve to the lone project's kernel rather than the control
637644
// plane.
638-
if (!environmentId && this.defaultEnvironmentIdProvider) {
645+
if (this.defaultEnvironmentIdProvider) {
639646
try {
640647
const def = this.defaultEnvironmentIdProvider();
641-
if (def) environmentId = def;
648+
if (def) return def;
642649
} catch { /* fall through */ }
643650
}
644-
if (!environmentId || !this.kernelManager) return this.protocol;
645-
const kernel = await this.kernelManager.getOrCreate(environmentId);
651+
return undefined;
652+
}
653+
654+
private async resolveProtocol(environmentId?: string, req?: any): Promise<ObjectStackProtocol> {
655+
if (environmentId === 'platform') return this.protocol;
656+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
657+
if (!envId || !this.kernelManager) return this.protocol;
658+
const kernel = await this.kernelManager.getOrCreate(envId);
646659
return kernel.getServiceAsync<ObjectStackProtocol>('protocol');
647660
}
648661

@@ -3589,7 +3602,21 @@ export class RestServer {
35893602
*/
35903603
private registerAnalyticsEndpoints(basePath: string): void {
35913604
const isScoped = basePath.includes('/environments/:environmentId');
3592-
const resolveService = async (environmentId?: string) => {
3605+
// Resolve the ENVIRONMENT's analytics service first — its strategy
3606+
// bridges are bound to the env kernel's own data engine. The host
3607+
// provider (whose 'data' is the host kernel's engine) is only a
3608+
// fallback: serving a tenant's dataset query from the host engine
3609+
// reads the WRONG database and silently aggregates over nothing
3610+
// (the staging "Total Spend: 0 on a populated table" incident).
3611+
const resolveService = async (environmentId?: string, req?: any) => {
3612+
try {
3613+
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
3614+
if (envId && envId !== 'platform' && this.kernelManager) {
3615+
const kernel = await this.kernelManager.getOrCreate(envId);
3616+
const svc = await kernel.getServiceAsync<any>('analytics').catch(() => undefined);
3617+
if (svc) return svc;
3618+
}
3619+
} catch { /* fall back to the host service */ }
35933620
if (!this.analyticsServiceProvider) return undefined;
35943621
try { return await this.analyticsServiceProvider(environmentId); }
35953622
catch { return undefined; }
@@ -3604,7 +3631,7 @@ export class RestServer {
36043631
const context = await this.resolveExecCtx(environmentId, req);
36053632
if (this.enforceAuth(req, res, context)) return;
36063633

3607-
const svc = await resolveService(environmentId);
3634+
const svc = await resolveService(environmentId, req);
36083635
if (!svc || typeof svc.queryDataset !== 'function') {
36093636
return res.status(501).json({
36103637
code: 'NOT_IMPLEMENTED',

0 commit comments

Comments
 (0)