diff --git a/.changeset/server-timing-perf-tuning.md b/.changeset/server-timing-perf-tuning.md new file mode 100644 index 0000000000..acfd0ec41e --- /dev/null +++ b/.changeset/server-timing-perf-tuning.md @@ -0,0 +1,10 @@ +--- +'@objectstack/observability': minor +'@objectstack/plugin-hono-server': minor +--- + +Observability: per-request performance timing surfaced via the `Server-Timing` response header ("perf-tuning mode"). + +`@objectstack/observability` gains a tiny, dependency-free `PerfTiming` collector plus an `AsyncLocalStorage`-backed ambient API (`runWithPerfTiming` / `currentPerfTiming` and the no-op-when-disabled free functions `measureServerTiming` / `startServerTiming` / `recordServerTiming`) and a spec-compliant `formatServerTiming` serializer that sanitizes names to tokens and quotes/escapes descriptions (no header injection). + +The Hono server plugin can now emit `Server-Timing` per request. It is **off by default** — the header discloses internal phase durations, which is a backend-fingerprinting surface — and opt-in via `new HonoServerPlugin({ serverTiming: true })` or `OS_SERVER_TIMING=true` (so it works through the default `os serve`). When enabled, every response carries `total` (measured by an outer middleware that brackets the whole request) plus the adapter-contributed `parse` and `handler` sub-phases; any code on the request's async call chain can add its own phases via the ambient API. When disabled, the timing call sites are zero-overhead no-ops. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index c068136cb6..03f0b7e6c3 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -220,6 +220,58 @@ runtime: the OTel API surface is large and host-specific (Node vs. edge vs. browser), so we publish the parsing primitive and leave SDK wiring to the host. +## Server-Timing (perf-tuning mode) + +Per-request server-side timing can be surfaced to clients via the W3C +[`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) +response header. The browser DevTools **Network → Timing** panel renders these +phases inline, which makes it trivial to see where wall-clock time went on a +slow request without attaching a profiler. + +``` +Server-Timing: total;dur=18.7;desc="Total server time", parse;dur=0.4;desc="Body parse", handler;dur=17.9;desc="Route handler" +``` + +This is **off by default**: the header discloses internal phase durations, +which is helpful for profiling but also lets a caller fingerprint the backend. +Treat it as a perf-tuning toggle you flip in staging (or briefly in production +behind an allowlist), not a default-on header. + +Enable it on the Hono server plugin: + +```ts +new HonoServerPlugin({ serverTiming: true }); +``` + +…or, for the default `os serve` server (which constructs the plugin for you), +via the environment: + +```bash +OS_SERVER_TIMING=true os serve +``` + +When enabled, every response carries `total` (the whole request, measured by +an outer middleware) plus any sub-phases the request recorded. The HTTP adapter +contributes `parse` (request-body parsing) and `handler` (route-handler +execution) out of the box. + +### Recording your own phases + +Timing is collected through a request-scoped `AsyncLocalStorage` collector, so +any code on the request's async call chain can add a phase without threading a +request object through every layer. The free functions are cheap no-ops when +the feature is off, so they are safe to leave in place permanently: + +```ts +import { measureServerTiming } from '@objectstack/observability'; + +const rows = await measureServerTiming('db', () => engine.find(query), 'Primary query'); +// → adds `db;dur=;desc="Primary query"` to the response when perf-tuning is on. +``` + +`startServerTiming(name)` (returns an `end()` callback) and +`recordServerTiming(name, dur)` are also available for manual instrumentation. + ## Go-live checklist - [ ] `metrics` adapter configured and `/metrics` (Prometheus) or OTel @@ -234,3 +286,5 @@ host. - [ ] Log records include `requestId` field; cross-checked one against the response `X-Request-Id` header. - [ ] Alerts wired: error rate, p95 latency per route. +- [ ] (Optional) `Server-Timing` verified in DevTools when `serverTiming` / + `OS_SERVER_TIMING=true` is enabled, and confirmed **absent** by default. diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index ac3bf1c98a..0f05701ea0 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -40,3 +40,16 @@ export { LOG_LEVELS, type LogLevel, } from './loggers.js'; + +// Per-request performance timing (Server-Timing header) +export { + PerfTiming, + perfNow, + formatServerTiming, + runWithPerfTiming, + currentPerfTiming, + recordServerTiming, + startServerTiming, + measureServerTiming, + type ServerTimingMark, +} from './perf-timing.js'; diff --git a/packages/observability/src/perf-timing.test.ts b/packages/observability/src/perf-timing.test.ts new file mode 100644 index 0000000000..74de6b61e4 --- /dev/null +++ b/packages/observability/src/perf-timing.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + PerfTiming, + formatServerTiming, + runWithPerfTiming, + currentPerfTiming, + recordServerTiming, + startServerTiming, + measureServerTiming, +} from './perf-timing.js'; + +describe('formatServerTiming', () => { + it('serializes name + duration', () => { + expect(formatServerTiming([{ name: 'db', dur: 12.3 }])).toBe('db;dur=12.3'); + }); + + it('rounds duration to 2 decimals', () => { + expect(formatServerTiming([{ name: 'db', dur: 12.34567 }])).toBe('db;dur=12.35'); + }); + + it('emits a quoted desc when present', () => { + expect(formatServerTiming([{ name: 'total', dur: 5, desc: 'Total time' }])).toBe( + 'total;dur=5;desc="Total time"', + ); + }); + + it('joins multiple marks with comma-space', () => { + expect( + formatServerTiming([ + { name: 'parse', dur: 1 }, + { name: 'handler', dur: 4 }, + ]), + ).toBe('parse;dur=1, handler;dur=4'); + }); + + it('sanitizes names into tokens', () => { + expect(formatServerTiming([{ name: 'db query!', dur: 1 }])).toBe('db_query;dur=1'); + }); + + it('drops marks whose name is empty after sanitization', () => { + expect(formatServerTiming([{ name: '!!!', dur: 1 }])).toBe(''); + }); + + it('strips quotes/backslashes/control chars from desc (no header injection)', () => { + const out = formatServerTiming([ + { name: 'x', dur: 1, desc: 'a"b\\c\r\nInjected: 1' }, + ]); + expect(out).toBe('x;dur=1;desc="a b c Injected: 1"'); + expect(out).not.toContain('\n'); + expect(out).not.toContain('"a"b"'); + }); + + it('coerces non-finite durations to 0', () => { + expect(formatServerTiming([{ name: 'x', dur: Number.NaN }])).toBe('x;dur=0'); + expect(formatServerTiming([{ name: 'x', dur: Number.POSITIVE_INFINITY }])).toBe('x;dur=0'); + }); + + it('returns empty string for no marks', () => { + expect(formatServerTiming([])).toBe(''); + }); +}); + +describe('PerfTiming', () => { + it('records explicit marks in order', () => { + const t = new PerfTiming(); + t.record('a', 1); + t.record('b', 2); + expect(t.marks().map((m) => m.name)).toEqual(['a', 'b']); + expect(t.toHeader()).toBe('a;dur=1, b;dur=2'); + }); + + it('start() returns an idempotent end()', () => { + const t = new PerfTiming(); + const end = t.start('phase'); + end(); + end(); // second call ignored + expect(t.marks()).toHaveLength(1); + expect(t.marks()[0].name).toBe('phase'); + expect(t.marks()[0].dur).toBeGreaterThanOrEqual(0); + }); + + it('measure() records duration and returns the value', async () => { + const t = new PerfTiming(); + const value = await t.measure('work', async () => { + await new Promise((r) => setTimeout(r, 5)); + return 42; + }); + expect(value).toBe(42); + expect(t.marks()).toHaveLength(1); + expect(t.marks()[0].dur).toBeGreaterThan(0); + }); + + it('measure() records even when the function throws', async () => { + const t = new PerfTiming(); + await expect( + t.measure('boom', async () => { + throw new Error('nope'); + }), + ).rejects.toThrow('nope'); + expect(t.marks()).toHaveLength(1); + expect(t.marks()[0].name).toBe('boom'); + }); +}); + +describe('ambient collector', () => { + it('currentPerfTiming() is undefined outside a run scope', () => { + expect(currentPerfTiming()).toBeUndefined(); + }); + + it('free functions are no-ops with no active collector', async () => { + recordServerTiming('x', 1); // must not throw + const end = startServerTiming('y'); + end(); // must not throw + const v = await measureServerTiming('z', async () => 7); + expect(v).toBe(7); + }); + + it('records onto the ambient collector inside runWithPerfTiming', async () => { + const t = new PerfTiming(); + await runWithPerfTiming(t, async () => { + expect(currentPerfTiming()).toBe(t); + recordServerTiming('db', 3, 'Database'); + const v = await measureServerTiming('compute', async () => 'ok'); + expect(v).toBe('ok'); + }); + const names = t.marks().map((m) => m.name); + expect(names).toContain('db'); + expect(names).toContain('compute'); + expect(t.toHeader()).toContain('db;dur=3;desc="Database"'); + }); + + it('propagates across async boundaries', async () => { + const t = new PerfTiming(); + await runWithPerfTiming(t, async () => { + await new Promise((r) => setTimeout(r, 1)); + recordServerTiming('after-await', 1); + }); + expect(t.marks().map((m) => m.name)).toContain('after-await'); + }); +}); diff --git a/packages/observability/src/perf-timing.ts b/packages/observability/src/perf-timing.ts new file mode 100644 index 0000000000..dbb40ac920 --- /dev/null +++ b/packages/observability/src/perf-timing.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Per-request performance timing - a tiny, dependency-free collector that + * accumulates named phase durations during a single request and serializes + * them into the W3C `Server-Timing` response header. + * + * @see + * @see + * + * Two ways to record: + * + * 1. **Explicit collector.** Hold a {@link PerfTiming} instance and call + * `start()` / `record()` / `measure()` directly. The HTTP adapter owns + * the instance for a request. + * + * 2. **Ambient collector.** Run a request inside {@link runWithPerfTiming} + * and any framework code on that async call chain records phases via the + * free functions ({@link measureServerTiming}, {@link startServerTiming}, + * {@link recordServerTiming}) without threading the request object + * through every layer. When no collector is active the free functions are + * cheap no-ops, so call sites pay nothing when the feature is off. + * + * `Server-Timing` exposes internal phase durations to any client, which is a + * (mild) information-disclosure surface - it helps an attacker profile the + * backend. Emission is therefore opt-in ("perf-tuning mode"); the collector + * itself never decides whether to emit, it only measures. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * One recorded phase of a request's server-side processing, serialized as a + * single member of the `Server-Timing` header. + */ +export interface ServerTimingMark { + /** Metric name. Coerced to a Server-Timing token on record. */ + name: string; + /** Duration in milliseconds. */ + dur: number; + /** Optional human-readable description (rendered as the quoted `desc`). */ + desc?: string; +} + +/** + * Monotonic millisecond clock. Prefers `performance.now()` (monotonic, not + * affected by wall-clock adjustments); falls back to `Date.now()` on the rare + * runtime where `performance` is unavailable. + */ +export function perfNow(): number { + try { + return performance.now(); + } catch { + return Date.now(); + } +} + +/** Characters not allowed in a Server-Timing metric name (a token). */ +const NAME_UNSAFE = /[^A-Za-z0-9_-]+/g; + +const UNDERSCORE = 0x5f; + +/** + * Coerce an arbitrary string into a Server-Timing token: non-token runs become + * a single underscore, then leading/trailing underscores are trimmed. + * + * The trim is a linear scan rather than a `/^_+|_+$/g` regex on purpose - the + * anchored `_+$` quantifier backtracks polynomially on underscore-heavy input + * (CodeQL js/polynomial-redos), and this name comes from a public-API argument. + */ +function sanitizeName(name: string): string { + const collapsed = String(name).replace(NAME_UNSAFE, '_'); + let start = 0; + let end = collapsed.length; + while (start < end && collapsed.charCodeAt(start) === UNDERSCORE) start++; + while (end > start && collapsed.charCodeAt(end - 1) === UNDERSCORE) end--; + return collapsed.slice(start, end); +} + +/** + * Make a description safe to embed in a quoted-string. Backslashes and double + * quotes would terminate the quoting; control chars (incl. CR/LF) could forge + * headers. Collapse anything outside a conservative printable set to a space. + */ +function sanitizeDesc(desc: string): string { + let out = ''; + for (const ch of String(desc)) { + const code = ch.codePointAt(0)!; + // Printable ASCII excluding `"` (0x22) and `\` (0x5C); drop the rest. + if (code >= 0x20 && code < 0x7f && ch !== '"' && ch !== '\\') { + out += ch; + } else { + out += ' '; + } + } + return out.replace(/ +/g, ' ').trim(); +} + +/** Round to at most 2 decimals without trailing-zero noise (`12.3`, not `12.30`). */ +function fmtDur(dur: number): string { + if (!Number.isFinite(dur)) return '0'; + return String(Math.round(dur * 100) / 100); +} + +/** + * Serialize marks into a `Server-Timing` header value. Marks with an empty + * name after sanitization are dropped (the grammar requires a token). Returns + * `''` when there is nothing to emit so callers can skip the header. + */ +export function formatServerTiming(marks: readonly ServerTimingMark[]): string { + const parts: string[] = []; + for (const m of marks) { + const name = sanitizeName(m.name); + if (!name) continue; + let part = `${name};dur=${fmtDur(m.dur)}`; + if (m.desc) { + const desc = sanitizeDesc(m.desc); + if (desc) part += `;desc="${desc}"`; + } + parts.push(part); + } + return parts.join(', '); +} + +/** + * Collector for one request's timing phases. Not thread-safe by design - one + * instance belongs to one request. All methods are allocation-light and never + * throw on the hot path. + */ +export class PerfTiming { + private readonly _marks: ServerTimingMark[] = []; + + /** Record an already-measured phase. */ + record(name: string, dur: number, desc?: string): void { + this._marks.push({ name, dur, desc }); + } + + /** + * Begin timing a phase. Returns an idempotent `end()` - the first call + * records the elapsed duration; later calls are ignored, so it is safe to + * call from both a success and an error path. + */ + start(name: string, desc?: string): () => void { + const t0 = perfNow(); + let done = false; + return () => { + if (done) return; + done = true; + this.record(name, perfNow() - t0, desc); + }; + } + + /** Time an async (or sync) function, recording its elapsed duration. */ + async measure(name: string, fn: () => T | Promise, desc?: string): Promise { + const end = this.start(name, desc); + try { + return await fn(); + } finally { + end(); + } + } + + /** Snapshot of recorded marks, in record order. */ + marks(): readonly ServerTimingMark[] { + return this._marks; + } + + /** Serialize to a `Server-Timing` header value (`''` when empty). */ + toHeader(): string { + return formatServerTiming(this._marks); + } +} + +// --- Ambient (request-scoped) collector ------------------------------- + +const store = new AsyncLocalStorage(); + +/** Run `fn` with `timing` as the ambient collector for the async call chain. */ +export function runWithPerfTiming(timing: PerfTiming, fn: () => T): T { + return store.run(timing, fn); +} + +/** The collector for the current request, or `undefined` outside a request. */ +export function currentPerfTiming(): PerfTiming | undefined { + return store.getStore(); +} + +/** Record a phase on the ambient collector. No-op when none is active. */ +export function recordServerTiming(name: string, dur: number, desc?: string): void { + store.getStore()?.record(name, dur, desc); +} + +/** + * Begin timing a phase on the ambient collector. Returns an `end()` callback; + * when no collector is active the returned callback is a no-op so call sites + * stay branch-free. + */ +export function startServerTiming(name: string, desc?: string): () => void { + const t = store.getStore(); + if (!t) return () => {}; + return t.start(name, desc); +} + +/** + * Time an async function on the ambient collector. When no collector is active + * the function is awaited with zero timing overhead. + */ +export async function measureServerTiming( + name: string, + fn: () => T | Promise, + desc?: string, +): Promise { + const t = store.getStore(); + if (!t) return fn(); + return t.measure(name, fn, desc); +} diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index a62774629f..3b4acb228c 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hono/node-server": "^2.0.5", "@objectstack/core": "workspace:*", + "@objectstack/observability": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "hono": "^4.12.26" diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index c07c3e8775..6e286105ca 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -8,6 +8,7 @@ import { RouteHandler, Middleware } from '@objectstack/core'; +import { currentPerfTiming } from '@objectstack/observability'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; @@ -62,6 +63,12 @@ export class HonoHttpServer implements IHttpServer { return async (c: any) => { let body: any = {}; + // Ambient per-request timing collector — present only when the + // Server-Timing / perf-tuning middleware established one for this + // request. All marks below are no-ops otherwise (zero overhead). + const _perf = currentPerfTiming(); + const _endParse = _perf?.start('parse', 'Body parse'); + const contentType = c.req.header('content-type') ?? ''; const isOctetStream = contentType.includes('application/octet-stream'); @@ -84,6 +91,8 @@ export class HonoHttpServer implements IHttpServer { } catch(e) {} } + _endParse?.(); + const rawHeaders = c.req.header(); // Fetch API `Request` objects don't expose the `Host` header // (it's a forbidden header — derived from the URL by the @@ -173,9 +182,11 @@ export class HonoHttpServer implements IHttpServer { }); // Run the handler; once it's done, check if streaming was used + const _endHandler = _perf?.start('handler', 'Route handler'); const result = handler(req as any, res as any); const done = result instanceof Promise ? result : Promise.resolve(result); done.then(() => { + _endHandler?.(); if (isStreaming) { resolve(new Response(stream, { status: 200, @@ -187,6 +198,7 @@ export class HonoHttpServer implements IHttpServer { resolve(null); } }).catch((err) => { + _endHandler?.(); closeStream(); resolve(null); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 536ca4329c..dcbcf97cdb 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { createOriginMatcher, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher'; import { readEnvWithDeprecation } from '@objectstack/types'; +import { PerfTiming, runWithPerfTiming } from '@objectstack/observability'; export interface StaticMount { root: string; @@ -57,6 +58,17 @@ export interface HonoPluginOptions { * (legacy CORS_* names still honoured with a deprecation warning). */ cors?: HonoCorsOptions | false; + + /** + * Enable per-request performance timing via the `Server-Timing` response + * header ("perf-tuning mode"). OFF by default — the header discloses + * internal phase durations (total / body-parse / handler), which is handy + * for profiling but is also a backend-fingerprinting surface, so it is + * opt-in. Can also be enabled with the `OS_SERVER_TIMING=true` environment + * variable. + * @default false + */ + serverTiming?: boolean; } /** @@ -110,6 +122,29 @@ export class HonoServerPlugin implements Plugin { ctx.registerService('http-server', this.server); ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' }); + // ─── Server-Timing (perf-tuning mode) ───────────────────────────────── + // Opt-in per-request performance timing exposed via the `Server-Timing` + // response header. Registered FIRST (before CORS) so the `total` mark + // brackets the whole request and the ambient timing collector is + // established — via AsyncLocalStorage — for every downstream layer + // (CORS, route handler, body parse) to record sub-phases into. + const serverTimingEnabled = + this.options.serverTiming ?? (process.env.OS_SERVER_TIMING === 'true'); + if (serverTimingEnabled) { + const rawApp = this.server.getRawApp(); + rawApp.use('*', async (c, next) => { + const timing = new PerfTiming(); + const endTotal = timing.start('total', 'Total server time'); + await runWithPerfTiming(timing, () => next()); + endTotal(); + const header = timing.toHeader(); + // `append` (not `set`) so we coexist with any upstream proxy + // that already added a Server-Timing entry. + if (header) c.res.headers.append('Server-Timing', header); + }); + ctx.logger.debug('Server-Timing (perf-tuning) middleware enabled'); + } + // ─── CORS Middleware ────────────────────────────────────────────────── // Enabled by default. Controlled via options.cors or environment variables. const corsDisabledByEnv = readEnvWithDeprecation('OS_CORS_ENABLED', 'CORS_ENABLED', { silent: true }) === 'false'; diff --git a/packages/plugins/plugin-hono-server/src/server-timing.test.ts b/packages/plugins/plugin-hono-server/src/server-timing.test.ts new file mode 100644 index 0000000000..4d0c99dca8 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/server-timing.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; +import type { PluginContext } from '@objectstack/core'; + +/** + * Integration tests for the opt-in `Server-Timing` (perf-tuning) middleware. + * Uses the real Hono adapter (no mock) and drives requests through the raw + * Hono app via `app.request()`. + */ + +function fakeCtx(): PluginContext { + const services = new Map(); + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => services.get(name), + } as unknown as PluginContext; +} + +async function setup(opts: { serverTiming?: boolean } = {}) { + const plugin = new HonoServerPlugin({ ...opts, cors: false }); + await (plugin as any).init(fakeCtx()); + const server = (plugin as any).server; + server.get('/ping', (_req: any, res: any) => res.json({ ok: true })); + const app = server.getRawApp(); + return { plugin, server, app }; +} + +describe('Server-Timing (perf-tuning) middleware', () => { + const prev = process.env.OS_SERVER_TIMING; + beforeEach(() => { delete process.env.OS_SERVER_TIMING; }); + afterEach(() => { + if (prev === undefined) delete process.env.OS_SERVER_TIMING; + else process.env.OS_SERVER_TIMING = prev; + }); + + it('is OFF by default — no Server-Timing header', async () => { + const { app } = await setup(); + const res = await app.request('/ping'); + expect(res.status).toBe(200); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); + + it('emits Server-Timing with total + sub-phases when serverTiming: true', async () => { + const { app } = await setup({ serverTiming: true }); + const res = await app.request('/ping'); + expect(res.status).toBe(200); + const header = res.headers.get('Server-Timing'); + expect(header).toBeTruthy(); + // total is always present; the adapter contributes parse + handler. + expect(header).toMatch(/(^|, )total;dur=[\d.]+/); + expect(header).toContain('handler;dur='); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('is enabled via OS_SERVER_TIMING=true when the option is unset', async () => { + process.env.OS_SERVER_TIMING = 'true'; + const { app } = await setup(); + const res = await app.request('/ping'); + expect(res.headers.get('Server-Timing')).toMatch(/total;dur=/); + }); + + it('explicit serverTiming: false overrides OS_SERVER_TIMING=true', async () => { + process.env.OS_SERVER_TIMING = 'true'; + const { app } = await setup({ serverTiming: false }); + const res = await app.request('/ping'); + expect(res.headers.get('Server-Timing')).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-hono-server/vitest.config.ts b/packages/plugins/plugin-hono-server/vitest.config.ts index fcded82ed9..154db32466 100644 --- a/packages/plugins/plugin-hono-server/vitest.config.ts +++ b/packages/plugins/plugin-hono-server/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ resolve: { alias: { '@objectstack/core': path.resolve(__dirname, '../../core/src/index.ts'), + '@objectstack/observability': path.resolve(__dirname, '../../observability/src/index.ts'), '@objectstack/spec/api': path.resolve(__dirname, '../../spec/src/api/index.ts'), '@objectstack/spec/contracts': path.resolve(__dirname, '../../spec/src/contracts/index.ts'), '@objectstack/spec/data': path.resolve(__dirname, '../../spec/src/data/index.ts'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0047b25815..2b963d4002 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1361,6 +1361,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/observability': + specifier: workspace:* + version: link:../../observability '@objectstack/spec': specifier: workspace:* version: link:../../spec