Skip to content

Commit 1dc774d

Browse files
fix(observability): stamp tenant org on gateway traffic docs (G-GATEWAY-INDEX-ORG)
Insights → Analytics/Accounting read ZEROS while offgrid-gateway holds 2,899 real docs: those surfaces filter by term:{org} for tenant isolation, but the aggregator shipped docs with no org field, so the filter matched 0 (proven live: match_all → 2899 hits, term org=default → 0). Producer-side fix (the tenant filter is correct — dropping it would let one tenant's cost model count another's traffic): - aggregator reads x-offgrid-org off the request and stamps on the observability record (absent ⇒ null: honestly unattributed, never guessed). - new PURE gatewayAttribution({orgId,userId}) in lib/gateway.ts — one place that builds the attribution headers, so call sites stop hand-writing them. Blank values are DROPPED rather than sent empty (an empty header would attribute the call to org "", worse than honestly unattributed). - chat/stream (the interactive inference path, where orgId is already in scope) now sends both headers. 4 unit tests on the pure helper. Suite 5079 pass / 0 fail; coverage green. HONEST REMAINDER: agent/app-run inference still ships unattributed — gatewayAnswer threads but not orgId, so that needs the param threaded through the planner/compose signatures. Historical docs stay unattributed by design (guessing an org for past traffic would fabricate attribution).
1 parent 72a041b commit 1dc774d

4 files changed

Lines changed: 68 additions & 3 deletions

File tree

scripts/gateway-aggregator.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,11 @@ async function handle(req, res, chunks) {
593593
// Request-side context for the observability record (A + D above).
594594
const caller = String(req.headers['user-agent'] || '').slice(0, 80);
595595
const corrId = String(req.headers['x-offgrid-run'] || req.headers['x-request-id'] || '');
596+
// TENANT ORG attribution (G-GATEWAY-INDEX-ORG). The console's observability surfaces filter the
597+
// offgrid-gateway index by `term: { org }` for tenant isolation, so a doc with no `org` is
598+
// invisible to Insights → Analytics/Accounting. The console (which owns the request-scoped tenant)
599+
// sends it as x-offgrid-org; absent ⇒ null (honestly unattributed, never guessed).
600+
const org = String(req.headers['x-offgrid-org'] || '').trim().slice(0, 64) || null;
596601
// PA-15 — per-tenant gateway ATTRIBUTION. When this request arrived on a provisioned per-tenant
597602
// gateway host ("<slug5><rand5>-gateway.<apex>"), the tunnel/Caddy forwards the original host in
598603
// the Host header. Resolve it (pure) so the request is attributed to that tenant's gateway on the
@@ -697,12 +702,13 @@ async function handle(req, res, chunks) {
697702
tps, finish, toolCalls, reasoning: reasoning.slice(0, 2000), caller, corrId, params, msgs,
698703
tenantGateway: tenantGateway ? tenantGateway.label : null,
699704
tenantGatewayHost: tenantGateway ? inboundHost.toLowerCase() : null,
705+
org,
700706
input: promptText(body), output: output.slice(0, 2000),
701707
});
702708
});
703709
});
704710
up.on('error', (e) => {
705-
record({ gateway: target.name, model: body.model || target.model, modelServed: target.model, kind, status: 502, ms: Date.now() - started, bytes: 0, tokens: 0, caller, corrId, params, msgs, tenantGateway: tenantGateway ? tenantGateway.label : null, tenantGatewayHost: tenantGateway ? inboundHost.toLowerCase() : null, input: promptText(body), output: `(error: ${e.message})` });
711+
record({ gateway: target.name, model: body.model || target.model, modelServed: target.model, kind, status: 502, ms: Date.now() - started, bytes: 0, tokens: 0, caller, corrId, params, msgs, tenantGateway: tenantGateway ? tenantGateway.label : null, tenantGatewayHost: tenantGateway ? inboundHost.toLowerCase() : null, org, input: promptText(body), output: `(error: ${e.message})` });
706712
json(res, 502, { error: { message: `gateway ${target.name} (${target.host}) error: ${e.message}`, type: 'upstream_error' } });
707713
});
708714
up.setTimeout(Number(process.env.OFFGRID_GATEWAY_UPSTREAM_TIMEOUT_MS || 300000), () => up.destroy(new Error('upstream timeout')));

src/app/api/v1/chat/stream/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { DEFAULT_ORG } from '@/lib/tenancy-policy';
6161
export const dynamic = 'force-dynamic';
6262
export const maxDuration = 300;
6363

64-
import { GATEWAY_URL, gatewayHeaders } from '@/lib/gateway';
64+
import { GATEWAY_URL, gatewayAttribution, gatewayHeaders } from '@/lib/gateway';
6565
const enc = new TextEncoder();
6666

6767
type ContentPart =
@@ -588,7 +588,12 @@ export async function POST(req: Request) {
588588
method: 'POST',
589589
// x-offgrid-user attributes gateway spend to the real signed-in user (captured into the
590590
// gateway's OpenSearch log as `caller`) rather than the console's user-agent.
591-
headers: gatewayHeaders({ 'content-type': 'application/json', 'x-offgrid-user': userId }),
591+
// Attribution (user + tenant org) so the aggregator's observability doc is attributable and
592+
// the org-scoped Insights surfaces can actually see it (G-GATEWAY-INDEX-ORG).
593+
headers: gatewayHeaders({
594+
'content-type': 'application/json',
595+
...gatewayAttribution({ userId, orgId }),
596+
}),
592597
body: JSON.stringify(payload),
593598
signal: AbortSignal.timeout(290000),
594599
}).catch(() => null);

src/lib/gateway.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@ export const GATEWAY_CONTROL_URL = ENDPOINTS.controlUrl;
2020

2121
const GATEWAY_API_KEY = process.env.OFFGRID_GATEWAY_API_KEY ?? '';
2222

23+
/**
24+
* ATTRIBUTION headers for an inference call (PURE). The aggregator stamps these onto the
25+
* `offgrid-gateway` observability doc, and the console's Insights surfaces filter that index by
26+
* `term: { org }` for tenant isolation — so a call that omits the org lands as an UNATTRIBUTED doc
27+
* that those surfaces can never show (G-GATEWAY-INDEX-ORG). Every inference call site should spread
28+
* this so attribution is stamped one way, in one place, instead of hand-written per call.
29+
*
30+
* Blank/whitespace values are DROPPED rather than sent empty, so an unknown user/org stays honestly
31+
* absent instead of becoming an empty-string attribution.
32+
*/
33+
export function gatewayAttribution(
34+
attribution: { orgId?: string | null; userId?: string | null } = {},
35+
): Record<string, string> {
36+
const out: Record<string, string> = {};
37+
const org = attribution.orgId?.trim();
38+
const user = attribution.userId?.trim();
39+
if (org) out['x-offgrid-org'] = org;
40+
if (user) out['x-offgrid-user'] = user;
41+
return out;
42+
}
43+
2344
/**
2445
* Synchronous gateway headers (LEGACY seam). Emits exactly what the console sent before the broker:
2546
* the static `x-api-key` when configured, else no auth. Kept for the many synchronous call sites that

test/gateway-attribution.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import { gatewayAttribution } from '@/lib/gateway';
4+
5+
// The aggregator stamps these headers onto the offgrid-gateway observability doc, and the console's
6+
// Insights surfaces filter that index by `term: { org }` for TENANT ISOLATION. So a call that omits
7+
// the org lands as a doc those surfaces can never show (G-GATEWAY-INDEX-ORG). PURE — no I/O.
8+
9+
test('stamps both org and user when supplied', () => {
10+
assert.deepEqual(gatewayAttribution({ orgId: 'org_bharat', userId: 'rm@bank.test' }), {
11+
'x-offgrid-org': 'org_bharat',
12+
'x-offgrid-user': 'rm@bank.test',
13+
});
14+
});
15+
16+
test('DROPS blank/whitespace/missing values instead of sending empty attribution', () => {
17+
// An empty header would attribute the call to org "" — worse than honestly unattributed.
18+
assert.deepEqual(gatewayAttribution({ orgId: '', userId: ' ' }), {});
19+
assert.deepEqual(gatewayAttribution({}), {});
20+
assert.deepEqual(gatewayAttribution(), {});
21+
assert.deepEqual(gatewayAttribution({ orgId: null, userId: null }), {});
22+
});
23+
24+
test('trims surrounding whitespace so the aggregator stores a clean term value', () => {
25+
assert.deepEqual(gatewayAttribution({ orgId: ' org_suraksha ' }), {
26+
'x-offgrid-org': 'org_suraksha',
27+
});
28+
});
29+
30+
test('either half can stand alone (a system run may have an org but no user)', () => {
31+
assert.deepEqual(gatewayAttribution({ orgId: 'default' }), { 'x-offgrid-org': 'default' });
32+
assert.deepEqual(gatewayAttribution({ userId: 'ops@x' }), { 'x-offgrid-user': 'ops@x' });
33+
});

0 commit comments

Comments
 (0)