Skip to content

Commit 639d2f6

Browse files
committed
feat(observability): admin-gated per-request Server-Timing via X-OS-Debug-Timing (#2408)
Adds the per-request gating from the perf-tuning design so an operator can pull a single request's `Server-Timing` breakdown on a live environment without turning the header on for every user. Until now perf-tuning was global-only (`serverTiming` option / `OS_SERVER_TIMING`), which discloses internal phase durations — a mild backend-fingerprinting surface — to every caller. This wires up the second gating path the issue describes: - **Disclosure gate** (`@objectstack/observability`): a request-scoped `AsyncLocalStorage` gate, separate from the pure `PerfTiming` collector so the collector keeps its "only measures, never decides to emit" invariant. Pinned to a `Symbol.for` registry key like the collector store so the middleware (which seeds it) and the dispatcher (which opens it) share one store across ESM/CJS module copies. - **Hono middleware**: always registered unless `serverTiming: false`. Runs the collector when timing is global OR the request carries `X-OS-Debug-Timing: 1`; emits the header only when the gate is open. Zero collector overhead (one header read) when neither applies. - **Dispatcher**: after resolving the execution context, opens the gate for admin/service/system principals (`isSystem`, `principalKind` service/system, posture PLATFORM_ADMIN/TENANT_ADMIN). Ordinary callers get no header even if they send the debug header. - **Env alias**: `OS_PERF_TIMING=1` now also enables global mode, matching the issue's naming; `OS_SERVER_TIMING=true` still works. Tests cover the gate primitive, the middleware's per-request/global/hard-off paths, and the admin predicate. Existing global-mode behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011upHdyr5AnNc6dWAu63qxD
1 parent e057f42 commit 639d2f6

8 files changed

Lines changed: 365 additions & 41 deletions

File tree

packages/observability/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,9 @@ export {
5252
startServerTiming,
5353
measureServerTiming,
5454
countServerTiming,
55+
runWithPerfDisclosure,
56+
allowPerfDisclosure,
57+
isPerfDisclosureAllowed,
5558
type ServerTimingMark,
59+
type PerfDisclosureGate,
5660
} from './perf-timing.js';

packages/observability/src/perf-timing.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
startServerTiming,
1111
measureServerTiming,
1212
countServerTiming,
13+
runWithPerfDisclosure,
14+
allowPerfDisclosure,
15+
isPerfDisclosureAllowed,
16+
type PerfDisclosureGate,
1317
} from './perf-timing.js';
1418

1519
describe('formatServerTiming', () => {
@@ -208,3 +212,61 @@ describe('ambient collector', () => {
208212
expect(t.marks().map((m) => m.name)).toContain('after-await');
209213
});
210214
});
215+
216+
describe('disclosure gate', () => {
217+
it('isPerfDisclosureAllowed() is false with no active gate', () => {
218+
expect(isPerfDisclosureAllowed()).toBe(false);
219+
});
220+
221+
it('allowPerfDisclosure() is a no-op with no active gate (does not throw)', () => {
222+
allowPerfDisclosure();
223+
expect(isPerfDisclosureAllowed()).toBe(false);
224+
});
225+
226+
it('reflects the seeded state inside runWithPerfDisclosure', () => {
227+
const closed: PerfDisclosureGate = { allowed: false };
228+
runWithPerfDisclosure(closed, () => {
229+
expect(isPerfDisclosureAllowed()).toBe(false);
230+
});
231+
const open: PerfDisclosureGate = { allowed: true };
232+
runWithPerfDisclosure(open, () => {
233+
expect(isPerfDisclosureAllowed()).toBe(true);
234+
});
235+
});
236+
237+
it('allowPerfDisclosure() opens a closed gate the caller still holds', async () => {
238+
const gate: PerfDisclosureGate = { allowed: false };
239+
await runWithPerfDisclosure(gate, async () => {
240+
await new Promise((r) => setTimeout(r, 1));
241+
allowPerfDisclosure(); // after an await — same ALS scope
242+
});
243+
// The caller reads its own reference once the scope settles.
244+
expect(gate.allowed).toBe(true);
245+
});
246+
247+
it('leaves the gate closed when disclosure is never granted', async () => {
248+
const gate: PerfDisclosureGate = { allowed: false };
249+
await runWithPerfDisclosure(gate, async () => {
250+
await new Promise((r) => setTimeout(r, 1));
251+
});
252+
expect(gate.allowed).toBe(false);
253+
});
254+
255+
it('pins the gate store to a global-registry symbol (shared across module copies)', () => {
256+
const key = Symbol.for('@objectstack/observability:perf-disclosure-gate');
257+
expect((globalThis as Record<symbol, unknown>)[key]).toBeDefined();
258+
});
259+
260+
it('is independent of the timing collector scope', () => {
261+
// A collector can be active without a gate, and vice versa.
262+
const t = new PerfTiming();
263+
runWithPerfTiming(t, () => {
264+
expect(isPerfDisclosureAllowed()).toBe(false);
265+
});
266+
const gate: PerfDisclosureGate = { allowed: true };
267+
runWithPerfDisclosure(gate, () => {
268+
expect(currentPerfTiming()).toBeUndefined();
269+
expect(isPerfDisclosureAllowed()).toBe(true);
270+
});
271+
});
272+
});

packages/observability/src/perf-timing.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,64 @@ export async function measureServerTiming<T>(
283283
export function countServerTiming(name: string, dur: number, unit?: string): void {
284284
store.getStore()?.count(name, dur, unit);
285285
}
286+
287+
// --- Disclosure gate (WHO may see the timing) -------------------------
288+
289+
/**
290+
* Per-request disclosure gate — the policy counterpart to the collector.
291+
*
292+
* The {@link PerfTiming} collector only MEASURES; whether the measured
293+
* `Server-Timing` header is returned to the client is a separate decision. When
294+
* perf-tuning is turned on GLOBALLY (env flag / plugin option) the operator has
295+
* opted the whole environment in, so the gate opens up front and every response
296+
* carries the header. When it is turned on PER-REQUEST — the caller sends an
297+
* `X-OS-Debug-Timing` header — the header must stay withheld until the request
298+
* proves an admin/service identity: phase durations are a mild
299+
* backend-fingerprinting surface, so an ordinary user must never be able to pull
300+
* them just by sending a header. The request path flips the gate open with
301+
* {@link allowPerfDisclosure} once it has resolved a privileged principal.
302+
*
303+
* Keeping this out of {@link PerfTiming} preserves the collector's invariant
304+
* ("it only measures, it never decides whether to emit").
305+
*/
306+
export interface PerfDisclosureGate {
307+
/** Whether the collected timing may be disclosed to the client. */
308+
allowed: boolean;
309+
}
310+
311+
/**
312+
* The disclosure gate lives in its OWN global-registry-pinned
313+
* `AsyncLocalStorage`, for the same cross-module-copy reason as the collector
314+
* store above: the middleware seeds the gate and the dispatcher (a different
315+
* package, possibly a different module copy) flips it open — both must see the
316+
* one store.
317+
*/
318+
const GATE_KEY = Symbol.for('@objectstack/observability:perf-disclosure-gate');
319+
const globalGate = globalThis as unknown as Record<symbol, AsyncLocalStorage<PerfDisclosureGate> | undefined>;
320+
const gateStore: AsyncLocalStorage<PerfDisclosureGate> =
321+
globalGate[GATE_KEY] ?? (globalGate[GATE_KEY] = new AsyncLocalStorage<PerfDisclosureGate>());
322+
323+
/**
324+
* Run `fn` with `gate` as the ambient disclosure gate for the async call chain.
325+
* The caller keeps its reference to `gate` and reads `gate.allowed` after `fn`
326+
* settles to decide whether to emit the header.
327+
*/
328+
export function runWithPerfDisclosure<T>(gate: PerfDisclosureGate, fn: () => T): T {
329+
return gateStore.run(gate, fn);
330+
}
331+
332+
/**
333+
* Open the ambient disclosure gate — the request has proven it may see its own
334+
* `Server-Timing` header (admin/service identity). A no-op when no gate is
335+
* active (perf-tuning off, or already-global mode with no gate to flip), so the
336+
* call site stays branch-free.
337+
*/
338+
export function allowPerfDisclosure(): void {
339+
const g = gateStore.getStore();
340+
if (g) g.allowed = true;
341+
}
342+
343+
/** Whether the ambient disclosure gate is open. `false` when none is active. */
344+
export function isPerfDisclosureAllowed(): boolean {
345+
return gateStore.getStore()?.allowed ?? false;
346+
}

packages/plugins/plugin-hono-server/src/hono-plugin.test.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -210,36 +210,40 @@ describe('HonoServerPlugin', () => {
210210
});
211211

212212
it('should disable CORS when cors option is false', async () => {
213+
corsConfigCapture.last = undefined;
214+
213215
const plugin = new HonoServerPlugin({
214216
cors: false
215217
});
216218

217219
await plugin.init(context as PluginContext);
218220

219-
const serverInstance = (HonoHttpServer as any).mock.instances[0];
220-
const rawApp = serverInstance.getRawApp();
221-
222-
// CORS middleware should NOT be registered
223-
expect(rawApp.use).not.toHaveBeenCalled();
221+
// CORS middleware must NOT be configured. (Assert on the CORS config,
222+
// not the raw `use` count: the perf-timing middleware registers its
223+
// own `use('*')` by default to catch the `X-OS-Debug-Timing` header.)
224+
expect(corsConfigCapture.last).toBeUndefined();
224225
});
225226

226227
it('should disable CORS when CORS_ENABLED env is false', async () => {
227228
const originalEnv = process.env.OS_CORS_ENABLED;
228229
process.env.OS_CORS_ENABLED = 'false';
230+
corsConfigCapture.last = undefined;
229231

230-
const plugin = new HonoServerPlugin();
231-
await plugin.init(context as PluginContext);
232-
233-
const serverInstance = (HonoHttpServer as any).mock.instances[0];
234-
const rawApp = serverInstance.getRawApp();
235-
236-
expect(rawApp.use).not.toHaveBeenCalled();
237-
238-
// Restore environment
239-
if (originalEnv !== undefined) {
240-
process.env.OS_CORS_ENABLED = originalEnv;
241-
} else {
242-
delete process.env.OS_CORS_ENABLED;
232+
try {
233+
const plugin = new HonoServerPlugin();
234+
await plugin.init(context as PluginContext);
235+
236+
// CORS not configured — see the note above re: the perf-timing
237+
// middleware's own `use('*')`.
238+
expect(corsConfigCapture.last).toBeUndefined();
239+
} finally {
240+
// Restore environment even if the assertion fails, so a leaked
241+
// `OS_CORS_ENABLED=false` can't disable CORS in later tests.
242+
if (originalEnv !== undefined) {
243+
process.env.OS_CORS_ENABLED = originalEnv;
244+
} else {
245+
delete process.env.OS_CORS_ENABLED;
246+
}
243247
}
244248
});
245249

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import * as fs from 'fs';
1414
import * as path from 'path';
1515
import { createOriginMatcher, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher';
1616
import { readEnvWithDeprecation } from '@objectstack/types';
17-
import { PerfTiming, runWithPerfTiming } from '@objectstack/observability';
17+
import {
18+
PerfTiming,
19+
runWithPerfTiming,
20+
runWithPerfDisclosure,
21+
type PerfDisclosureGate,
22+
} from '@objectstack/observability';
1823

1924
export interface StaticMount {
2025
root: string;
@@ -63,13 +68,23 @@ export interface HonoPluginOptions {
6368
cors?: HonoCorsOptions | false;
6469

6570
/**
66-
* Enable per-request performance timing via the `Server-Timing` response
67-
* header ("perf-tuning mode"). OFF by default — the header discloses
68-
* internal phase durations (total / body-parse / handler), which is handy
69-
* for profiling but is also a backend-fingerprinting surface, so it is
70-
* opt-in. Can also be enabled with the `OS_SERVER_TIMING=true` environment
71-
* variable.
72-
* @default false
71+
* Per-request performance timing via the `Server-Timing` response header
72+
* ("perf-tuning mode"). The header discloses internal phase durations
73+
* (total / auth / db / hooks / serialize), which is handy for profiling but
74+
* is also a mild backend-fingerprinting surface, so disclosure is gated:
75+
*
76+
* - **GLOBAL** — `serverTiming: true`, or `OS_SERVER_TIMING=true` /
77+
* `OS_PERF_TIMING=1`: every response carries the header (an environment
78+
* under active investigation).
79+
* - **PER-REQUEST** — always available unless hard-disabled: a caller sends
80+
* `X-OS-Debug-Timing: 1` and the header is returned ONLY after the request
81+
* resolves an admin/service identity (the dispatcher opens the disclosure
82+
* gate). Ordinary users can never pull timings just by sending the header.
83+
* - `serverTiming: false` hard-disables BOTH paths (no middleware).
84+
*
85+
* `undefined` (the default) leaves global mode off but keeps the
86+
* admin-gated per-request path available.
87+
* @default undefined
7388
*/
7489
serverTiming?: boolean;
7590
}
@@ -123,6 +138,16 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
123138
}
124139
}
125140

141+
/**
142+
* Whether a request opted into per-request perf timing via `X-OS-Debug-Timing`.
143+
* Accepts the common truthy spellings; anything else (including absent) is off.
144+
*/
145+
export function isDebugTimingRequested(value: string | undefined | null): boolean {
146+
if (!value) return false;
147+
const v = value.trim().toLowerCase();
148+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
149+
}
150+
126151
/** Minimal schema shape the managed-write clamp needs. */
127152
export interface ManagedSchemaLike {
128153
managedBy?: string;
@@ -217,20 +242,45 @@ export class HonoServerPlugin implements Plugin {
217242
ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' });
218243

219244
// ─── Server-Timing (perf-tuning mode) ─────────────────────────────────
220-
// Opt-in per-request performance timing exposed via the `Server-Timing`
245+
// Per-request performance timing exposed via the `Server-Timing`
221246
// response header. Registered FIRST (before CORS) so the `total` mark
222247
// brackets the whole request and the ambient timing collector is
223248
// established — via AsyncLocalStorage — for every downstream layer
224-
// (CORS, route handler, body parse) to record sub-phases into.
225-
const serverTimingEnabled =
226-
this.options.serverTiming ?? (process.env.OS_SERVER_TIMING === 'true');
227-
if (serverTimingEnabled) {
249+
// (CORS, route handler, body parse, SQL driver, hooks) to record
250+
// sub-phases into.
251+
//
252+
// Two ways to turn it on (see the `serverTiming` option JSDoc):
253+
// • GLOBAL — `serverTiming: true` / `OS_SERVER_TIMING=true` /
254+
// `OS_PERF_TIMING=1`: the header is returned to EVERY
255+
// caller. The disclosure gate opens up front.
256+
// • PER-REQUEST — the caller sends `X-OS-Debug-Timing: 1`; the header
257+
// is returned ONLY after the dispatcher resolves an
258+
// admin/service identity and opens the gate, so an
259+
// ordinary user can never fingerprint the backend by
260+
// sending the header alone.
261+
// `serverTiming: false` hard-disables both paths (no middleware).
262+
if (this.options.serverTiming !== false) {
263+
const globalTiming =
264+
this.options.serverTiming === true ||
265+
process.env.OS_SERVER_TIMING === 'true' ||
266+
process.env.OS_PERF_TIMING === '1' ||
267+
process.env.OS_PERF_TIMING === 'true';
228268
const rawApp = this.server.getRawApp();
229269
rawApp.use('*', async (c, next) => {
270+
const perRequest = isDebugTimingRequested(c.req.header('X-OS-Debug-Timing'));
271+
// Nothing asked for timing on this request — a single header
272+
// read, then straight through. Zero collector overhead.
273+
if (!globalTiming && !perRequest) return next();
274+
230275
const timing = new PerfTiming();
276+
// Global mode opens the gate for everyone; the per-request path
277+
// starts closed and is opened only if an admin/service identity
278+
// is proven during dispatch (`allowPerfDisclosure`).
279+
const gate: PerfDisclosureGate = { allowed: globalTiming };
231280
const endTotal = timing.start('total', 'Total server time');
232-
await runWithPerfTiming(timing, () => next());
281+
await runWithPerfTiming(timing, () => runWithPerfDisclosure(gate, () => next()));
233282
endTotal();
283+
if (!gate.allowed) return; // per-request, unverified caller — withhold
234284
const header = timing.toHeader();
235285
// `append` (not `set`) so we coexist with any upstream proxy
236286
// that already added a Server-Timing entry.

0 commit comments

Comments
 (0)