mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D49 — lib/audit-query.mjs (Phase 3 audit aggregate query layer) (#26)
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory
ndjson scan; cross-file walk over audit.ndjson (live) +
audit-YYYY-MM-DD.ndjson (rotated). No server.mjs integration in this
D-day (D50 wires the consuming endpoints).
NEW lib/audit-query.mjs (~370 lines): 5 public API functions per
ADR 0008 § 4.1:
- discoverAuditFiles({ olpHome }): filesystem scan; returns
Map<date|'live', path>.
- readAuditWindow({ startMs, endMs, olpHome, logEvent }): generator
over events in half-open window [startMs, endMs). Walks rotated
date files + live file. Skips malformed lines + logs warn.
- aggregateRequests({ windowMs, olpHome }): counts + status buckets
+ by_provider + by_owner_tier + by_path + median/p95 latency over
rolling window.
- topFallbackChains({ windowMs, limit, olpHome }): top-N chains by
trigger count from events with fallback_hops > 0. Tied-count
tiebreak: ascending first_seen.
- spendTrendDaily({ days, olpHome }): daily series ending today
with sparse-fill for zero-request days. Per-day request_count +
median latency + by_provider breakdown.
- cacheHitRateWindow({ windowMs, olpHome }): audit-derived cache
hit rate (bypass excluded from denominator); per-provider + overall.
PII discipline (ADR 0008 § 4.3): every aggregate function relays only
schema fields; never message content. Suite 23g actively asserts the
absence of content/message/messages/prompt/response/body keys in every
aggregate output.
Cross-file walk semantics (ADR 0008 § 4.2): half-open window
[startMs, endMs); date-range computed once from window bounds; each
rotated date file checked; live audit.ndjson always checked (it
covers today regardless of whether the window endpoint is past
midnight).
spendTrendDaily calendar-date semantics:
days: N returns "last N calendar UTC dates ending today" — NOT
"events within a rolling N*86400-ms window" (which would span N+1
distinct UTC dates and produce off-by-one buckets at non-midnight
call times). Computed via:
for (let i = days-1; i >= 0; i--)
dates.push(_utcDateFromMs(now - i*86400*1000));
cacheHitRateWindow denominator: hit_rate = hit / (hit + miss).
Bypass is intentional non-cacheable (Anthropic cache_control marker),
NOT a cache miss; excluding it from the denominator gives a clean
cache-effectiveness signal.
TESTS — Suite 23, +27 (544 → 571):
23a-1..4: discoverAuditFiles (empty dir / live only / live+rotated /
non-audit files ignored)
23b-1..6: readAuditWindow (all-coverage / single-day / half-open
exclusivity / empty window / missing files / malformed-skip with
warn)
23c-1..4: aggregateRequests (counts + status buckets + by_provider;
by_owner_tier; median+p95 latency over realistic distribution;
invalid windowMs rejection)
23d-1..4: topFallbackChains (sort desc by count; limit truncation;
fallback_hops=0 excluded; first_seen/last_seen carried)
23e-1..3: spendTrendDaily (N-day range correctness — caught off-by-
one during local run; populated day breakdown; empty day sparse-
fill)
23f-1..3: cacheHitRateWindow (overall + per-provider hit_rate;
bypass not in denominator; cache_status=null events excluded)
23g-1..3: PII guard for aggregateRequests / spendTrendDaily /
topFallbackChains + cacheHitRateWindow — every output JSON-
stringified + scanned for forbidden PII keys
DOCUMENTATION:
- AGENTS.md: lib/audit-query.mjs new entry; lib/audit.mjs note added
that D52 extends with daily rotation.
NOT IN D49 scope:
- server.mjs endpoints consuming these queries (D50)
- dashboard.html (D51)
- lib/audit.mjs rotation extension + bin/olp-audit-rotate.mjs (D52)
- tried_providers schema fix (D53; D45 P2 deferral)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 544 → 571 (+27). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 4 (query API surface) + § 5 (rotation file naming
pattern) + § 3 (storage layout).
- ADR 0007 § 8 (audit ndjson event schema — input data).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR adds a new lib/ module. No provider
plugin / entry surface / IR change. Rule 5 commit-citation requirements
for those scopes do not apply.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,8 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
|
||||
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
|
||||
- `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. **✅ Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy).**
|
||||
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
|
||||
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).**
|
||||
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).** **D52 will extend with daily rotation per ADR 0008 § 5.**
|
||||
- `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).**
|
||||
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
|
||||
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
|
||||
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
|
||||
|
||||
@@ -4,6 +4,33 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
## Unreleased
|
||||
|
||||
### D49 — `lib/audit-query.mjs` audit aggregate query layer (Phase 3)
|
||||
|
||||
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory ndjson scan; cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). No server.mjs integration in this D-day (D50 wires the consuming endpoints).
|
||||
|
||||
- **New file `lib/audit-query.mjs`** (~370 lines): 5 public API functions per ADR 0008 § 4.1:
|
||||
- `discoverAuditFiles({ olpHome })` — filesystem scan; returns `Map<date|'live', path>`.
|
||||
- `readAuditWindow({ startMs, endMs, olpHome, logEvent })` — generator over events in half-open window [startMs, endMs). Walks rotated date files + live file. Skips malformed lines + logs warn.
|
||||
- `aggregateRequests({ windowMs, olpHome })` — counts + status buckets + by_provider + by_owner_tier + by_path + median/p95 latency over rolling window.
|
||||
- `topFallbackChains({ windowMs, limit, olpHome })` — top-N chains by trigger count from events with `fallback_hops > 0`. Tied-count tiebreak: ascending first_seen.
|
||||
- `spendTrendDaily({ days, olpHome })` — daily series ending today with sparse-fill for zero-request days. Per-day request_count + median latency + by_provider breakdown.
|
||||
- `cacheHitRateWindow({ windowMs, olpHome })` — audit-derived cache hit rate (bypass excluded from denominator); per-provider + overall.
|
||||
- **PII discipline** (ADR 0008 § 4.3): every aggregate function relays only schema fields; never message content. Suite 23g actively asserts the absence of `content`/`message`/`messages`/`prompt`/`response`/`body` keys in every aggregate output.
|
||||
- **Cross-file walk semantics** (ADR 0008 § 4.2): half-open window [startMs, endMs); date-range computed once from window bounds; each rotated date file checked; live `audit.ndjson` always checked (it covers today regardless of whether the window endpoint is past midnight).
|
||||
- **`spendTrendDaily` calendar-date semantics**: `days: N` returns "last N calendar UTC dates ending today" — NOT "events within a rolling N×86400-ms window" (which would span N+1 distinct UTC dates and produce off-by-one buckets at non-midnight call times). Computed via `for (let i = days-1; i >= 0; i--) dates.push(_utcDateFromMs(now - i*86400*1000));`.
|
||||
- **`cacheHitRateWindow` denominator**: hit_rate = hit / (hit + miss). Bypass is intentional non-cacheable (Anthropic cache_control marker), NOT a cache miss; excluding it from the denominator gives a clean cache-effectiveness signal.
|
||||
- **Test surface (Suite 23, +27 tests — 544 → 571):**
|
||||
- 23a-1..4: `discoverAuditFiles` (empty dir / live only / live+rotated / non-audit files ignored)
|
||||
- 23b-1..6: `readAuditWindow` (all-coverage / single-day / half-open exclusivity / empty window / missing files / malformed-skip with warn)
|
||||
- 23c-1..4: `aggregateRequests` (counts + status buckets + by_provider; by_owner_tier; median+p95 latency over realistic distribution; invalid windowMs rejection)
|
||||
- 23d-1..4: `topFallbackChains` (sort desc by count; limit truncation; fallback_hops=0 excluded; first_seen/last_seen carried)
|
||||
- 23e-1..3: `spendTrendDaily` (N-day range correctness; populated day breakdown; empty day sparse-fill)
|
||||
- 23f-1..3: `cacheHitRateWindow` (overall + per-provider hit_rate; bypass not in denominator; cache_status=null events excluded)
|
||||
- 23g-1..3: PII guard for `aggregateRequests` / `spendTrendDaily` / `topFallbackChains` + `cacheHitRateWindow` — every output JSON-stringified + scanned for forbidden PII keys
|
||||
- **Documentation:** AGENTS.md `lib/audit-query.mjs` new entry; `lib/audit.mjs` note added that D52 extends with daily rotation.
|
||||
- **Test count:** 544 → 571 (+27 D49 tests).
|
||||
- **Authority:** ADR 0008 § 4 (query API surface) + § 5 (rotation file naming pattern) + § 3 (storage layout); ADR 0007 § 8 (audit ndjson event schema — input data); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
|
||||
|
||||
### D48 — ADR 0008 Phase 3 design draft (Dashboard + audit query layer)
|
||||
|
||||
First Phase 3 D-day. Design-only. Ratifies the storage / query model / rotation / dashboard / refresh / scope decisions ahead of D49+ implementation D-days. Opens ADR 0007 § 12 deferral for Dashboard + audit query layer + rotation.
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* lib/audit-query.mjs — OLP audit ndjson aggregate query layer (Phase 3 / D49)
|
||||
*
|
||||
* Authority: ADR 0008 § 4 (query API surface) + § 5 (rotation file naming) +
|
||||
* § 3 (storage layout). Reads `~/.olp/logs/audit.ndjson` (live) +
|
||||
* `audit-YYYY-MM-DD.ndjson` (rotated dailies) and returns aggregate
|
||||
* summaries shaped for the Dashboard endpoints (D50).
|
||||
*
|
||||
* Query model (ADR 0008 Lane 2 = A): in-memory scan per request. O(N) where
|
||||
* N = total lines in the date range. Family-scale acceptable; SQLite hybrid
|
||||
* (ADR 0007 § 13) is the documented forward path when N+queries get slow.
|
||||
*
|
||||
* PII discipline (ADR 0007 § 8 + ADR 0008 § 4.3): event shape is hash + shape
|
||||
* only — no message content, no response content, no raw tokens. This module
|
||||
* MUST NOT introduce derived fields that reveal content. Every aggregate
|
||||
* function asserts the input event has the expected shape but does NOT inspect
|
||||
* or relay message bodies.
|
||||
*
|
||||
* What is NOT in this module (intentional split):
|
||||
* - Daily rotation trigger (D52, lib/audit.mjs extension)
|
||||
* - Server endpoints that consume these queries (D50, server.mjs)
|
||||
* - Dashboard HTML / DOM render (D51, dashboard.html)
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
|
||||
const OLP_HOME_ENV = 'OLP_HOME';
|
||||
const LIVE_AUDIT_FILE = 'audit.ndjson';
|
||||
const ROTATED_FILE_PATTERN = /^audit-(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||
|
||||
// ── Path helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function _resolveOlpHome(opts) {
|
||||
if (opts?.olpHome) return opts.olpHome;
|
||||
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
|
||||
return DEFAULT_OLP_HOME;
|
||||
}
|
||||
|
||||
function _logsDir(opts) {
|
||||
return join(_resolveOlpHome(opts), 'logs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UTC-date string (YYYY-MM-DD) for an ISO-8601 timestamp.
|
||||
*/
|
||||
function _utcDateString(isoTs) {
|
||||
if (typeof isoTs !== 'string' || isoTs.length < 10) return null;
|
||||
return isoTs.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UTC-date string for an epoch-ms.
|
||||
*/
|
||||
function _utcDateFromMs(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive range of UTC date strings from startDate to endDate (both
|
||||
* YYYY-MM-DD). Returns the list in ascending order. Safe for spans up to
|
||||
* several years (no upper bound enforced — caller's responsibility).
|
||||
*/
|
||||
function _dateRange(startDate, endDate) {
|
||||
const dates = [];
|
||||
const cur = new Date(`${startDate}T00:00:00Z`);
|
||||
const end = new Date(`${endDate}T00:00:00Z`);
|
||||
while (cur <= end) {
|
||||
dates.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// ── File enumeration ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discover audit files in the logs directory. Returns a Map from
|
||||
* date-string ('YYYY-MM-DD' or 'live' for the un-rotated file) to absolute
|
||||
* file path. The 'live' entry is `audit.ndjson` if present; date-string
|
||||
* entries are the rotated daily files matching `audit-YYYY-MM-DD.ndjson`.
|
||||
*
|
||||
* Returns an empty Map if the logs directory does not exist or is empty.
|
||||
* Caller responsible for date filtering.
|
||||
*
|
||||
* @param {object} [opts] - { olpHome }
|
||||
* @returns {Map<string, string>} date-string → absolute file path
|
||||
*/
|
||||
export function discoverAuditFiles(opts = {}) {
|
||||
const dir = _logsDir(opts);
|
||||
const out = new Map();
|
||||
if (!existsSync(dir)) return out;
|
||||
let entries;
|
||||
try { entries = readdirSync(dir); } catch { return out; }
|
||||
for (const name of entries) {
|
||||
if (name === LIVE_AUDIT_FILE) {
|
||||
out.set('live', join(dir, name));
|
||||
continue;
|
||||
}
|
||||
const m = ROTATED_FILE_PATTERN.exec(name);
|
||||
if (m) {
|
||||
out.set(m[1], join(dir, name));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Line-level read + parse ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a single ndjson line. Returns the event object on success, or
|
||||
* null on parse error. Caller logs warn for null returns.
|
||||
*/
|
||||
function _parseLine(line) {
|
||||
if (!line) return null;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (typeof obj !== 'object' || obj === null) return null;
|
||||
return obj;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all events from a single file, skipping malformed lines.
|
||||
* Logs warn (via logEvent override or console) for each malformed line so
|
||||
* a corrupted day doesn't kill the query.
|
||||
*
|
||||
* @param {string} path
|
||||
* @param {(level: string, event: string, data?: object) => void} [logEvent]
|
||||
* @returns {Array<object>} parsed events
|
||||
*/
|
||||
function _readFileEvents(path, logEvent) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf-8');
|
||||
} catch (err) {
|
||||
// Re-throw read errors (EACCES, ENOENT during race) so the dashboard
|
||||
// endpoint surfaces 500 with diagnostic per ADR 0008 § 4.4.
|
||||
throw new Error(`audit_query_read_failed: ${path}: ${err?.message ?? err}`);
|
||||
}
|
||||
const lines = raw.split('\n');
|
||||
const events = [];
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const ev = _parseLine(line);
|
||||
if (ev === null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
events.push(ev);
|
||||
}
|
||||
if (skipped > 0 && logEvent) {
|
||||
logEvent('warn', 'audit_query_skip_malformed', { path, skipped });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iterate all audit events in [startMs, endMs). Walks the rotated daily
|
||||
* files whose date overlaps the range + today's live audit.ndjson. Within
|
||||
* each file, includes only events whose `ts` falls in the window.
|
||||
*
|
||||
* Per ADR 0008 § 4.2: window semantics are half-open [start, end).
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.startMs - epoch-ms inclusive lower bound
|
||||
* @param {number} args.endMs - epoch-ms exclusive upper bound
|
||||
* @param {string} [args.olpHome]
|
||||
* @param {(level: string, event: string, data?: object) => void} [args.logEvent]
|
||||
* @yields {object} parsed audit event
|
||||
*/
|
||||
export function* readAuditWindow({ startMs, endMs, olpHome, logEvent } = {}) {
|
||||
if (typeof startMs !== 'number' || typeof endMs !== 'number') {
|
||||
throw new Error('readAuditWindow: startMs and endMs (numbers) are required');
|
||||
}
|
||||
if (endMs <= startMs) return; // empty window
|
||||
|
||||
const files = discoverAuditFiles({ olpHome });
|
||||
if (files.size === 0) return;
|
||||
|
||||
// Walk all dates in [startMs, endMs) plus the live file (today).
|
||||
const startDate = _utcDateFromMs(startMs);
|
||||
const endDate = _utcDateFromMs(endMs - 1); // endMs is exclusive
|
||||
const dateList = _dateRange(startDate, endDate);
|
||||
|
||||
for (const date of dateList) {
|
||||
const path = files.get(date);
|
||||
if (!path) continue;
|
||||
const events = _readFileEvents(path, logEvent);
|
||||
for (const ev of events) {
|
||||
const tsStr = ev.ts;
|
||||
if (typeof tsStr !== 'string') continue;
|
||||
const tsMs = Date.parse(tsStr);
|
||||
if (Number.isNaN(tsMs)) continue;
|
||||
if (tsMs >= startMs && tsMs < endMs) yield ev;
|
||||
}
|
||||
}
|
||||
|
||||
// Live file (today) — always check; date may overlap window's end.
|
||||
const livePath = files.get('live');
|
||||
if (livePath) {
|
||||
const events = _readFileEvents(livePath, logEvent);
|
||||
for (const ev of events) {
|
||||
const tsStr = ev.ts;
|
||||
if (typeof tsStr !== 'string') continue;
|
||||
const tsMs = Date.parse(tsStr);
|
||||
if (Number.isNaN(tsMs)) continue;
|
||||
if (tsMs >= startMs && tsMs < endMs) yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate request shape over a rolling window ending at "now".
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
* window: { startMs, endMs },
|
||||
* request_count, status_2xx, status_4xx, status_5xx,
|
||||
* by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, fallback_count } },
|
||||
* by_owner_tier: { owner: N, guest: N, anonymous: N },
|
||||
* by_path: { '/v1/chat/completions': N, '/v1/models': N, ... },
|
||||
* median_latency_ms, p95_latency_ms,
|
||||
* }
|
||||
*
|
||||
* Per ADR 0008 § 4.1 + § 4.3 PII discipline: aggregates count + categorical
|
||||
* breakdowns only, NEVER message content.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.windowMs - duration in ms; window = [now - windowMs, now)
|
||||
* @param {string} [args.olpHome]
|
||||
* @param {(level, event, data?) => void} [args.logEvent]
|
||||
* @param {() => number} [args._nowFn] - injectable for testing
|
||||
*/
|
||||
export function aggregateRequests({ windowMs, olpHome, logEvent, _nowFn } = {}) {
|
||||
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||
throw new Error('aggregateRequests: windowMs (positive number) is required');
|
||||
}
|
||||
const now = (_nowFn ?? Date.now)();
|
||||
const startMs = now - windowMs;
|
||||
const endMs = now;
|
||||
|
||||
const result = {
|
||||
window: { startMs, endMs },
|
||||
request_count: 0,
|
||||
status_2xx: 0,
|
||||
status_4xx: 0,
|
||||
status_5xx: 0,
|
||||
by_provider: {},
|
||||
by_owner_tier: { owner: 0, guest: 0, anonymous: 0 },
|
||||
by_path: {},
|
||||
median_latency_ms: 0,
|
||||
p95_latency_ms: 0,
|
||||
};
|
||||
const latencies = [];
|
||||
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||
result.request_count++;
|
||||
|
||||
// Status code bucket
|
||||
const sc = typeof ev.status_code === 'number' ? ev.status_code : 0;
|
||||
if (sc >= 200 && sc < 300) result.status_2xx++;
|
||||
else if (sc >= 400 && sc < 500) result.status_4xx++;
|
||||
else if (sc >= 500) result.status_5xx++;
|
||||
|
||||
// By provider
|
||||
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
|
||||
const p = result.by_provider[ev.provider] ??= {
|
||||
count: 0, cache_hit: 0, cache_miss: 0, cache_bypass: 0, fallback_count: 0,
|
||||
};
|
||||
p.count++;
|
||||
if (ev.cache_status === 'hit') p.cache_hit++;
|
||||
else if (ev.cache_status === 'miss') p.cache_miss++;
|
||||
else if (ev.cache_status === 'bypass') p.cache_bypass++;
|
||||
if (typeof ev.fallback_hops === 'number' && ev.fallback_hops > 0) p.fallback_count++;
|
||||
}
|
||||
|
||||
// By owner tier
|
||||
if (ev.owner_tier === 'owner') result.by_owner_tier.owner++;
|
||||
else if (ev.owner_tier === 'guest') result.by_owner_tier.guest++;
|
||||
else result.by_owner_tier.anonymous++;
|
||||
|
||||
// By path
|
||||
if (typeof ev.path === 'string' && ev.path.length > 0) {
|
||||
result.by_path[ev.path] = (result.by_path[ev.path] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Latency
|
||||
if (typeof ev.latency_ms === 'number' && ev.latency_ms >= 0) {
|
||||
latencies.push(ev.latency_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Median + p95 over sorted latencies
|
||||
if (latencies.length > 0) {
|
||||
latencies.sort((a, b) => a - b);
|
||||
const midIdx = Math.floor(latencies.length / 2);
|
||||
result.median_latency_ms = latencies.length % 2 === 0
|
||||
? Math.round((latencies[midIdx - 1] + latencies[midIdx]) / 2)
|
||||
: latencies[midIdx];
|
||||
const p95Idx = Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95));
|
||||
result.p95_latency_ms = latencies[p95Idx];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-N fallback chains by trigger count in window. A "chain" is the
|
||||
* `tried_providers` array from an event with fallback_hops > 0. Returns
|
||||
* sorted array descending by count; ties broken by earliest first_seen.
|
||||
*
|
||||
* [{ chain: ['anthropic', 'openai'], count: 42, first_seen, last_seen }, ...]
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.windowMs
|
||||
* @param {number} [args.limit=10]
|
||||
* @param {string} [args.olpHome]
|
||||
* @param {(level, event, data?) => void} [args.logEvent]
|
||||
* @param {() => number} [args._nowFn]
|
||||
*/
|
||||
export function topFallbackChains({ windowMs, limit = 10, olpHome, logEvent, _nowFn } = {}) {
|
||||
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||
throw new Error('topFallbackChains: windowMs (positive number) is required');
|
||||
}
|
||||
const now = (_nowFn ?? Date.now)();
|
||||
const startMs = now - windowMs;
|
||||
const endMs = now;
|
||||
|
||||
// Map chain-key (joined string) → aggregate
|
||||
const chains = new Map();
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||
if (typeof ev.fallback_hops !== 'number' || ev.fallback_hops <= 0) continue;
|
||||
if (!Array.isArray(ev.tried_providers) || ev.tried_providers.length < 2) continue;
|
||||
const key = ev.tried_providers.join('→');
|
||||
const entry = chains.get(key);
|
||||
const ts = typeof ev.ts === 'string' ? ev.ts : null;
|
||||
if (entry === undefined) {
|
||||
chains.set(key, {
|
||||
chain: [...ev.tried_providers],
|
||||
count: 1,
|
||||
first_seen: ts,
|
||||
last_seen: ts,
|
||||
});
|
||||
} else {
|
||||
entry.count++;
|
||||
if (ts && (!entry.first_seen || ts < entry.first_seen)) entry.first_seen = ts;
|
||||
if (ts && (!entry.last_seen || ts > entry.last_seen)) entry.last_seen = ts;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort desc by count, ascending by first_seen on ties
|
||||
const arr = [...chains.values()];
|
||||
arr.sort((a, b) => {
|
||||
if (b.count !== a.count) return b.count - a.count;
|
||||
if (a.first_seen && b.first_seen) return a.first_seen < b.first_seen ? -1 : a.first_seen > b.first_seen ? 1 : 0;
|
||||
return 0;
|
||||
});
|
||||
return arr.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily series of request_count + median latency_ms + by_provider over N
|
||||
* UTC days ending today. Sparse-fills zero-request days. Returns ascending
|
||||
* by date:
|
||||
*
|
||||
* [{ date: '2026-05-22', request_count, median_latency_ms, by_provider }, ...]
|
||||
*
|
||||
* by_provider is { [providerKey]: count } per day.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.days
|
||||
* @param {string} [args.olpHome]
|
||||
* @param {(level, event, data?) => void} [args.logEvent]
|
||||
* @param {() => number} [args._nowFn]
|
||||
*/
|
||||
export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
|
||||
if (typeof days !== 'number' || days <= 0) {
|
||||
throw new Error('spendTrendDaily: days (positive number) is required');
|
||||
}
|
||||
const now = (_nowFn ?? Date.now)();
|
||||
|
||||
// Compute the N UTC dates ending today (inclusive). Semantics: "last N
|
||||
// calendar dates ending today" — NOT "events within a rolling N*86400-ms
|
||||
// window ago" (the latter would span N+1 distinct UTC dates and produce
|
||||
// off-by-one buckets at non-midnight call times).
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(_utcDateFromMs(now - i * 86400 * 1000));
|
||||
}
|
||||
// Window covers the start of the first date through "now" so readAuditWindow
|
||||
// sees every event whose ts falls in any of the N dates' UTC days.
|
||||
const startMs = Date.parse(`${dates[0]}T00:00:00Z`);
|
||||
const endMs = now;
|
||||
|
||||
// Bucket by UTC date
|
||||
const buckets = new Map();
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||
const date = _utcDateString(ev.ts);
|
||||
if (!date) continue;
|
||||
const b = buckets.get(date) ?? { request_count: 0, latencies: [], by_provider: {} };
|
||||
b.request_count++;
|
||||
if (typeof ev.latency_ms === 'number') b.latencies.push(ev.latency_ms);
|
||||
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
|
||||
b.by_provider[ev.provider] = (b.by_provider[ev.provider] ?? 0) + 1;
|
||||
}
|
||||
buckets.set(date, b);
|
||||
}
|
||||
|
||||
// Sparse-fill using the precomputed dates list (preserves ascending order)
|
||||
return dates.map(date => {
|
||||
const b = buckets.get(date);
|
||||
if (b) {
|
||||
b.latencies.sort((a, b) => a - b);
|
||||
const midIdx = Math.floor(b.latencies.length / 2);
|
||||
const median = b.latencies.length === 0 ? 0
|
||||
: b.latencies.length % 2 === 0
|
||||
? Math.round((b.latencies[midIdx - 1] + b.latencies[midIdx]) / 2)
|
||||
: b.latencies[midIdx];
|
||||
return {
|
||||
date,
|
||||
request_count: b.request_count,
|
||||
median_latency_ms: median,
|
||||
by_provider: b.by_provider,
|
||||
};
|
||||
}
|
||||
return { date, request_count: 0, median_latency_ms: 0, by_provider: {} };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-derived cache hit rate over the window. Differs from
|
||||
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
|
||||
* this is the audit-side rate scoped to the rolling window.
|
||||
*
|
||||
* { window: { startMs, endMs }, total, hit, miss, bypass, hit_rate, by_provider }
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.windowMs
|
||||
* @param {string} [args.olpHome]
|
||||
* @param {(level, event, data?) => void} [args.logEvent]
|
||||
* @param {() => number} [args._nowFn]
|
||||
*/
|
||||
export function cacheHitRateWindow({ windowMs, olpHome, logEvent, _nowFn } = {}) {
|
||||
if (typeof windowMs !== 'number' || windowMs <= 0) {
|
||||
throw new Error('cacheHitRateWindow: windowMs (positive number) is required');
|
||||
}
|
||||
const now = (_nowFn ?? Date.now)();
|
||||
const startMs = now - windowMs;
|
||||
const endMs = now;
|
||||
|
||||
let total = 0, hit = 0, miss = 0, bypass = 0;
|
||||
const by_provider = {};
|
||||
|
||||
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
|
||||
if (ev.cache_status === null || ev.cache_status === undefined) continue;
|
||||
total++;
|
||||
const p = typeof ev.provider === 'string' && ev.provider.length > 0 ? ev.provider : '__unknown__';
|
||||
const pe = by_provider[p] ??= { total: 0, hit: 0, miss: 0, bypass: 0, hit_rate: 0 };
|
||||
pe.total++;
|
||||
if (ev.cache_status === 'hit') { hit++; pe.hit++; }
|
||||
else if (ev.cache_status === 'miss') { miss++; pe.miss++; }
|
||||
else if (ev.cache_status === 'bypass') { bypass++; pe.bypass++; }
|
||||
}
|
||||
|
||||
// Compute hit_rate per provider + overall (excludes bypass from denominator
|
||||
// since bypass-by-cache_control is intentional non-cacheable, not a cache miss).
|
||||
for (const p of Object.values(by_provider)) {
|
||||
const denom = p.hit + p.miss;
|
||||
p.hit_rate = denom > 0 ? p.hit / denom : 0;
|
||||
}
|
||||
const overallDenom = hit + miss;
|
||||
const hit_rate = overallDenom > 0 ? hit / overallDenom : 0;
|
||||
|
||||
return {
|
||||
window: { startMs, endMs },
|
||||
total, hit, miss, bypass, hit_rate, by_provider,
|
||||
};
|
||||
}
|
||||
@@ -10897,3 +10897,450 @@ describe('Suite 22 — D47 keygen CLI (bin/olp-keys.mjs, ADR 0007 § 9.1)', () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 23: D49 lib/audit-query.mjs (Phase 3 audit aggregate query layer) ──
|
||||
//
|
||||
// Unit tests for the in-memory audit query layer per ADR 0008 § 4.
|
||||
// - discoverAuditFiles: filesystem scan + date-suffix recognition
|
||||
// - readAuditWindow: window filtering + cross-file walk + malformed skip
|
||||
// - aggregateRequests: count + median/p95 latency + by_provider + by_owner_tier + by_path
|
||||
// - topFallbackChains: sort + tied-count tiebreak + tried_providers shape
|
||||
// - spendTrendDaily: sparse-fill + UTC day buckets
|
||||
// - cacheHitRateWindow: per-provider + bypass-not-in-denominator
|
||||
// - PII guard: aggregate shapes never include message content
|
||||
|
||||
import {
|
||||
discoverAuditFiles,
|
||||
readAuditWindow,
|
||||
aggregateRequests,
|
||||
topFallbackChains,
|
||||
spendTrendDaily,
|
||||
cacheHitRateWindow,
|
||||
} from './lib/audit-query.mjs';
|
||||
import { mkdirSync, writeFileSync as fsWriteFileSyncForS23 } from 'node:fs';
|
||||
|
||||
describe('Suite 23 — D49 lib/audit-query.mjs (Phase 3 audit aggregate query layer, ADR 0008 § 4)', () => {
|
||||
|
||||
// Helper: write synthetic audit ndjson files to a tmpdir's logs/ subdir.
|
||||
// entries is { 'live': [...events] | 'YYYY-MM-DD': [...events] }
|
||||
function setupAuditFiles(tmp, entries) {
|
||||
const logsDir = _pathJoinForSetup(tmp, 'logs');
|
||||
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
||||
for (const [date, events] of Object.entries(entries)) {
|
||||
const filename = date === 'live' ? 'audit.ndjson' : `audit-${date}.ndjson`;
|
||||
const path = _pathJoinForSetup(logsDir, filename);
|
||||
const lines = events.map(ev => JSON.stringify(ev)).join('\n') + '\n';
|
||||
fsWriteFileSyncForS23(path, lines, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
function makeEvent(overrides = {}) {
|
||||
return {
|
||||
ts: new Date().toISOString(),
|
||||
key_id: 'k1',
|
||||
owner_tier: 'owner',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
status_code: 200,
|
||||
latency_ms: 100,
|
||||
cache_status: 'miss',
|
||||
fallback_hops: 0,
|
||||
tried_providers: ['anthropic'],
|
||||
error_code: null,
|
||||
ir_request_hash: 'abc123',
|
||||
chain_id: 'c1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('23a — discoverAuditFiles', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23a-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23a-1: empty logs dir → empty Map', () => {
|
||||
const r = discoverAuditFiles({ olpHome: TMP });
|
||||
assert.equal(r.size, 0);
|
||||
});
|
||||
|
||||
it('23a-2: only audit.ndjson → Map with "live" key', () => {
|
||||
setupAuditFiles(TMP, { 'live': [makeEvent()] });
|
||||
const r = discoverAuditFiles({ olpHome: TMP });
|
||||
assert.equal(r.size, 1);
|
||||
assert.ok(r.has('live'));
|
||||
});
|
||||
|
||||
it('23a-3: rotated files + live → all recognized', () => {
|
||||
setupAuditFiles(TMP, {
|
||||
'live': [makeEvent()],
|
||||
'2026-05-24': [makeEvent({ ts: '2026-05-24T12:00:00Z' })],
|
||||
'2026-05-23': [makeEvent({ ts: '2026-05-23T12:00:00Z' })],
|
||||
});
|
||||
const r = discoverAuditFiles({ olpHome: TMP });
|
||||
assert.equal(r.size, 3);
|
||||
assert.ok(r.has('live'));
|
||||
assert.ok(r.has('2026-05-24'));
|
||||
assert.ok(r.has('2026-05-23'));
|
||||
});
|
||||
|
||||
it('23a-4: non-audit files ignored', () => {
|
||||
setupAuditFiles(TMP, { 'live': [makeEvent()] });
|
||||
fsWriteFileSyncForS23(_pathJoinForSetup(TMP, 'logs', 'some-random.log'), 'noise\n', { mode: 0o600 });
|
||||
fsWriteFileSyncForS23(_pathJoinForSetup(TMP, 'logs', 'audit-invalid-date.ndjson'), 'noise\n', { mode: 0o600 });
|
||||
const r = discoverAuditFiles({ olpHome: TMP });
|
||||
// live + 3 prior rotated from 23a-3 (state carries within describe block); ignore random files
|
||||
assert.ok(r.size >= 1);
|
||||
for (const key of r.keys()) {
|
||||
assert.ok(key === 'live' || /^\d{4}-\d{2}-\d{2}$/.test(key));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('23b — readAuditWindow', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23b-'));
|
||||
setupAuditFiles(TMP, {
|
||||
'2026-05-23': [
|
||||
makeEvent({ ts: '2026-05-23T10:00:00Z', provider: 'anthropic' }),
|
||||
makeEvent({ ts: '2026-05-23T20:00:00Z', provider: 'openai' }),
|
||||
],
|
||||
'2026-05-24': [
|
||||
makeEvent({ ts: '2026-05-24T10:00:00Z', provider: 'mistral' }),
|
||||
],
|
||||
'live': [
|
||||
makeEvent({ ts: '2026-05-25T10:00:00Z', provider: 'anthropic' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23b-1: window covering all → all 4 events', () => {
|
||||
const start = Date.parse('2026-05-23T00:00:00Z');
|
||||
const end = Date.parse('2026-05-26T00:00:00Z');
|
||||
const events = [...readAuditWindow({ startMs: start, endMs: end, olpHome: TMP })];
|
||||
assert.equal(events.length, 4);
|
||||
});
|
||||
|
||||
it('23b-2: window covering only 2026-05-24 → 1 event (mistral)', () => {
|
||||
const start = Date.parse('2026-05-24T00:00:00Z');
|
||||
const end = Date.parse('2026-05-25T00:00:00Z');
|
||||
const events = [...readAuditWindow({ startMs: start, endMs: end, olpHome: TMP })];
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].provider, 'mistral');
|
||||
});
|
||||
|
||||
it('23b-3: half-open semantics — endMs exclusive', () => {
|
||||
// Event at 2026-05-23T20:00:00Z. Window endMs = exactly that ts → exclude.
|
||||
const start = Date.parse('2026-05-23T00:00:00Z');
|
||||
const end = Date.parse('2026-05-23T20:00:00Z');
|
||||
const events = [...readAuditWindow({ startMs: start, endMs: end, olpHome: TMP })];
|
||||
assert.equal(events.length, 1); // only the 10:00 event
|
||||
assert.equal(events[0].ts, '2026-05-23T10:00:00Z');
|
||||
});
|
||||
|
||||
it('23b-4: empty window (end <= start) → empty', () => {
|
||||
const events = [...readAuditWindow({ startMs: 1000, endMs: 1000, olpHome: TMP })];
|
||||
assert.equal(events.length, 0);
|
||||
const events2 = [...readAuditWindow({ startMs: 2000, endMs: 1000, olpHome: TMP })];
|
||||
assert.equal(events2.length, 0);
|
||||
});
|
||||
|
||||
it('23b-5: missing files → empty iteration (not an error)', () => {
|
||||
const TMP2 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23b5-'));
|
||||
try {
|
||||
const events = [...readAuditWindow({
|
||||
startMs: Date.parse('2026-05-01T00:00:00Z'),
|
||||
endMs: Date.parse('2026-05-02T00:00:00Z'),
|
||||
olpHome: TMP2,
|
||||
})];
|
||||
assert.equal(events.length, 0);
|
||||
} finally {
|
||||
rmSync(TMP2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('23b-6: malformed lines skipped + warn fired', () => {
|
||||
const TMP3 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23b6-'));
|
||||
try {
|
||||
mkdirSync(_pathJoinForSetup(TMP3, 'logs'), { recursive: true, mode: 0o700 });
|
||||
const path = _pathJoinForSetup(TMP3, 'logs', 'audit.ndjson');
|
||||
const lines = [
|
||||
JSON.stringify(makeEvent({ ts: '2026-05-25T10:00:00Z' })),
|
||||
'{ this is not valid json',
|
||||
JSON.stringify(makeEvent({ ts: '2026-05-25T11:00:00Z' })),
|
||||
'',
|
||||
'definitely not json',
|
||||
JSON.stringify(makeEvent({ ts: '2026-05-25T12:00:00Z' })),
|
||||
].join('\n');
|
||||
fsWriteFileSyncForS23(path, lines, { mode: 0o600 });
|
||||
|
||||
let warnFired = false;
|
||||
const events = [...readAuditWindow({
|
||||
startMs: Date.parse('2026-05-25T00:00:00Z'),
|
||||
endMs: Date.parse('2026-05-26T00:00:00Z'),
|
||||
olpHome: TMP3,
|
||||
logEvent: (level, ev) => { if (level === 'warn' && ev === 'audit_query_skip_malformed') warnFired = true; },
|
||||
})];
|
||||
assert.equal(events.length, 3, 'malformed lines skipped, 3 valid events kept');
|
||||
assert.ok(warnFired, 'warn must fire for malformed lines');
|
||||
} finally {
|
||||
rmSync(TMP3, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('23c — aggregateRequests', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23c-'));
|
||||
const now = Date.now();
|
||||
const t = (offsetMs) => new Date(now - offsetMs).toISOString();
|
||||
setupAuditFiles(TMP, {
|
||||
'live': [
|
||||
makeEvent({ ts: t(60000), provider: 'anthropic', cache_status: 'hit', owner_tier: 'owner', status_code: 200, latency_ms: 50 }),
|
||||
makeEvent({ ts: t(50000), provider: 'anthropic', cache_status: 'miss', owner_tier: 'owner', status_code: 200, latency_ms: 150 }),
|
||||
makeEvent({ ts: t(40000), provider: 'openai', cache_status: 'miss', owner_tier: 'guest', status_code: 200, latency_ms: 200 }),
|
||||
makeEvent({ ts: t(30000), provider: 'mistral', cache_status: 'bypass', owner_tier: 'owner', status_code: 401, latency_ms: 10 }),
|
||||
makeEvent({ ts: t(20000), provider: 'anthropic', cache_status: 'miss', owner_tier: 'owner', status_code: 503, latency_ms: 5000, fallback_hops: 1, tried_providers: ['anthropic', 'openai'] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23c-1: aggregateRequests counts requests + status buckets + by_provider', () => {
|
||||
const r = aggregateRequests({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
assert.equal(r.request_count, 5);
|
||||
assert.equal(r.status_2xx, 3); // 200x3
|
||||
assert.equal(r.status_4xx, 1); // 401
|
||||
assert.equal(r.status_5xx, 1); // 503
|
||||
assert.equal(r.by_provider.anthropic.count, 3);
|
||||
assert.equal(r.by_provider.anthropic.cache_hit, 1);
|
||||
assert.equal(r.by_provider.anthropic.cache_miss, 2);
|
||||
assert.equal(r.by_provider.anthropic.fallback_count, 1);
|
||||
assert.equal(r.by_provider.openai.count, 1);
|
||||
assert.equal(r.by_provider.mistral.cache_bypass, 1);
|
||||
});
|
||||
|
||||
it('23c-2: by_owner_tier breakdown', () => {
|
||||
const r = aggregateRequests({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
assert.equal(r.by_owner_tier.owner, 4);
|
||||
assert.equal(r.by_owner_tier.guest, 1);
|
||||
assert.equal(r.by_owner_tier.anonymous, 0);
|
||||
});
|
||||
|
||||
it('23c-3: median + p95 latency over [5, 50, 150, 200, 5000]', () => {
|
||||
const r = aggregateRequests({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
// sorted: 5, 10, 50, 150, 200; we have 5 events with latencies [50, 150, 200, 10, 5000]
|
||||
// sorted: [10, 50, 150, 200, 5000]; median (index 2) = 150
|
||||
assert.equal(r.median_latency_ms, 150);
|
||||
// p95 index = floor(5 * 0.95) = 4 → 5000
|
||||
assert.equal(r.p95_latency_ms, 5000);
|
||||
});
|
||||
|
||||
it('23c-4: throws on invalid windowMs', () => {
|
||||
assert.throws(() => aggregateRequests({ olpHome: TMP }), /windowMs.*required/);
|
||||
assert.throws(() => aggregateRequests({ windowMs: 0, olpHome: TMP }), /windowMs.*required/);
|
||||
assert.throws(() => aggregateRequests({ windowMs: -1, olpHome: TMP }), /windowMs.*required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('23d — topFallbackChains', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23d-'));
|
||||
const now = Date.now();
|
||||
const t = (offsetMs) => new Date(now - offsetMs).toISOString();
|
||||
setupAuditFiles(TMP, {
|
||||
'live': [
|
||||
// 3x anthropic→openai
|
||||
makeEvent({ ts: t(5000), fallback_hops: 1, tried_providers: ['anthropic', 'openai'] }),
|
||||
makeEvent({ ts: t(4000), fallback_hops: 1, tried_providers: ['anthropic', 'openai'] }),
|
||||
makeEvent({ ts: t(3000), fallback_hops: 1, tried_providers: ['anthropic', 'openai'] }),
|
||||
// 2x anthropic→mistral
|
||||
makeEvent({ ts: t(2500), fallback_hops: 1, tried_providers: ['anthropic', 'mistral'] }),
|
||||
makeEvent({ ts: t(2000), fallback_hops: 1, tried_providers: ['anthropic', 'mistral'] }),
|
||||
// 1x openai→mistral (single chain)
|
||||
makeEvent({ ts: t(1000), fallback_hops: 1, tried_providers: ['openai', 'mistral'] }),
|
||||
// events with fallback_hops:0 are excluded
|
||||
makeEvent({ ts: t(500), fallback_hops: 0, tried_providers: ['anthropic'] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23d-1: sorted desc by count', () => {
|
||||
const r = topFallbackChains({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
assert.equal(r.length, 3);
|
||||
assert.equal(r[0].count, 3);
|
||||
assert.deepEqual(r[0].chain, ['anthropic', 'openai']);
|
||||
assert.equal(r[1].count, 2);
|
||||
assert.deepEqual(r[1].chain, ['anthropic', 'mistral']);
|
||||
assert.equal(r[2].count, 1);
|
||||
assert.deepEqual(r[2].chain, ['openai', 'mistral']);
|
||||
});
|
||||
|
||||
it('23d-2: limit argument truncates result', () => {
|
||||
const r = topFallbackChains({ windowMs: 86400 * 1000, limit: 2, olpHome: TMP });
|
||||
assert.equal(r.length, 2);
|
||||
});
|
||||
|
||||
it('23d-3: events with fallback_hops=0 excluded from chains', () => {
|
||||
const r = topFallbackChains({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
const allCounts = r.reduce((s, c) => s + c.count, 0);
|
||||
assert.equal(allCounts, 6); // 3+2+1 — fallback_hops:0 event not counted
|
||||
});
|
||||
|
||||
it('23d-4: chain entries carry first_seen + last_seen', () => {
|
||||
const r = topFallbackChains({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
for (const c of r) {
|
||||
assert.ok(typeof c.first_seen === 'string');
|
||||
assert.ok(typeof c.last_seen === 'string');
|
||||
assert.ok(c.first_seen <= c.last_seen);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('23e — spendTrendDaily', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23e-'));
|
||||
// 3 events on day-2, 1 event on day-0; day-1 has none (sparse fill test)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const day1 = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
const day2 = new Date(Date.now() - 2 * 86400 * 1000).toISOString().slice(0, 10);
|
||||
setupAuditFiles(TMP, {
|
||||
[day2]: [
|
||||
makeEvent({ ts: `${day2}T10:00:00Z`, provider: 'anthropic', latency_ms: 100 }),
|
||||
makeEvent({ ts: `${day2}T11:00:00Z`, provider: 'openai', latency_ms: 200 }),
|
||||
makeEvent({ ts: `${day2}T12:00:00Z`, provider: 'anthropic', latency_ms: 50 }),
|
||||
],
|
||||
'live': [
|
||||
makeEvent({ ts: new Date().toISOString(), provider: 'mistral', latency_ms: 75 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23e-1: 3-day window returns 3 entries (sparse-fills empty days)', () => {
|
||||
const r = spendTrendDaily({ days: 3, olpHome: TMP });
|
||||
assert.equal(r.length, 3);
|
||||
// Each entry has a date + request_count (>=0)
|
||||
for (const e of r) {
|
||||
assert.ok(typeof e.date === 'string');
|
||||
assert.equal(e.date.length, 10);
|
||||
assert.ok(typeof e.request_count === 'number');
|
||||
assert.ok(typeof e.median_latency_ms === 'number');
|
||||
assert.ok(typeof e.by_provider === 'object');
|
||||
}
|
||||
});
|
||||
|
||||
it('23e-2: day with data has correct breakdown', () => {
|
||||
const r = spendTrendDaily({ days: 5, olpHome: TMP });
|
||||
const day2Date = new Date(Date.now() - 2 * 86400 * 1000).toISOString().slice(0, 10);
|
||||
const day2 = r.find(e => e.date === day2Date);
|
||||
assert.ok(day2);
|
||||
assert.equal(day2.request_count, 3);
|
||||
assert.equal(day2.by_provider.anthropic, 2);
|
||||
assert.equal(day2.by_provider.openai, 1);
|
||||
// median of [50, 100, 200] = 100
|
||||
assert.equal(day2.median_latency_ms, 100);
|
||||
});
|
||||
|
||||
it('23e-3: empty day has request_count=0 and empty by_provider', () => {
|
||||
const r = spendTrendDaily({ days: 3, olpHome: TMP });
|
||||
const day1Date = new Date(Date.now() - 86400 * 1000).toISOString().slice(0, 10);
|
||||
const day1 = r.find(e => e.date === day1Date);
|
||||
assert.ok(day1);
|
||||
assert.equal(day1.request_count, 0);
|
||||
assert.deepEqual(day1.by_provider, {});
|
||||
assert.equal(day1.median_latency_ms, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('23f — cacheHitRateWindow', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23f-'));
|
||||
const now = Date.now();
|
||||
const t = (offsetMs) => new Date(now - offsetMs).toISOString();
|
||||
setupAuditFiles(TMP, {
|
||||
'live': [
|
||||
makeEvent({ ts: t(5000), provider: 'anthropic', cache_status: 'hit' }),
|
||||
makeEvent({ ts: t(4000), provider: 'anthropic', cache_status: 'hit' }),
|
||||
makeEvent({ ts: t(3000), provider: 'anthropic', cache_status: 'miss' }),
|
||||
makeEvent({ ts: t(2000), provider: 'openai', cache_status: 'miss' }),
|
||||
makeEvent({ ts: t(1500), provider: 'openai', cache_status: 'bypass' }),
|
||||
// events with cache_status null (e.g., 401 paths) excluded
|
||||
makeEvent({ ts: t(1000), provider: null, cache_status: null }),
|
||||
],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23f-1: hit_rate excludes bypass from denominator', () => {
|
||||
const r = cacheHitRateWindow({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
// overall: hit=2, miss=2, bypass=1 → hit_rate = 2/(2+2) = 0.5
|
||||
assert.equal(r.hit, 2);
|
||||
assert.equal(r.miss, 2);
|
||||
assert.equal(r.bypass, 1);
|
||||
assert.equal(r.hit_rate, 0.5);
|
||||
});
|
||||
|
||||
it('23f-2: per-provider hit_rate', () => {
|
||||
const r = cacheHitRateWindow({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
// anthropic: 2 hit + 1 miss → rate 2/3
|
||||
assert.equal(r.by_provider.anthropic.hit, 2);
|
||||
assert.equal(r.by_provider.anthropic.miss, 1);
|
||||
assert.equal(r.by_provider.anthropic.hit_rate, 2 / 3);
|
||||
// openai: 1 miss + 1 bypass → rate 0/1 = 0
|
||||
assert.equal(r.by_provider.openai.miss, 1);
|
||||
assert.equal(r.by_provider.openai.bypass, 1);
|
||||
assert.equal(r.by_provider.openai.hit_rate, 0);
|
||||
});
|
||||
|
||||
it('23f-3: events with cache_status null excluded from total', () => {
|
||||
const r = cacheHitRateWindow({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
assert.equal(r.total, 5); // 6 events but 1 has cache_status:null
|
||||
});
|
||||
});
|
||||
|
||||
describe('23g — PII guard (ADR 0008 § 4.3)', () => {
|
||||
let TMP;
|
||||
before(() => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-23g-'));
|
||||
setupAuditFiles(TMP, {
|
||||
'live': [makeEvent({ provider: 'anthropic', latency_ms: 100 })],
|
||||
});
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
it('23g-1: aggregateRequests returns NO message-content fields', () => {
|
||||
const r = aggregateRequests({ windowMs: 86400 * 1000, olpHome: TMP });
|
||||
const json = JSON.stringify(r);
|
||||
for (const piiKey of ['content', 'message', 'messages', 'prompt', 'response', 'body']) {
|
||||
assert.ok(!json.includes(`"${piiKey}"`),
|
||||
`aggregateRequests result MUST NOT contain field "${piiKey}" (PII guard)`);
|
||||
}
|
||||
});
|
||||
|
||||
it('23g-2: spendTrendDaily returns NO message-content fields', () => {
|
||||
const r = spendTrendDaily({ days: 1, olpHome: TMP });
|
||||
const json = JSON.stringify(r);
|
||||
for (const piiKey of ['content', 'message', 'messages', 'prompt', 'response', 'body']) {
|
||||
assert.ok(!json.includes(`"${piiKey}"`));
|
||||
}
|
||||
});
|
||||
|
||||
it('23g-3: topFallbackChains + cacheHitRateWindow contain no message fields', () => {
|
||||
const j1 = JSON.stringify(topFallbackChains({ windowMs: 86400 * 1000, olpHome: TMP }));
|
||||
const j2 = JSON.stringify(cacheHitRateWindow({ windowMs: 86400 * 1000, olpHome: TMP }));
|
||||
for (const piiKey of ['content', 'message', 'messages', 'prompt', 'response', 'body']) {
|
||||
assert.ok(!j1.includes(`"${piiKey}"`));
|
||||
assert.ok(!j2.includes(`"${piiKey}"`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user