mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(gateway): thread Vercel Edge ctx through createDomainGateway (#3381) PR-0 of the Axiom usage-telemetry stack. Pure infra change: no telemetry emission yet, only the signature plumbing required for ctx.waitUntil to exist on the hot path. - createDomainGateway returns (req, ctx) instead of (req) - rewriteToSebuf propagates ctx to its target gateway - 5 alias callsites updated to pass ctx through - ~30 [rpc].ts callsites unchanged (export default createDomainGateway(...)) Pattern reference: api/notification-channels.ts:166. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(usage): pure UsageIdentity resolver + Axiom emit primitives (#3381) server/_shared/usage-identity.ts - buildUsageIdentity: pure function, consumes already-resolved gateway state. - Static ENTERPRISE_KEY_TO_CUSTOMER map (explicit, reviewable in code). - Does not re-verify JWTs or re-validate API keys. server/_shared/usage.ts - buildRequestEvent / buildUpstreamEvent: allowlisted-primitive builders only. Never accept Request/Response — additive field leaks become structurally impossible. - emitUsageEvents → ctx.waitUntil(sendToAxiom). Direct fetch, 1.5s timeout, no retry, gated by USAGE_TELEMETRY=1 and AXIOM_API_TOKEN. - Sliding-window circuit breaker (5% over 5min, min 20 samples). Trips with one structured console.error; subsequent drops are 1%-sampled console.warn. - Header derivers reuse Vercel/CF headers for request_id, region, country, reqBytes; ua_hash null unless USAGE_UA_PEPPER is set (no stable fingerprinting). - Dev-only x-usage-telemetry response header for 2-second debugging. server/_shared/auth-session.ts - New resolveClerkSession returning { userId, orgId } in one JWT verify so customer_id can be Clerk org id without a second pass. resolveSessionUserId kept as back-compat wrapper. No emission wiring yet — that lands in the next commit (gateway request event + 403 + 429). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gateway): emit Axiom request events on every return path (#3381) Wires the request-event side of the Axiom usage-telemetry stack. Behind USAGE_TELEMETRY=1 — no-op when the env var is unset. Emit points (each builds identity from accumulated gateway state): - origin_403 disallowed origin → reason=origin_403 - API access subscription required (403) - legacy bearer 401 / 403 / 401-without-bearer - entitlement check fail-through - endpoint rate-limit 429 → reason=rate_limit_429 - global rate-limit 429 → reason=rate_limit_429 - 405 method not allowed - 404 not found - 304 etag match (resolved cache tier) - 200 GET with body (resolved cache tier, real res_bytes) - streaming / non-GET-200 final return (res_bytes best-effort) Identity inputs (UsageIdentityInput): - sessionUserId / clerkOrgId from new resolveClerkSession (one JWT verify) - isUserApiKey + userApiKeyCustomerRef from validateUserApiKey result - enterpriseApiKey when keyCheck.valid + non-wm_ wmKey present - widgetKey from x-widget-key header (best-effort) - tier captured opportunistically from existing getEntitlements calls Header derivers reuse Vercel/CF metadata (x-vercel-id, x-vercel-ip-country, cf-ipcountry, content-length, sentry-trace) — no new geo lookup, no new crypto on the hot path. ua_hash null unless USAGE_UA_PEPPER is set. Dev-only x-usage-telemetry response header (ok | degraded | off) attached on the response paths for 2-second debugging in non-production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(usage): upstream events via implicit request scope (#3381) Closes the upstream-attribution side of the Axiom usage-telemetry stack without requiring leaf-handler changes (per koala's review). server/_shared/usage.ts - AsyncLocalStorage-backed UsageScope: gateway sets it once per request, fetch helpers read from it lazily. Defensive import — if the runtime rejects node:async_hooks, scope helpers degrade to no-ops and the request event is unaffected. - runWithUsageScope(scope, fn) / getUsageScope() exports. server/gateway.ts - Wraps matchedHandler in runWithUsageScope({ ctx, requestId, customerId, route, tier }) so deep fetchers can attribute upstream calls without threading state through every handler signature. server/_shared/redis.ts - cachedFetchJsonWithMeta accepts opts.usage = { provider, operation? }. Only the provider label is required to opt in — request_id / customer_id / route / tier flow implicitly from UsageScope. - Emits on the fresh path only (cache hits don't emit; the inbound request event already records cache_status). - cache_status correctly distinguishes 'miss' vs 'neg-sentinel' by construction, matching NEG_SENTINEL handling. - Telemetry never throws — failures are swallowed in the lazy-import catch, sink itself short-circuits on USAGE_TELEMETRY=0. server/_shared/fetch-json.ts - New optional { provider, operation } in FetchJsonOptions. Same opt-in-by-provider model as cachedFetchJsonWithMeta. Auto-derives host from URL. Reads body via .text() so response_bytes is recorded (best-effort; chunked responses still report 0). Net result: any handler that uses fetchJson or cachedFetchJsonWithMeta gets full per-customer upstream attribution by adding two fields to the options bag. No signature changes anywhere else. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gateway): address round-1 codex feedback on usage telemetry - ctx is now optional on the createDomainGateway handler signature so direct callers (tests, non-Vercel paths) no longer crash on emit - legacy premium bearer-token routes (resilience, shipping-v2) propagate session.userId into the usage accumulator so successful requests are attributed instead of emitting as anon - after checkEntitlement allows a tier-gated route, re-read entitlements (Redis-cached + in-flight coalesced) to populate usage.tier so analyze-stock & co. emit the correct tier rather than 0 - domain extraction now skips a leading vN segment, so /api/v2/shipping/* records domain="shipping" instead of "v2" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(usage): assert telemetry payload + identity resolver + operator guide - tests/usage-telemetry-emission.test.mts stubs globalThis.fetch to capture the Axiom ingest POST body and asserts the four review-flagged fields end-to-end through the gateway: domain on /api/v2/<svc>/* (was "v2"), customer_id on legacy premium bearer success (was null/anon), tier on entitlement-gated success via the Convex fallback path (was 0), plus a ctx-optional regression guard - server/__tests__/usage-identity.test.ts unit-tests the pure buildUsageIdentity() resolver across every auth_kind branch, tier coercion, and the secret-handling invariant (raw enterprise key never lands in any output field) - docs/architecture/usage-telemetry.md is the operator + dev guide: field reference, architecture, configuration, failure modes, local workflow, eight Axiom APL recipes, and runbooks for adding fields / new gateway return paths Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(usage): make recorder.settled robust to nested waitUntil Promise.all(pending) snapshotted the array at call time, missing the inner ctx.waitUntil(sendToAxiom(...)) that emitUsageEvents pushes after the outer drain begins. Tests passed only because the fetch spy resolved in an earlier microtask tick. Replace with a quiescence loop so the helper survives any future async in the emit path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: trigger preview * fix(usage): address koala #3403 review — collapse nested waitUntil, widget-key validation, neg-sentinel status, auth_* reasons P1 - Collapse nested ctx.waitUntil at all 3 emit sites (gateway.ts emitRequest, fetch-json.ts, redis.ts emitUpstreamFromHook). Export sendToAxiom and call it directly inside the outer waitUntil so Edge runtimes don't drop the delivery promise after the response phase. - Validate X-Widget-Key against WIDGET_AGENT_KEY before populating usage.widgetKey so unauthenticated callers can't spoof per-customer attribution. P2 - Emit on OPTIONS preflight (new 'preflight' RequestReason). - Gate cachedFetchJsonWithMeta upstreamStatus=200 on result != null so the neg-sentinel branch no longer reports as a successful upstream call. - Extend RequestReason with auth_401/auth_403/tier_403 and replace reason:'ok' on every auth/tier-rejection emit path. - Replace 32-bit FNV-1a with a two-round XOR-folded 64-bit variant in hashKeySync (collision space matters once widget-key adoption grows). Verification - tests/usage-telemetry-emission.test.mts — 6/6 - tests/premium-stock-gateway.test.mts + tests/gateway-cdn-origin-policy.test.mts — 15/15 - npx vitest run server/__tests__/usage-identity.test.ts — 13/13 - npx tsc --noEmit clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: trigger preview rebuild for AXIOM_API_TOKEN * chore(usage): note Axiom region in ingest URL comment Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * debug(usage): unconditional logs in sendToAxiom for preview troubleshooting Temporary — to be reverted once Axiom delivery is confirmed working in preview. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(usage): add 'live' cache tier + revert preview debug logs - Sync UsageCacheTier with the local CacheTier in gateway.ts (main added 'live' in PR #3402 — synthetic merge with main was failing typecheck:api). - Revert temporary unconditional debug logs in sendToAxiom now that Axiom delivery is verified end-to-end on preview (event landed with all fields populated, including the new auth_401 reason from the koala #3403 fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
/**
|
|
* Axiom-based API usage observability — emit-side primitives.
|
|
*
|
|
* - Builders accept allowlisted primitives only. Never accept Request, Response,
|
|
* or untyped objects: future field additions then leak by structural impossibility.
|
|
* - emitUsageEvents fires via ctx.waitUntil so the Edge isolate cannot tear down
|
|
* the unflushed POST. Direct fetch, 1.5s timeout, no retry.
|
|
* - Circuit breaker (5% failure / 5min sliding window) trips when delivery is broken.
|
|
* - Tripping logs once via console.error; drops thereafter are 1%-sampled console.warn.
|
|
* - Telemetry failure must not affect API availability or latency.
|
|
*
|
|
* Scoped to USAGE attribution. Sentry-edge already covers exceptions — do NOT
|
|
* emit error tracebacks here. Cross-link via sentry_trace_id field instead.
|
|
*/
|
|
|
|
import type { AuthKind } from './usage-identity';
|
|
|
|
const AXIOM_DATASET = 'wm_api_usage';
|
|
// US region endpoint. EU workspaces would use api.eu.axiom.co.
|
|
const AXIOM_INGEST_URL = `https://api.axiom.co/v1/datasets/${AXIOM_DATASET}/ingest`;
|
|
const TELEMETRY_TIMEOUT_MS = 1_500;
|
|
|
|
const CB_WINDOW_MS = 5 * 60 * 1_000;
|
|
const CB_TRIP_FAILURE_RATIO = 0.05;
|
|
const CB_MIN_SAMPLES = 20;
|
|
const SAMPLED_DROP_LOG_RATE = 0.01;
|
|
|
|
function isUsageEnabled(): boolean {
|
|
return process.env.USAGE_TELEMETRY === '1';
|
|
}
|
|
|
|
function isDevHeaderEnabled(): boolean {
|
|
return process.env.NODE_ENV !== 'production';
|
|
}
|
|
|
|
// ---------- Event shapes ----------
|
|
|
|
export type CacheTier =
|
|
| 'fast'
|
|
| 'medium'
|
|
| 'slow'
|
|
| 'slow-browser'
|
|
| 'static'
|
|
| 'daily'
|
|
| 'no-store'
|
|
| 'live';
|
|
|
|
export type CacheStatus = 'miss' | 'fresh' | 'stale-while-revalidate' | 'neg-sentinel';
|
|
|
|
export type ExecutionPlane = 'vercel-edge' | 'vercel-node' | 'railway-relay';
|
|
|
|
export type OriginKind =
|
|
| 'browser-same-origin'
|
|
| 'browser-cross-origin'
|
|
| 'api-key'
|
|
| 'oauth'
|
|
| 'mcp'
|
|
| 'internal-cron';
|
|
|
|
export type RequestReason =
|
|
| 'ok'
|
|
| 'origin_403'
|
|
| 'rate_limit_429'
|
|
| 'preflight'
|
|
| 'auth_401'
|
|
| 'auth_403'
|
|
| 'tier_403';
|
|
|
|
export interface RequestEvent {
|
|
_time: string;
|
|
event_type: 'request';
|
|
request_id: string;
|
|
domain: string;
|
|
route: string;
|
|
method: string;
|
|
status: number;
|
|
duration_ms: number;
|
|
req_bytes: number;
|
|
res_bytes: number;
|
|
customer_id: string | null;
|
|
principal_id: string | null;
|
|
auth_kind: AuthKind;
|
|
tier: number;
|
|
country: string | null;
|
|
execution_region: string | null;
|
|
execution_plane: ExecutionPlane;
|
|
origin_kind: OriginKind | null;
|
|
cache_tier: CacheTier | null;
|
|
ua_hash: string | null;
|
|
sentry_trace_id: string | null;
|
|
reason: RequestReason;
|
|
}
|
|
|
|
export interface UpstreamEvent {
|
|
_time: string;
|
|
event_type: 'upstream';
|
|
request_id: string;
|
|
customer_id: string | null;
|
|
route: string;
|
|
tier: number;
|
|
provider: string;
|
|
operation: string;
|
|
host: string;
|
|
status: number;
|
|
duration_ms: number;
|
|
request_bytes: number;
|
|
response_bytes: number;
|
|
cache_status: CacheStatus;
|
|
}
|
|
|
|
export type UsageEvent = RequestEvent | UpstreamEvent;
|
|
|
|
// ---------- Builders (allowlisted primitives only) ----------
|
|
|
|
export function buildRequestEvent(p: {
|
|
requestId: string;
|
|
domain: string;
|
|
route: string;
|
|
method: string;
|
|
status: number;
|
|
durationMs: number;
|
|
reqBytes: number;
|
|
resBytes: number;
|
|
customerId: string | null;
|
|
principalId: string | null;
|
|
authKind: AuthKind;
|
|
tier: number;
|
|
country: string | null;
|
|
executionRegion: string | null;
|
|
executionPlane: ExecutionPlane;
|
|
originKind: OriginKind | null;
|
|
cacheTier: CacheTier | null;
|
|
uaHash: string | null;
|
|
sentryTraceId: string | null;
|
|
reason: RequestReason;
|
|
}): RequestEvent {
|
|
return {
|
|
_time: new Date().toISOString(),
|
|
event_type: 'request',
|
|
request_id: p.requestId,
|
|
domain: p.domain,
|
|
route: p.route,
|
|
method: p.method,
|
|
status: p.status,
|
|
duration_ms: p.durationMs,
|
|
req_bytes: p.reqBytes,
|
|
res_bytes: p.resBytes,
|
|
customer_id: p.customerId,
|
|
principal_id: p.principalId,
|
|
auth_kind: p.authKind,
|
|
tier: p.tier,
|
|
country: p.country,
|
|
execution_region: p.executionRegion,
|
|
execution_plane: p.executionPlane,
|
|
origin_kind: p.originKind,
|
|
cache_tier: p.cacheTier,
|
|
ua_hash: p.uaHash,
|
|
sentry_trace_id: p.sentryTraceId,
|
|
reason: p.reason,
|
|
};
|
|
}
|
|
|
|
export function buildUpstreamEvent(p: {
|
|
requestId: string;
|
|
customerId: string | null;
|
|
route: string;
|
|
tier: number;
|
|
provider: string;
|
|
operation: string;
|
|
host: string;
|
|
status: number;
|
|
durationMs: number;
|
|
requestBytes: number;
|
|
responseBytes: number;
|
|
cacheStatus: CacheStatus;
|
|
}): UpstreamEvent {
|
|
return {
|
|
_time: new Date().toISOString(),
|
|
event_type: 'upstream',
|
|
request_id: p.requestId,
|
|
customer_id: p.customerId,
|
|
route: p.route,
|
|
tier: p.tier,
|
|
provider: p.provider,
|
|
operation: p.operation,
|
|
host: p.host,
|
|
status: p.status,
|
|
duration_ms: p.durationMs,
|
|
request_bytes: p.requestBytes,
|
|
response_bytes: p.responseBytes,
|
|
cache_status: p.cacheStatus,
|
|
};
|
|
}
|
|
|
|
// ---------- Header-derived helpers (ok to take Request — these only read primitives) ----------
|
|
|
|
export function deriveRequestId(req: Request): string {
|
|
return req.headers.get('x-vercel-id') ?? '';
|
|
}
|
|
|
|
export function deriveExecutionRegion(req: Request): string | null {
|
|
const id = req.headers.get('x-vercel-id');
|
|
if (!id) return null;
|
|
const sep = id.indexOf('::');
|
|
return sep > 0 ? id.slice(0, sep) : null;
|
|
}
|
|
|
|
export function deriveCountry(req: Request): string | null {
|
|
return (
|
|
req.headers.get('x-vercel-ip-country') ??
|
|
req.headers.get('cf-ipcountry') ??
|
|
null
|
|
);
|
|
}
|
|
|
|
export function deriveReqBytes(req: Request): number {
|
|
const len = req.headers.get('content-length');
|
|
if (!len) return 0;
|
|
const n = Number(len);
|
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
}
|
|
|
|
export function deriveSentryTraceId(req: Request): string | null {
|
|
return req.headers.get('sentry-trace') ?? null;
|
|
}
|
|
|
|
// ua_hash: SHA-256(UA + monthly-rotated pepper). Pepper key: USAGE_UA_PEPPER.
|
|
// If the pepper is unset we return null rather than a stable per-browser fingerprint.
|
|
export async function deriveUaHash(req: Request): Promise<string | null> {
|
|
const pepper = process.env.USAGE_UA_PEPPER;
|
|
if (!pepper) return null;
|
|
const ua = req.headers.get('user-agent') ?? '';
|
|
if (!ua) return null;
|
|
const data = new TextEncoder().encode(`${pepper}|${ua}`);
|
|
const buf = await crypto.subtle.digest('SHA-256', data);
|
|
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export function deriveOriginKind(req: Request): OriginKind | null {
|
|
const origin = req.headers.get('origin') ?? '';
|
|
const hasApiKey =
|
|
req.headers.has('x-worldmonitor-key') || req.headers.has('x-api-key');
|
|
const hasBearer = (req.headers.get('authorization') ?? '').startsWith('Bearer ');
|
|
if (hasApiKey) return 'api-key';
|
|
if (hasBearer) return 'oauth';
|
|
if (!origin) return null;
|
|
try {
|
|
const host = new URL(origin).host;
|
|
const reqHost = new URL(req.url).host;
|
|
return host === reqHost ? 'browser-same-origin' : 'browser-cross-origin';
|
|
} catch {
|
|
return 'browser-cross-origin';
|
|
}
|
|
}
|
|
|
|
// ---------- Circuit breaker ----------
|
|
|
|
interface BreakerSample {
|
|
ts: number;
|
|
ok: boolean;
|
|
}
|
|
|
|
const breakerSamples: BreakerSample[] = [];
|
|
let breakerTripped = false;
|
|
let breakerLastNotifyTs = 0;
|
|
|
|
function pruneOldSamples(now: number): void {
|
|
while (breakerSamples.length > 0 && now - breakerSamples[0]!.ts > CB_WINDOW_MS) {
|
|
breakerSamples.shift();
|
|
}
|
|
}
|
|
|
|
function recordSample(ok: boolean): void {
|
|
const now = Date.now();
|
|
pruneOldSamples(now);
|
|
breakerSamples.push({ ts: now, ok });
|
|
|
|
if (breakerSamples.length < CB_MIN_SAMPLES) {
|
|
breakerTripped = false;
|
|
return;
|
|
}
|
|
let failures = 0;
|
|
for (const s of breakerSamples) if (!s.ok) failures++;
|
|
const ratio = failures / breakerSamples.length;
|
|
const wasTripped = breakerTripped;
|
|
breakerTripped = ratio > CB_TRIP_FAILURE_RATIO;
|
|
|
|
if (breakerTripped && !wasTripped && now - breakerLastNotifyTs > CB_WINDOW_MS) {
|
|
breakerLastNotifyTs = now;
|
|
console.error('[usage-telemetry] circuit breaker tripped', {
|
|
ratio: ratio.toFixed(3),
|
|
samples: breakerSamples.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getTelemetryHealth(): 'ok' | 'degraded' | 'off' {
|
|
if (!isUsageEnabled()) return 'off';
|
|
return breakerTripped ? 'degraded' : 'ok';
|
|
}
|
|
|
|
export function maybeAttachDevHealthHeader(headers: Headers): void {
|
|
if (!isDevHeaderEnabled()) return;
|
|
headers.set('x-usage-telemetry', getTelemetryHealth());
|
|
}
|
|
|
|
// ---------- Implicit request scope (AsyncLocalStorage) ----------
|
|
//
|
|
// Per koala's review (#3381), this lets fetch helpers emit upstream events
|
|
// without leaf handlers having to thread a usage hook through every call.
|
|
// The gateway sets the scope before invoking matchedHandler; fetch helpers
|
|
// (fetchJson, cachedFetchJsonWithMeta) read from it lazily.
|
|
//
|
|
// AsyncLocalStorage is loaded defensively. If the runtime ever rejects the
|
|
// import (older Edge versions, sandboxed contexts), the scope helpers
|
|
// degrade to no-ops and telemetry simply skips. The gateway request event
|
|
// is unaffected — it never depended on ALS.
|
|
|
|
export interface UsageScope {
|
|
ctx: WaitUntilCtx;
|
|
requestId: string;
|
|
customerId: string | null;
|
|
route: string;
|
|
tier: number;
|
|
}
|
|
|
|
type ALSLike<T> = {
|
|
run: <R>(store: T, fn: () => R) => R;
|
|
getStore: () => T | undefined;
|
|
};
|
|
|
|
let scopeStore: ALSLike<UsageScope> | null = null;
|
|
|
|
async function getScopeStore(): Promise<ALSLike<UsageScope> | null> {
|
|
if (scopeStore) return scopeStore;
|
|
try {
|
|
const mod = await import('node:async_hooks');
|
|
scopeStore = new mod.AsyncLocalStorage<UsageScope>();
|
|
return scopeStore;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function runWithUsageScope<R>(scope: UsageScope, fn: () => R | Promise<R>): Promise<R> {
|
|
const store = await getScopeStore();
|
|
if (!store) return fn();
|
|
return store.run(scope, fn) as R | Promise<R>;
|
|
}
|
|
|
|
export function getUsageScope(): UsageScope | undefined {
|
|
return scopeStore?.getStore();
|
|
}
|
|
|
|
// ---------- Sink ----------
|
|
|
|
export async function sendToAxiom(events: UsageEvent[]): Promise<void> {
|
|
if (!isUsageEnabled()) return;
|
|
if (events.length === 0) return;
|
|
const token = process.env.AXIOM_API_TOKEN;
|
|
if (!token) {
|
|
if (Math.random() < SAMPLED_DROP_LOG_RATE) {
|
|
console.warn('[usage-telemetry] drop', { reason: 'no-token' });
|
|
}
|
|
return;
|
|
}
|
|
if (breakerTripped) {
|
|
if (Math.random() < SAMPLED_DROP_LOG_RATE) {
|
|
console.warn('[usage-telemetry] drop', { reason: 'breaker-open' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
try {
|
|
const resp = await fetch(AXIOM_INGEST_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(events),
|
|
signal: controller.signal,
|
|
});
|
|
if (!resp.ok) {
|
|
recordSample(false);
|
|
if (Math.random() < SAMPLED_DROP_LOG_RATE) {
|
|
console.warn('[usage-telemetry] drop', { reason: `http-${resp.status}` });
|
|
}
|
|
return;
|
|
}
|
|
recordSample(true);
|
|
} catch (err) {
|
|
recordSample(false);
|
|
if (Math.random() < SAMPLED_DROP_LOG_RATE) {
|
|
const reason = err instanceof Error && err.name === 'AbortError' ? 'timeout' : 'fetch-error';
|
|
console.warn('[usage-telemetry] drop', { reason });
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export interface WaitUntilCtx {
|
|
waitUntil: (p: Promise<unknown>) => void;
|
|
}
|
|
|
|
export function emitUsageEvents(ctx: WaitUntilCtx, events: UsageEvent[]): void {
|
|
if (!isUsageEnabled() || events.length === 0) return;
|
|
ctx.waitUntil(sendToAxiom(events));
|
|
}
|
|
|
|
// Variant that returns the in-flight delivery promise instead of registering
|
|
// it on a context. Use when the caller is already inside a single
|
|
// ctx.waitUntil() chain and wants to await delivery synchronously to avoid a
|
|
// nested waitUntil registration (which Edge runtimes may drop).
|
|
export function deliverUsageEvents(events: UsageEvent[]): Promise<void> {
|
|
if (!isUsageEnabled() || events.length === 0) return Promise.resolve();
|
|
return sendToAxiom(events);
|
|
}
|