From 56dbaf476764b221e0f115d1afc218e85a254a55 Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Tue, 7 Jul 2026 13:52:30 +0100 Subject: [PATCH] Add core observability events --- .changeset/structured-observability-events.md | 5 + .../src/content/docs/api/http-client.mdx | 163 ++-- docs-site/src/content/docs/api/utilities.mdx | 32 +- .../src/content/docs/dashboard/overview.mdx | 2 +- packages/core/README.md | 77 +- .../core/src/http-client/http-client.test.ts | 736 ++++++++++++++++++ packages/core/src/http-client/http-client.ts | 602 +++++++++++++- packages/core/src/types/index.ts | 1 + packages/core/src/types/observability.ts | 174 +++++ 9 files changed, 1680 insertions(+), 112 deletions(-) create mode 100644 .changeset/structured-observability-events.md create mode 100644 packages/core/src/types/observability.ts diff --git a/.changeset/structured-observability-events.md b/.changeset/structured-observability-events.md new file mode 100644 index 0000000..dfabace --- /dev/null +++ b/.changeset/structured-observability-events.md @@ -0,0 +1,5 @@ +--- +'@http-client-toolkit/core': minor +--- + +Add structured observability lifecycle events for requests, cache, dedupe, rate limiting, server cooldowns, and retries via `HttpClientOptions.observability.onEvent`. diff --git a/docs-site/src/content/docs/api/http-client.mdx b/docs-site/src/content/docs/api/http-client.mdx index 6ef7bb8..5bcafbf 100644 --- a/docs-site/src/content/docs/api/http-client.mdx +++ b/docs-site/src/content/docs/api/http-client.mdx @@ -3,7 +3,6 @@ title: HttpClient description: API reference for the HttpClient class --- - The main client class that orchestrates caching, deduplication, and rate limiting. ## Import @@ -15,48 +14,49 @@ import { HttpClient } from '@http-client-toolkit/core'; ## Constructor ```typescript -new HttpClient(options) +new HttpClient(options); ``` ### Options `name` is required. All other fields are optional — pass only what you need. -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `name` | `string` | required | Name for the client instance | -| `cache` | `HttpClientCacheOptions` | — | Cache configuration (see below) | -| `dedupe` | `DedupeStore` | — | Request deduplication | -| `rateLimit` | `HttpClientRateLimitOptions` | — | Rate limit configuration (see below) | -| `resourceKeyResolver` | `(url: string) => string` | URL origin | Resolve the logical rate-limit resource key for a URL | -| `fetchFn` | `(url: string, init?: RequestInit) => Promise` | `globalThis.fetch` | Custom fetch implementation | -| `requestInterceptor` | `(url: string, init: RequestInit) => Promise \| RequestInit` | — | Pre-request hook to modify the outgoing request | -| `responseInterceptor` | `(response: Response, url: string) => Promise \| Response` | — | Post-response hook to inspect/modify the raw Response | -| `responseTransformer` | `(data: unknown) => unknown` | — | Transform parsed response data before caching (e.g. snake_case to camelCase) | -| `responseHandler` | `(data: unknown) => unknown` | — | Post-transformation hook for validation or domain-level error detection. Throw to reject 2xx responses with application-level errors | -| `errorHandler` | `(context: HttpErrorContext) => Error` | — | Convert HTTP errors to domain-specific types. Context includes `url`, response `status`, parsed `data`, and `headers`. Not called for network failures | -| `retry` | `RetryOptions \| false` | — | Automatic retry configuration. See [Retries guide](/http-client-toolkit/guides/retries/) | +| Property | Type | Default | Description | +| --------------------- | ------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | `string` | required | Name for the client instance | +| `cache` | `HttpClientCacheOptions` | — | Cache configuration (see below) | +| `dedupe` | `DedupeStore` | — | Request deduplication | +| `rateLimit` | `HttpClientRateLimitOptions` | — | Rate limit configuration (see below) | +| `resourceKeyResolver` | `(url: string) => string` | URL origin | Resolve the logical rate-limit resource key for a URL | +| `fetchFn` | `(url: string, init?: RequestInit) => Promise` | `globalThis.fetch` | Custom fetch implementation | +| `requestInterceptor` | `(url: string, init: RequestInit) => Promise \| RequestInit` | — | Pre-request hook to modify the outgoing request | +| `responseInterceptor` | `(response: Response, url: string) => Promise \| Response` | — | Post-response hook to inspect/modify the raw Response | +| `responseTransformer` | `(data: unknown) => unknown` | — | Transform parsed response data before caching (e.g. snake_case to camelCase) | +| `responseHandler` | `(data: unknown) => unknown` | — | Post-transformation hook for validation or domain-level error detection. Throw to reject 2xx responses with application-level errors | +| `errorHandler` | `(context: HttpErrorContext) => Error` | — | Convert HTTP errors to domain-specific types. Context includes `url`, response `status`, parsed `data`, and `headers`. Not called for network failures | +| `retry` | `RetryOptions \| false` | — | Automatic retry configuration. See [Retries guide](/http-client-toolkit/guides/retries/) | +| `observability` | `{ onEvent?: (event: HttpClientEvent) => void \| Promise }` | — | Subscribe to read-only structured lifecycle events | #### `cache` Options (`HttpClientCacheOptions`) -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `store` | `CacheStore` | required | Cache store instance | -| `globalScope` | `boolean` | `false` | When `true`, cache keys are not prefixed with the client name. By default, keys are prefixed with `name:` to isolate each client's cache entries | -| `ttl` | `number` | `3600` | Cache TTL in seconds. Used when response has no cache headers | -| `overrides` | `CacheOverrideOptions` | — | Override specific cache header behaviors (see below) | +| Property | Type | Default | Description | +| ------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `store` | `CacheStore` | required | Cache store instance | +| `globalScope` | `boolean` | `false` | When `true`, cache keys are not prefixed with the client name. By default, keys are prefixed with `name:` to isolate each client's cache entries | +| `ttl` | `number` | `3600` | Cache TTL in seconds. Used when response has no cache headers | +| `overrides` | `CacheOverrideOptions` | — | Override specific cache header behaviors (see below) | #### `rateLimit` Options (`HttpClientRateLimitOptions`) -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `store` | `RateLimitStore \| AdaptiveRateLimitStore` | — | Rate limit store instance (optional — server cooldown logic works without a store) | -| `throw` | `boolean` | `true` | Throw when rate limited vs. wait | -| `maxWaitTime` | `number` | `60000` | Max wait time in ms before throwing | -| `headers` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | -| `resourceExtractor` | `(url: string) => string` | URL origin | Deprecated. Use `resourceKeyResolver` instead | -| `configs` | `RateLimitConfigMap` | — | Per-resource rate limit configurations | -| `defaultConfig` | `RateLimitConfig` | — | Fallback rate limit config when no per-resource config matches | +| Property | Type | Default | Description | +| ------------------- | ------------------------------------------ | ---------- | ---------------------------------------------------------------------------------- | +| `store` | `RateLimitStore \| AdaptiveRateLimitStore` | — | Rate limit store instance (optional — server cooldown logic works without a store) | +| `throw` | `boolean` | `true` | Throw when rate limited vs. wait | +| `maxWaitTime` | `number` | `60000` | Max wait time in ms before throwing | +| `headers` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | +| `resourceExtractor` | `(url: string) => string` | URL origin | Deprecated. Use `resourceKeyResolver` instead | +| `configs` | `RateLimitConfigMap` | — | Per-resource rate limit configurations | +| `defaultConfig` | `RateLimitConfig` | — | Fallback rate limit config when no per-resource config matches | `resourceKeyResolver` applies everywhere the client performs rate-limit accounting, including store checks and server cooldowns. `rateLimit.resourceExtractor` is deprecated, retained for compatibility, and only used when `resourceKeyResolver` is not provided. @@ -76,13 +76,13 @@ The `url` must be an absolute URL (e.g. `https://api.example.com/items`). **Request Options** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `signal` | `AbortSignal` | — | Cancels wait + request when aborted | -| `priority` | `'user' \| 'background'` | `'background'` | Used by adaptive rate-limit stores | -| `headers` | `Record` | — | Custom headers sent with the request; also used for Vary-based cache matching | -| `retry` | `RetryOptions \| false` | — | Per-request retry override. Pass `false` to disable retries for this request | -| `cache` | `{ ttl?: number; overrides?: CacheOverrideOptions; tags?: string[] }` | — | Per-request cache options. `ttl` overrides constructor TTL; `overrides` are shallow-merged with constructor-level; `tags` associates the cached entry with tags for later invalidation | +| Property | Type | Default | Description | +| ---------- | --------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `signal` | `AbortSignal` | — | Cancels wait + request when aborted | +| `priority` | `'user' \| 'background'` | `'background'` | Used by adaptive rate-limit stores | +| `headers` | `Record` | — | Custom headers sent with the request; also used for Vary-based cache matching | +| `retry` | `RetryOptions \| false` | — | Per-request retry override. Pass `false` to disable retries for this request | +| `cache` | `{ ttl?: number; overrides?: CacheOverrideOptions; tags?: string[] }` | — | Per-request cache options. `ttl` overrides constructor TTL; `overrides` are shallow-merged with constructor-level; `tags` associates the cached entry with tags for later invalidation | ## Request Flow @@ -100,15 +100,78 @@ When `client.get(url)` is called, the request passes through each configured lay See the [Interceptors guide](/http-client-toolkit/guides/interceptors/) for detailed usage. +## Observability + +Use `observability.onEvent` to collect request lifecycle events for logs, metrics, or tracing without wrapping internal stores or fetch calls. + +```typescript +import { HttpClient, type HttpClientEvent } from '@http-client-toolkit/core'; + +const client = new HttpClient({ + name: 'catalog-api', + observability: { + onEvent(event: HttpClientEvent) { + logger.info({ event }, event.type); + + if (event.type === 'request:success') { + metrics.histogram('http_client_duration_ms', event.durationMs, { + clientName: event.clientName, + status: String(event.status ?? 'unknown'), + }); + } + }, + }, +}); +``` + +`onEvent` is a read-only observer. It is not an interceptor or middleware hook: +return values are ignored, synchronous errors are swallowed, and rejected +promises are caught without awaiting them in the hot request path. Keep +synchronous observer work lightweight because it runs inline before any returned +promise is detached. Observers cannot change the request result or thrown error. + +### Event Types + +| Event | When it emits | +| -------------------- | -------------------------------------------------------------------------------------------------- | +| `request:start` | A request begins, before cache, dedupe, rate-limit, or cooldown checks | +| `request:success` | A request resolves, including cache-served and deduped responses | +| `request:error` | A final request error is about to be thrown | +| `cache:hit` | A fresh, `no-cache` override, stale-while-revalidate, or stale-if-error value is served from cache | +| `cache:miss` | No usable cache entry is available, including Vary mismatches | +| `cache:stale` | A cached entry exists but needs revalidation or fallback handling | +| `cache:revalidate` | Foreground or background cache revalidation is scheduled, succeeds, returns 304, or errors | +| `dedupe:owner` | This caller owns the upstream request for a dedupe key | +| `dedupe:join` | This caller joins or receives an existing deduped request result | +| `rateLimit:wait` | The client waits for store-based limits or server cooldowns | +| `rateLimit:throw` | The client throws because a store limit or server cooldown blocks the request | +| `serverCooldown:set` | Response headers set server cooldown state | +| `retry:scheduled` | A retryable error schedules another attempt | +| `retry:exhausted` | A retry sequence reaches its final failed attempt | + +All events include stable public fields such as `type`, `clientName`, +`requestId`, `url`, `method`, `resourceKey`, and `timestamp`. Events add +context-specific fields like `attempt`, `durationMs`, `status`, `error`, +`cacheKey`, and `waitMs` where applicable. These payloads are public API. + +Attempt numbers are emitted on retry events (`retry:scheduled` and +`retry:exhausted`). Final `request:success` and `request:error` events describe +the logical request outcome and do not include a fetch attempt count. + +Bridge `onEvent` to your logger, metrics client, or OpenTelemetry instrumentation +by translating events into log records, counters, histograms, span events, or +attributes. OpenTelemetry is intentionally not a dependency of +`@http-client-toolkit/core`. + ## Cache TTL Semantics Consistent across all built-in stores: -| Value | Behavior | -|-------|----------| -| `ttlSeconds > 0` | Expires after N seconds | +| Value | Behavior | +| ------------------ | ------------------------- | +| `ttlSeconds > 0` | Expires after N seconds | | `ttlSeconds === 0` | Never expires (permanent) | -| `ttlSeconds < 0` | Immediately expired | +| `ttlSeconds < 0` | Immediately expired | ## Header-Based Rate Limiting @@ -121,6 +184,7 @@ The client respects these headers out of the box: - Combined structured `RateLimit` (e.g. `"default";r=0;t=30`) Cooldowns are enforced when: + - The response is a throttling status (`429` or `503`), **or** - Remaining quota is explicitly exhausted (`remaining <= 0`) @@ -145,12 +209,12 @@ The client respects `Cache-Control`, `ETag`, `Last-Modified`, and `Expires` head ### `cacheOverrides` -| Property | Type | Description | -|----------|------|-------------| -| `ignoreNoStore` | `boolean` | Cache responses even when `no-store` is set | +| Property | Type | Description | +| --------------- | --------- | --------------------------------------------- | +| `ignoreNoStore` | `boolean` | Cache responses even when `no-store` is set | | `ignoreNoCache` | `boolean` | Skip revalidation even when `no-cache` is set | -| `minimumTTL` | `number` | Floor on header-derived freshness (seconds) | -| `maximumTTL` | `number` | Cap on header-derived freshness (seconds) | +| `minimumTTL` | `number` | Floor on header-derived freshness (seconds) | +| `maximumTTL` | `number` | Cap on header-derived freshness (seconds) | ### `invalidateByTag(tag)` @@ -181,7 +245,10 @@ await client.flushRevalidations(); ### Cache-Only Client ```typescript -const client = new HttpClient({ name: 'my-api', cache: { store: new InMemoryCacheStore() } }); +const client = new HttpClient({ + name: 'my-api', + cache: { store: new InMemoryCacheStore() }, +}); ``` ### Full Stack with Adaptive Rate Limiting diff --git a/docs-site/src/content/docs/api/utilities.mdx b/docs-site/src/content/docs/api/utilities.mdx index f7156a4..b40f91d 100644 --- a/docs-site/src/content/docs/api/utilities.mdx +++ b/docs-site/src/content/docs/api/utilities.mdx @@ -3,7 +3,6 @@ title: Utilities description: API reference for hashRequest, HttpClientError, and other utilities --- - ## hashRequest Generates deterministic SHA-256 hashes for cache and deduplication keys. @@ -35,9 +34,9 @@ import { HttpClientError } from '@http-client-toolkit/core'; ### Properties -| Property | Type | Description | -|----------|------|-------------| -| `message` | `string` | Error description | +| Property | Type | Description | +| ------------ | --------------------- | ------------------------------------ | +| `message` | `string` | Error description | | `statusCode` | `number \| undefined` | HTTP status code (e.g. `404`, `500`) | ### Usage @@ -47,7 +46,7 @@ try { await client.get(url); } catch (error) { if (error instanceof HttpClientError) { - console.log(error.message); // "Not Found" + console.log(error.message); // "Not Found" console.log(error.statusCode); // 404 } } @@ -57,14 +56,15 @@ try { The `@http-client-toolkit/core` package exports: -| Export | Type | Description | -|--------|------|-------------| -| `HttpClient` | Class | Main client class | -| `HttpClientError` | Class | Error class with `statusCode` | -| `hashRequest` | Function | Deterministic SHA-256 request hashing | -| `HttpClientCacheOptions` | Interface | Cache configuration for `HttpClientOptions.cache` | -| `HttpClientRateLimitOptions` | Interface | Rate limit configuration for `HttpClientOptions.rateLimit` | -| `CacheStore` | Interface | Cache store contract | -| `DedupeStore` | Interface | Deduplication store contract | -| `RateLimitStore` | Interface | Rate limit store contract | -| `AdaptiveRateLimitStore` | Interface | Adaptive rate limit store contract | +| Export | Type | Description | +| ---------------------------- | --------- | ------------------------------------------------------------------- | +| `HttpClient` | Class | Main client class | +| `HttpClientError` | Class | Error class with `statusCode` | +| `HttpClientEvent` | Type | Stable structured lifecycle event union for `observability.onEvent` | +| `hashRequest` | Function | Deterministic SHA-256 request hashing | +| `HttpClientCacheOptions` | Interface | Cache configuration for `HttpClientOptions.cache` | +| `HttpClientRateLimitOptions` | Interface | Rate limit configuration for `HttpClientOptions.rateLimit` | +| `CacheStore` | Interface | Cache store contract | +| `DedupeStore` | Interface | Deduplication store contract | +| `RateLimitStore` | Interface | Rate limit store contract | +| `AdaptiveRateLimitStore` | Interface | Adaptive rate limit store contract | diff --git a/docs-site/src/content/docs/dashboard/overview.mdx b/docs-site/src/content/docs/dashboard/overview.mdx index 449e7ff..97faabc 100644 --- a/docs-site/src/content/docs/dashboard/overview.mdx +++ b/docs-site/src/content/docs/dashboard/overview.mdx @@ -250,4 +250,4 @@ The dashboard exposes a REST API that the React SPA consumes. All store endpoint ## Future Improvements -- **Retry metrics** — Show requests that required retries and their success rate. The core `HttpClient` retry loop currently doesn't persist metrics, so this would require adding internal retry tracking (total attempts, successes after retry, final failures) and exposing it via a new dashboard adapter. +- **Retry metrics** — Show requests that required retries and their success rate by collecting `retry:scheduled`, `retry:exhausted`, `request:success`, and `request:error` events from `HttpClient` observability and exposing aggregated metrics through a dashboard adapter. diff --git a/packages/core/README.md b/packages/core/README.md index 9b8ff0f..d4e8a90 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -82,22 +82,23 @@ Create a thin wrapper module per third-party API so callers don't configure anyt **Constructor options:** -| Property | Type | Default | Description | -| --------------------- | ------------------------------------------ | ---------- | -------------------------------------------------- | -| `name` | `string` | required | Name for the client instance | -| `cache` | `CacheStore` | - | Response caching | -| `dedupe` | `DedupeStore` | - | Request deduplication | -| `rateLimit` | `RateLimitStore \| AdaptiveRateLimitStore` | - | Rate limiting | -| `cacheTTL` | `number` | `3600` | Cache TTL when response has no headers | -| `throwOnRateLimit` | `boolean` | `true` | Throw when rate limited vs. wait | -| `maxWaitTime` | `number` | `60000` | Max wait time (ms) before throwing | -| `responseTransformer` | `(data: unknown) => unknown` | - | Transform raw response data | -| `responseHandler` | `(data: unknown) => unknown` | - | Validate/process transformed data | -| `errorHandler` | `(error: unknown) => Error` | - | Convert errors to domain-specific types | -| `cacheOverrides` | `CacheOverrideOptions` | - | Override cache header behaviors | -| `retry` | `RetryOptions \| false` | - | Retry config; `false` disables globally | -| `rateLimitHeaders` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | -| `resourceKeyResolver` | `(url: string) => string` | URL origin | Customize how rate-limit resource keys are derived | +| Property | Type | Default | Description | +| --------------------- | ----------------------------------------------------------------- | ---------- | -------------------------------------------------- | +| `name` | `string` | required | Name for the client instance | +| `cache` | `CacheStore` | - | Response caching | +| `dedupe` | `DedupeStore` | - | Request deduplication | +| `rateLimit` | `RateLimitStore \| AdaptiveRateLimitStore` | - | Rate limiting | +| `cacheTTL` | `number` | `3600` | Cache TTL when response has no headers | +| `throwOnRateLimit` | `boolean` | `true` | Throw when rate limited vs. wait | +| `maxWaitTime` | `number` | `60000` | Max wait time (ms) before throwing | +| `responseTransformer` | `(data: unknown) => unknown` | - | Transform raw response data | +| `responseHandler` | `(data: unknown) => unknown` | - | Validate/process transformed data | +| `errorHandler` | `(error: unknown) => Error` | - | Convert errors to domain-specific types | +| `cacheOverrides` | `CacheOverrideOptions` | - | Override cache header behaviors | +| `retry` | `RetryOptions \| false` | - | Retry config; `false` disables globally | +| `rateLimitHeaders` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | +| `resourceKeyResolver` | `(url: string) => string` | URL origin | Customize how rate-limit resource keys are derived | +| `observability` | `{ onEvent?: (event: HttpClientEvent) => void \| Promise }` | - | Subscribe to structured lifecycle events | ### Request Flow @@ -108,6 +109,49 @@ Create a thin wrapper module per third-party API so callers don't configure anyt 5. **Transform & Validate** - Apply `responseTransformer` then `responseHandler` 6. **Store** - Cache the result, record the rate limit hit, and resolve any deduplicated waiters +### Observability + +Use `observability.onEvent` to collect structured lifecycle events without wrapping internal stores or fetch calls: + +```typescript +import { HttpClient, type HttpClientEvent } from '@http-client-toolkit/core'; + +const client = new HttpClient({ + name: 'catalog-api', + observability: { + onEvent(event: HttpClientEvent) { + logger.info({ event }, event.type); + + if (event.type === 'request:success') { + metrics.histogram('http_client_duration_ms', event.durationMs, { + clientName: event.clientName, + status: String(event.status ?? 'unknown'), + }); + } + }, + }, +}); +``` + +Events cover request start/success/error, cache hits/misses/stale/revalidation, +dedupe ownership and joins, rate-limit waits/throws, server cooldown updates, +and retry scheduling/exhaustion. Payloads include stable public fields such as +`clientName`, `requestId`, `url`, `method`, `resourceKey`, `timestamp`, +`attempt`, `durationMs`, `status`, `error`, `cacheKey`, and `waitMs` where they +apply. + +Attempt numbers are emitted on retry events (`retry:scheduled` and +`retry:exhausted`). Final `request:success` and `request:error` events describe +the logical request outcome and do not include a fetch attempt count. + +Bridge `onEvent` to your logger, metrics client, or OpenTelemetry instrumentation +by translating these events into log records, counters, histograms, span events, +or attributes. OpenTelemetry is intentionally not a dependency of core. + +Observer return values are ignored, observer errors are swallowed, and returned +promises are not awaited; observers cannot change the request result or thrown +error. Keep synchronous observer work lightweight because it runs inline. + ### Error Handling All HTTP errors are wrapped in `HttpClientError`: @@ -184,6 +228,7 @@ compatibility. - `HttpClient` - Main client class - `HttpClientError` - Error class with `statusCode` +- `HttpClientEvent` - Stable structured lifecycle event union - `hashRequest` - Deterministic SHA-256 request hashing - Store interfaces: `CacheStore`, `DedupeStore`, `RateLimitStore`, `AdaptiveRateLimitStore` diff --git a/packages/core/src/http-client/http-client.test.ts b/packages/core/src/http-client/http-client.test.ts index ebcec7c..fbe0d1f 100644 --- a/packages/core/src/http-client/http-client.test.ts +++ b/packages/core/src/http-client/http-client.test.ts @@ -3,6 +3,7 @@ import { HttpClient } from './http-client.js'; import { isCacheEntry, type CacheEntry } from '../cache/index.js'; import { HttpClientError } from '../errors/http-client-error.js'; import { hashRequest } from '../stores/index.js'; +import type { HttpClientEvent } from '../types/index.js'; const baseUrl = 'https://api.example.com'; const alternateBaseUrl = 'https://api-alt.example.com'; @@ -690,6 +691,741 @@ describe('HttpClient', () => { expect(result.ok).toBe(true); }); + describe('observability', () => { + function collectEvents() { + const events: Array = []; + return { + events, + observability: { + onEvent: (event: HttpClientEvent) => { + events.push(event); + }, + }, + }; + } + + function makeCacheStore() { + const store = new Map(); + return { + async get(hash: string) { + return store.get(hash); + }, + async set(hash: string, value: unknown) { + store.set(hash, value); + }, + async setWithTags(hash: string, value: unknown) { + store.set(hash, value); + }, + async invalidateByTag() { + return 0; + }, + async invalidateByTags() { + return 0; + }, + async delete(hash: string) { + store.delete(hash); + }, + async clear() { + store.clear(); + }, + _store: store, + }; + } + + test('emits request lifecycle events in order for a normal request', async () => { + const observed = collectEvents(); + nock(baseUrl).get('/observed-normal').reply(200, { ok: true }); + + const client = new HttpClient({ + name: 'observed', + observability: observed.observability, + }); + + const result = await client.get<{ ok: boolean }>( + `${baseUrl}/observed-normal`, + ); + + expect(result).toEqual({ ok: true }); + expect(observed.events.map((event) => event.type)).toEqual([ + 'request:start', + 'request:success', + ]); + const [start, success] = observed.events; + expect(start).toMatchObject({ + type: 'request:start', + clientName: 'observed', + url: `${baseUrl}/observed-normal`, + method: 'GET', + resourceKey: baseUrl, + }); + expect(success).toMatchObject({ + type: 'request:success', + requestId: start!.requestId, + status: 200, + }); + expect(success!.timestamp).toBeGreaterThanOrEqual(start!.timestamp); + }); + + test('emits cache miss and hit events', async () => { + const observed = collectEvents(); + const cache = makeCacheStore(); + + nock(baseUrl) + .get('/observed-cache') + .reply(200, { id: 1 }, { 'Cache-Control': 'max-age=60' }); + + const client = new HttpClient({ + name: 'observed', + cache: { store: cache }, + observability: observed.observability, + }); + + await client.get(`${baseUrl}/observed-cache`); + await client.get(`${baseUrl}/observed-cache`); + + expect(observed.events.map((event) => event.type)).toEqual([ + 'request:start', + 'cache:miss', + 'request:success', + 'request:start', + 'cache:hit', + 'request:success', + ]); + expect(observed.events[1]).toMatchObject({ + type: 'cache:miss', + cacheStatus: 'miss', + }); + expect(observed.events[4]).toMatchObject({ + type: 'cache:hit', + cacheStatus: 'fresh', + status: 200, + }); + }); + + test('emits cache stale and notModified revalidation events', async () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const observed = collectEvents(); + const cache = makeCacheStore(); + + nock(baseUrl) + .get('/observed-cache-304') + .reply( + 200, + { id: 1 }, + { 'Cache-Control': 'max-age=1', ETag: '"cache-304"' }, + ); + + const client = new HttpClient({ + name: 'observed', + cache: { store: cache }, + observability: observed.observability, + }); + + await client.get(`${baseUrl}/observed-cache-304`); + observed.events.length = 0; + vi.spyOn(Date, 'now').mockReturnValue(now + 5000); + + nock(baseUrl) + .get('/observed-cache-304') + .matchHeader('If-None-Match', '"cache-304"') + .reply(304, '', { 'Cache-Control': 'max-age=60' }); + + const result = await client.get(`${baseUrl}/observed-cache-304`); + + expect(result).toEqual({ id: 1 }); + expect(observed.events.map((event) => event.type)).toEqual([ + 'request:start', + 'cache:stale', + 'cache:revalidate', + 'cache:revalidate', + 'request:success', + ]); + expect(observed.events[1]).toMatchObject({ + type: 'cache:stale', + cacheStatus: 'stale', + status: 200, + }); + expect(observed.events[2]).toMatchObject({ + type: 'cache:revalidate', + phase: 'scheduled', + status: 200, + }); + expect(observed.events[3]).toMatchObject({ + type: 'cache:revalidate', + phase: 'notModified', + status: 304, + }); + expect(observed.events[4]).toMatchObject({ + type: 'request:success', + status: 200, + }); + }); + + test('emits stale-while-revalidate cache hit and background success events', async () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const observed = collectEvents(); + const cache = makeCacheStore(); + + nock(baseUrl).get('/observed-cache-swr').reply( + 200, + { version: 1 }, + { + 'Cache-Control': 'max-age=1, stale-while-revalidate=120', + ETag: '"swr-1"', + }, + ); + + const client = new HttpClient({ + name: 'observed', + cache: { store: cache }, + observability: observed.observability, + }); + + await client.get(`${baseUrl}/observed-cache-swr`); + observed.events.length = 0; + vi.spyOn(Date, 'now').mockReturnValue(now + 5000); + + nock(baseUrl) + .get('/observed-cache-swr') + .matchHeader('If-None-Match', '"swr-1"') + .reply(200, { version: 2 }, { 'Cache-Control': 'max-age=60' }); + + const result = await client.get(`${baseUrl}/observed-cache-swr`); + await client.flushRevalidations(); + + expect(result).toEqual({ version: 1 }); + expect(observed.events.map((event) => event.type)).toEqual([ + 'request:start', + 'cache:stale', + 'cache:revalidate', + 'cache:hit', + 'request:success', + 'cache:revalidate', + ]); + expect(observed.events[1]).toMatchObject({ + type: 'cache:stale', + cacheStatus: 'stale-while-revalidate', + }); + expect(observed.events[2]).toMatchObject({ + type: 'cache:revalidate', + phase: 'scheduled', + }); + expect(observed.events[3]).toMatchObject({ + type: 'cache:hit', + cacheStatus: 'stale-while-revalidate', + status: 200, + }); + expect(observed.events[5]).toMatchObject({ + type: 'cache:revalidate', + phase: 'success', + status: 200, + }); + }); + + test('emits stale-if-error fallback and revalidation error events', async () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const observed = collectEvents(); + const cache = makeCacheStore(); + + nock(baseUrl) + .get('/observed-cache-sie') + .reply( + 200, + { version: 1 }, + { 'Cache-Control': 'max-age=1, stale-if-error=300' }, + ); + + const client = new HttpClient({ + name: 'observed', + cache: { store: cache }, + observability: observed.observability, + }); + + await client.get(`${baseUrl}/observed-cache-sie`); + observed.events.length = 0; + vi.spyOn(Date, 'now').mockReturnValue(now + 5000); + + nock(baseUrl) + .get('/observed-cache-sie') + .reply(500, { message: 'Server error' }); + + const result = await client.get(`${baseUrl}/observed-cache-sie`); + + expect(result).toEqual({ version: 1 }); + expect(observed.events.map((event) => event.type)).toEqual([ + 'request:start', + 'cache:stale', + 'cache:revalidate', + 'cache:revalidate', + 'cache:hit', + 'request:success', + ]); + expect(observed.events[1]).toMatchObject({ + type: 'cache:stale', + cacheStatus: 'stale-if-error', + }); + expect(observed.events[3]).toMatchObject({ + type: 'cache:revalidate', + phase: 'error', + status: 500, + }); + expect(observed.events[4]).toMatchObject({ + type: 'cache:hit', + cacheStatus: 'stale-if-error', + status: 200, + }); + expect(observed.events[5]).toMatchObject({ + type: 'request:success', + status: 200, + }); + }); + + test('emits background revalidation error events', async () => { + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now); + const observed = collectEvents(); + const cache = makeCacheStore(); + + nock(baseUrl).get('/observed-cache-swr-error').reply( + 200, + { version: 1 }, + { + 'Cache-Control': 'max-age=1, stale-while-revalidate=120', + ETag: '"swr-error-1"', + }, + ); + + const client = new HttpClient({ + name: 'observed', + cache: { store: cache }, + observability: observed.observability, + }); + + await client.get(`${baseUrl}/observed-cache-swr-error`); + observed.events.length = 0; + vi.spyOn(Date, 'now').mockReturnValue(now + 5000); + + nock(baseUrl) + .get('/observed-cache-swr-error') + .matchHeader('If-None-Match', '"swr-error-1"') + .reply(503, { message: 'Try later' }); + + const result = await client.get(`${baseUrl}/observed-cache-swr-error`); + await client.flushRevalidations(); + + expect(result).toEqual({ version: 1 }); + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'cache:revalidate', + phase: 'error', + status: 503, + }), + ); + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'request:success', + status: 200, + }), + ); + }); + + test('emits retry scheduled and exhausted events', async () => { + const scheduled = collectEvents(); + nock(baseUrl) + .get('/observed-retry-success') + .reply(500, { message: 'fail' }) + .get('/observed-retry-success') + .reply(200, { ok: true }); + + const retryingClient = new HttpClient({ + name: 'observed', + retry: { maxRetries: 1, jitter: 'none', baseDelay: 1 }, + observability: scheduled.observability, + }); + + await retryingClient.get(`${baseUrl}/observed-retry-success`); + + expect(scheduled.events).toContainEqual( + expect.objectContaining({ + type: 'retry:scheduled', + attempt: 1, + waitMs: 1, + status: 500, + }), + ); + + const exhausted = collectEvents(); + nock(baseUrl) + .get('/observed-retry-exhausted') + .times(2) + .reply(500, { message: 'fail' }); + + const exhaustedClient = new HttpClient({ + name: 'observed', + retry: { maxRetries: 1, jitter: 'none', baseDelay: 1 }, + observability: exhausted.observability, + }); + + await expect( + exhaustedClient.get(`${baseUrl}/observed-retry-exhausted`), + ).rejects.toThrow(HttpClientError); + + expect(exhausted.events).toContainEqual( + expect.objectContaining({ + type: 'retry:exhausted', + attempt: 2, + status: 500, + }), + ); + expect(exhausted.events.at(-1)).toMatchObject({ + type: 'request:error', + status: 500, + }); + }); + + test('does not call retryCondition again when emitting retry exhausted', async () => { + const observed = collectEvents(); + const retryCondition = vi.fn(() => true); + nock(baseUrl) + .get('/observed-retry-condition-count') + .times(2) + .reply(500, { message: 'fail' }); + + const client = new HttpClient({ + name: 'observed', + retry: { + maxRetries: 1, + jitter: 'none', + baseDelay: 1, + retryCondition, + }, + observability: observed.observability, + }); + + await expect( + client.get(`${baseUrl}/observed-retry-condition-count`), + ).rejects.toThrow(HttpClientError); + + expect(retryCondition).toHaveBeenCalledTimes(1); + expect(retryCondition.mock.calls[0]?.[1]).toBe(1); + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'retry:scheduled', + attempt: 1, + }), + ); + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'retry:exhausted', + attempt: 2, + }), + ); + }); + + test('does not call retryCondition when no retry attempt is available', async () => { + const observed = collectEvents(); + const retryCondition = vi.fn(() => true); + nock(baseUrl) + .get('/observed-retry-condition-no-attempts') + .reply(500, { message: 'fail' }); + + const client = new HttpClient({ + name: 'observed', + retry: { + maxRetries: 0, + jitter: 'none', + baseDelay: 1, + retryCondition, + }, + observability: observed.observability, + }); + + await expect( + client.get(`${baseUrl}/observed-retry-condition-no-attempts`), + ).rejects.toThrow(HttpClientError); + + expect(retryCondition).not.toHaveBeenCalled(); + expect( + observed.events.some((event) => event.type.startsWith('retry:')), + ).toBe(false); + }); + + test('emits rate-limit wait and throw events for store and server cooldowns', async () => { + const waitObserved = collectEvents(); + let canProceedChecks = 0; + const waitStore = { + async canProceed() { + canProceedChecks += 1; + return canProceedChecks > 1; + }, + async record() {}, + async getStatus() { + return { remaining: 0, resetTime: new Date(), limit: 1 }; + }, + async reset() {}, + async getWaitTime() { + return 1; + }, + } as const; + + nock(baseUrl).get('/observed-rate-wait').reply(200, { ok: true }); + + const waitClient = new HttpClient({ + name: 'observed', + rateLimit: { store: waitStore, throw: false, maxWaitTime: 20 }, + observability: waitObserved.observability, + }); + + await waitClient.get(`${baseUrl}/observed-rate-wait`); + + expect(waitObserved.events).toContainEqual( + expect.objectContaining({ + type: 'rateLimit:wait', + source: 'store', + waitMs: 1, + resourceKey: baseUrl, + }), + ); + + const throwObserved = collectEvents(); + const throwStore = { + async canProceed() { + return false; + }, + async record() {}, + async getStatus() { + return { remaining: 0, resetTime: new Date(), limit: 1 }; + }, + async reset() {}, + async getWaitTime() { + return 123; + }, + } as const; + + const throwClient = new HttpClient({ + name: 'observed', + rateLimit: { store: throwStore, throw: true }, + observability: throwObserved.observability, + }); + + await expect( + throwClient.get(`${baseUrl}/observed-rate-throw`), + ).rejects.toThrow(/Rate limit exceeded/); + + expect(throwObserved.events).toContainEqual( + expect.objectContaining({ + type: 'rateLimit:throw', + source: 'store', + waitMs: 123, + }), + ); + + const cooldownObserved = collectEvents(); + nock(baseUrl) + .get('/observed-cooldown-source') + .reply(429, { message: 'slow down' }, { 'Retry-After': '1' }); + + const cooldownClient = new HttpClient({ + name: 'observed', + observability: cooldownObserved.observability, + }); + + await expect( + cooldownClient.get(`${baseUrl}/observed-cooldown-source`), + ).rejects.toThrow(HttpClientError); + + await expect( + cooldownClient.get(`${baseUrl}/observed-cooldown-blocked`), + ).rejects.toThrow(/Rate limit exceeded/); + + expect(cooldownObserved.events).toContainEqual( + expect.objectContaining({ + type: 'serverCooldown:set', + status: 429, + waitMs: 1000, + }), + ); + expect(cooldownObserved.events).toContainEqual( + expect.objectContaining({ + type: 'rateLimit:throw', + source: 'serverCooldown', + resourceKey: baseUrl, + }), + ); + + const cooldownWaitObserved = collectEvents(); + nock(baseUrl).get('/observed-cooldown-wait').reply(200, { ok: true }); + + const cooldownWaitClient = new HttpClient({ + name: 'observed', + rateLimit: { throw: false, maxWaitTime: 50 }, + observability: cooldownWaitObserved.observability, + }); + const privateCooldownWaitClient = cooldownWaitClient as unknown as { + serverCooldowns: Map; + }; + privateCooldownWaitClient.serverCooldowns.set(baseUrl, Date.now() + 10); + + await cooldownWaitClient.get(`${baseUrl}/observed-cooldown-wait`); + + expect(cooldownWaitObserved.events).toContainEqual( + expect.objectContaining({ + type: 'rateLimit:wait', + source: 'serverCooldown', + resourceKey: baseUrl, + }), + ); + }); + + test('emits dedupe owner and join events', async () => { + const observed = collectEvents(); + type Deferred = { + promise: Promise; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + }; + + const jobs = new Map(); + const dedupeStoreStub = { + async waitFor(hash: string) { + const job = jobs.get(hash); + if (!job) { + return undefined; + } + try { + return await job.promise; + } catch { + return undefined; + } + }, + async register(hash: string) { + const existing = jobs.get(hash); + if (existing) { + return 'shared-job'; + } + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + jobs.set(hash, { promise, resolve, reject }); + return 'owner-job'; + }, + async registerOrJoin(hash: string) { + const existing = jobs.get(hash); + if (existing) { + return { jobId: 'shared-job', isOwner: false }; + } + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + jobs.set(hash, { promise, resolve, reject }); + return { jobId: 'owner-job', isOwner: true }; + }, + async complete(hash: string, value: unknown) { + jobs.get(hash)?.resolve(value); + }, + async fail(hash: string, error: Error) { + jobs.get(hash)?.reject(error); + }, + async isInProgress(hash: string) { + return jobs.has(hash); + }, + } as const; + + nock(baseUrl).get('/observed-dedupe').delay(20).reply(200, { ok: true }); + + const client = new HttpClient({ + name: 'observed', + dedupe: dedupeStoreStub, + observability: observed.observability, + }); + + await Promise.all([ + client.get(`${baseUrl}/observed-dedupe`), + client.get(`${baseUrl}/observed-dedupe`), + ]); + + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'dedupe:owner', + }), + ); + expect(observed.events).toContainEqual( + expect.objectContaining({ + type: 'dedupe:join', + }), + ); + }); + + test('swallows observer errors without affecting request results', async () => { + nock(baseUrl).get('/observed-swallow-sync').reply(200, { ok: true }); + const throwingClient = new HttpClient({ + name: 'observed', + observability: { + onEvent: () => { + throw new Error('observer failed'); + }, + }, + }); + + await expect( + throwingClient.get(`${baseUrl}/observed-swallow-sync`), + ).resolves.toEqual({ ok: true }); + + nock(baseUrl).get('/observed-swallow-async').reply(200, { ok: true }); + const rejectingClient = new HttpClient({ + name: 'observed', + observability: { + onEvent: async () => { + throw new Error('observer rejected'); + }, + }, + }); + + await expect( + rejectingClient.get(`${baseUrl}/observed-swallow-async`), + ).resolves.toEqual({ ok: true }); + }); + + test('does not await async observers in the request path', async () => { + const observedEvents: Array = []; + const timeout = Symbol('timeout'); + + nock(baseUrl).get('/observed-unawaited').reply(200, { ok: true }); + + const client = new HttpClient({ + name: 'observed', + observability: { + onEvent: (event) => { + observedEvents.push(event); + return new Promise(() => {}); + }, + }, + }); + + const result = await Promise.race([ + client.get(`${baseUrl}/observed-unawaited`), + new Promise((resolve) => { + setTimeout(() => resolve(timeout), 50); + }), + ]); + + expect(result).toEqual({ ok: true }); + expect(observedEvents.map((event) => event.type)).toEqual([ + 'request:start', + 'request:success', + ]); + }); + }); + test('should execute only one upstream request when dedupe supports registerOrJoin', async () => { type Deferred = { promise: Promise; diff --git a/packages/core/src/http-client/http-client.ts b/packages/core/src/http-client/http-client.ts index 8508118..ea43763 100644 --- a/packages/core/src/http-client/http-client.ts +++ b/packages/core/src/http-client/http-client.ts @@ -25,6 +25,7 @@ import { HttpClientContract, type CacheOverrideOptions, type HttpErrorContext, + type HttpClientEvent, type RetryContext, type RetryOptions, } from '../types/index.js'; @@ -202,6 +203,10 @@ export interface HttpClientOptions { * Pass an options object to enable retries with custom settings. */ retry?: RetryOptions | false; + /** Structured lifecycle events for metrics, logs, and tracing. */ + observability?: { + onEvent?: (event: HttpClientEvent) => void | Promise; + }; } interface RateLimitHeaderConfig { @@ -216,9 +221,28 @@ interface ParsedResponseBody { data: unknown; } +interface RequestEventContext { + requestId: string; + url: string; + method: string; + resourceKey: string; + cacheKey?: string; + startedAt: number; +} + +interface EventBaseFields { + clientName: string; + requestId: string; + url: string; + method: string; + resourceKey: string; + timestamp: number; +} + export { type CacheOverrideOptions, type HttpErrorContext, + type HttpClientEvent, type RetryContext, type RetryOptions, }; @@ -228,6 +252,7 @@ export class HttpClient implements HttpClientContract { public readonly stores: HttpClientStores; private serverCooldowns = new Map(); private pendingRevalidations: Array> = []; + private requestSequence = 0; private options: { cacheTTL: number; throwOnRateLimit: boolean; @@ -246,6 +271,7 @@ export class HttpClient implements HttpClientContract { rateLimitConfigs?: RateLimitConfigMap; defaultRateLimitConfig?: RateLimitConfig; rateLimitHeaders: RateLimitHeaderConfig; + observability?: HttpClientOptions['observability']; }; constructor(options: HttpClientOptions) { @@ -276,6 +302,7 @@ export class HttpClient implements HttpClientContract { rateLimitHeaders: this.normalizeRateLimitHeaders( options.rateLimit?.headers, ), + observability: options.observability, }; if ( @@ -298,6 +325,83 @@ export class HttpClient implements HttpClientContract { } } + private nextRequestId(): string { + this.requestSequence += 1; + return `${this.name}-${this.requestSequence}`; + } + + private eventBase(context: RequestEventContext): EventBaseFields { + return { + clientName: this.name, + requestId: context.requestId, + url: context.url, + method: context.method, + resourceKey: context.resourceKey, + timestamp: Date.now(), + }; + } + + private emitEvent(event: HttpClientEvent): void { + const onEvent = this.options.observability?.onEvent; + if (!onEvent) { + return; + } + + try { + const result = onEvent(event); + if (result && typeof result.then === 'function') { + void result.catch(() => {}); + } + } catch { + // Observability callbacks must never affect request behavior. + } + } + + private toError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); + } + + private statusFromError(error: unknown): number | undefined { + if (this.isHttpErrorContext(error)) { + return error.response.status; + } + if (error instanceof HttpClientError) { + return error.statusCode; + } + return undefined; + } + + private emitRequestSuccess( + context: RequestEventContext, + status?: number, + ): void { + this.emitEvent({ + ...this.eventBase(context), + type: 'request:success', + durationMs: Date.now() - context.startedAt, + cacheKey: context.cacheKey, + status, + }); + } + + private emitRequestError( + context: RequestEventContext, + error: Error, + status?: number, + ): void { + this.emitEvent({ + ...this.eventBase(context), + type: 'request:error', + durationMs: Date.now() - context.startedAt, + cacheKey: context.cacheKey, + status, + error, + }); + } + private normalizeRateLimitHeaders( customHeaders?: HttpClientRateLimitOptions['headers'], ): RateLimitHeaderConfig { @@ -557,6 +661,8 @@ export class HttpClient implements HttpClientContract { url: string, headers: Headers | Record | undefined, statusCode?: number, + eventContext?: RequestEventContext, + attempt?: number, ): void { if (!headers) { return; @@ -601,6 +707,17 @@ export class HttpClient implements HttpClientContract { } else { this.serverCooldowns.set(scope, cooldownUntilMs); } + + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'serverCooldown:set', + resourceKey: scope, + waitMs, + status: statusCode, + attempt, + }); + } } private async readCooldown(scope: string): Promise { @@ -622,6 +739,7 @@ export class HttpClient implements HttpClientContract { url: string, signal?: AbortSignal, forceWait = false, + eventContext?: RequestEventContext, ): Promise { const scope = this.resolveRateLimitKey(url); const startedAt = Date.now(); @@ -642,21 +760,53 @@ export class HttpClient implements HttpClientContract { } if (this.options.throwOnRateLimit && !forceWait) { - throw new Error( + const error = new Error( `Rate limit exceeded for resource '${scope}'. Wait ${waitMs}ms before retrying.`, ); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:throw', + resourceKey: scope, + source: 'serverCooldown', + waitMs, + error, + }); + } + throw error; } const elapsedMs = Date.now() - startedAt; const remainingWaitBudgetMs = this.options.maxWaitTime - elapsedMs; if (remainingWaitBudgetMs <= 0) { - throw new Error( + const error = new Error( `Rate limit wait exceeded maxWaitTime (${this.options.maxWaitTime}ms) for resource '${scope}'.`, ); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:throw', + resourceKey: scope, + source: 'serverCooldown', + waitMs, + error, + }); + } + throw error; } - await wait(Math.min(waitMs, remainingWaitBudgetMs), signal); + const waitDurationMs = Math.min(waitMs, remainingWaitBudgetMs); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:wait', + resourceKey: scope, + source: 'serverCooldown', + waitMs: waitDurationMs, + }); + } + await wait(waitDurationMs, signal); } } @@ -664,6 +814,7 @@ export class HttpClient implements HttpClientContract { resource: string, priority: RequestPriority, signal?: AbortSignal, + eventContext?: RequestEventContext, ): Promise { const rateLimit = this.stores.rateLimit as AdaptiveRateLimitStore; const startedAt = Date.now(); @@ -680,9 +831,20 @@ export class HttpClient implements HttpClientContract { const canProceed = await canProceedNow(); if (!canProceed) { const waitTime = await rateLimit.getWaitTime(resource, priority); - throw new Error( + const error = new Error( `Rate limit exceeded for resource '${resource}'. Wait ${waitTime}ms before retrying.`, ); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:throw', + resourceKey: resource, + source: 'store', + waitMs: waitTime, + error, + }); + } + throw error; } return hasAtomicAcquire; } @@ -696,9 +858,20 @@ export class HttpClient implements HttpClientContract { const remainingWaitBudgetMs = this.options.maxWaitTime - elapsedMs; if (remainingWaitBudgetMs <= 0) { - throw new Error( + const error = new Error( `Rate limit wait exceeded maxWaitTime (${this.options.maxWaitTime}ms) for resource '${resource}'.`, ); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:throw', + resourceKey: resource, + source: 'store', + waitMs: suggestedWaitMs, + error, + }); + } + throw error; } // If a store reports "blocked" but no wait time, use a tiny backoff to @@ -708,6 +881,15 @@ export class HttpClient implements HttpClientContract { ? Math.min(suggestedWaitMs, remainingWaitBudgetMs) : Math.min(25, remainingWaitBudgetMs); + if (eventContext) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'rateLimit:wait', + resourceKey: resource, + source: 'store', + waitMs: waitTime, + }); + } await wait(waitTime, signal); } @@ -727,6 +909,7 @@ export class HttpClient implements HttpClientContract { url: string, hash: string, entry: CacheEntry, + eventContext: RequestEventContext, requestHeaders?: Record, cacheConfig?: { cacheTTL: number; @@ -742,6 +925,8 @@ export class HttpClient implements HttpClientContract { fetchHeaders.set('If-Modified-Since', entry.metadata.lastModified); } + const revalidationStartedAt = Date.now(); + try { let revalInit: RequestInit = { headers: fetchHeaders }; if (this.options.requestInterceptor) { @@ -755,7 +940,13 @@ export class HttpClient implements HttpClientContract { response = await this.options.responseInterceptor(response, url); } - this.applyServerRateLimitHints(url, response.headers, response.status); + this.applyServerRateLimitHints( + url, + response.headers, + response.status, + eventContext, + 1, + ); /* v8 ignore next -- cacheConfig is always provided by resolveCacheConfig() */ const resolvedTTL = cacheConfig?.cacheTTL ?? this.options.cacheTTL; @@ -769,6 +960,14 @@ export class HttpClient implements HttpClientContract { resolvedOverrides, ); await this.cacheSet(hash, refreshed, ttl, tags); + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'cache:revalidate', + cacheKey: hash, + phase: 'notModified', + status: response.status, + durationMs: Date.now() - revalidationStartedAt, + }); return; } @@ -798,8 +997,37 @@ export class HttpClient implements HttpClientContract { resolvedOverrides, ); await this.cacheSet(hash, newEntry, ttl, tags); + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'cache:revalidate', + cacheKey: hash, + phase: 'success', + status: response.status, + durationMs: Date.now() - revalidationStartedAt, + }); + return; } - } catch { + + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'cache:revalidate', + cacheKey: hash, + phase: 'error', + status: response.status, + error: new Error( + `Background revalidation failed with status ${response.status}`, + ), + durationMs: Date.now() - revalidationStartedAt, + }); + } catch (error) { + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'cache:revalidate', + cacheKey: hash, + phase: 'error', + error: this.toError(error), + durationMs: Date.now() - revalidationStartedAt, + }); // Background revalidation failures are silently ignored. // The stale entry remains in the cache and will be served until // it falls out of the stale-while-revalidate window. @@ -940,12 +1168,10 @@ export class HttpClient implements HttpClientContract { return jitteredDelay; } - private isRetryableRequest( + private createRetryContext( error: Error | HttpErrorContext, - retryConfig: NonNullable>, - attempt: number, url: string, - ): { shouldRetry: boolean; context: RetryContext } { + ): RetryContext { let statusCode: number | undefined; let retryAfterMs: number | undefined; @@ -958,7 +1184,53 @@ export class HttpClient implements HttpClientContract { retryAfterMs = this.parseRetryAfterMs(retryAfterRaw); } - const context: RetryContext = { error, retryAfterMs, statusCode, url }; + return { error, retryAfterMs, statusCode, url }; + } + + private isDefaultRetryableRequest( + error: Error | HttpErrorContext, + statusCode?: number, + ): boolean { + if (statusCode !== undefined) { + return HttpClient.RETRYABLE_STATUS_CODES.has(statusCode); + } + + return error instanceof TypeError; + } + + private emitRetryExhausted( + error: Error | HttpErrorContext, + retryConfig: NonNullable>, + attempt: number, + url: string, + eventContext: RequestEventContext, + hasScheduledRetry: boolean, + ): void { + const context = this.createRetryContext(error, url); + const shouldEmit = retryConfig.retryCondition + ? hasScheduledRetry + : this.isDefaultRetryableRequest(error, context.statusCode); + + if (!shouldEmit) { + return; + } + + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'retry:exhausted', + attempt, + status: context.statusCode, + error: context.error, + }); + } + + private isRetryableRequest( + error: Error | HttpErrorContext, + retryConfig: NonNullable>, + attempt: number, + url: string, + ): { shouldRetry: boolean; context: RetryContext } { + const context = this.createRetryContext(error, url); // Custom condition overrides default logic if (retryConfig.retryCondition) { @@ -968,20 +1240,10 @@ export class HttpClient implements HttpClientContract { }; } - // Default: retry on retryable status codes - if (statusCode !== undefined) { - return { - shouldRetry: HttpClient.RETRYABLE_STATUS_CODES.has(statusCode), - context, - }; - } - - // Network errors (TypeError) are retryable - if (error instanceof TypeError) { - return { shouldRetry: true, context }; - } - - return { shouldRetry: false, context }; + return { + shouldRetry: this.isDefaultRetryableRequest(error, context.statusCode), + context, + }; } private async parseResponseBody( @@ -1028,18 +1290,20 @@ export class HttpClient implements HttpClientContract { ReturnType > | null, staleEntry: CacheEntry | undefined, + eventContext: RequestEventContext, ): Promise< | { notModified: true; refreshedEntry: CacheEntry } | { notModified: false; response: Response; parsedBody: ParsedResponseBody } > { const maxAttempts = retryConfig ? retryConfig.maxRetries + 1 : 1; + let hasScheduledRetry = false; for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Re-check server cooldown between retries — the previous attempt may // have set a cooldown via applyServerRateLimitHints. Always wait (never // throw) since the retry mechanism is handling recovery. if (attempt > 1) { - await this.enforceServerCooldown(url, signal, true); + await this.enforceServerCooldown(url, signal, true, eventContext); } try { @@ -1059,7 +1323,13 @@ export class HttpClient implements HttpClientContract { if (this.options.responseInterceptor) { response = await this.options.responseInterceptor(response, url); } - this.applyServerRateLimitHints(url, response.headers, response.status); + this.applyServerRateLimitHints( + url, + response.headers, + response.status, + eventContext, + attempt, + ); // Handle 304 Not Modified — must be checked BEFORE !response.ok if (response.status === 304 && staleEntry) { @@ -1098,12 +1368,32 @@ export class HttpClient implements HttpClientContract { retryConfig.jitter, context.retryAfterMs, ); + hasScheduledRetry = true; + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'retry:scheduled', + attempt, + waitMs: delay, + status: context.statusCode, + error: context.error, + }); retryConfig.onRetry?.(context, attempt, delay); await wait(delay, signal); continue; } } + if (retryConfig && attempt >= maxAttempts) { + this.emitRetryExhausted( + httpError, + retryConfig, + attempt, + url, + eventContext, + hasScheduledRetry, + ); + } + throw httpError; } @@ -1134,12 +1424,36 @@ export class HttpClient implements HttpClientContract { retryConfig.jitter, context.retryAfterMs, ); + hasScheduledRetry = true; + this.emitEvent({ + ...this.eventBase(eventContext), + type: 'retry:scheduled', + attempt, + waitMs: delay, + status: context.statusCode, + error: context.error, + }); retryConfig.onRetry?.(context, attempt, delay); await wait(delay, signal); continue; } } + if ( + fetchError instanceof TypeError && + retryConfig && + attempt >= maxAttempts + ) { + this.emitRetryExhausted( + fetchError, + retryConfig, + attempt, + url, + eventContext, + hasScheduledRetry, + ); + } + // HttpErrorContext thrown from the !response.ok branch above // or other non-retryable errors — propagate throw fetchError; @@ -1206,19 +1520,83 @@ export class HttpClient implements HttpClientContract { options.cache?.ttl, options.cache?.overrides, ); + const requestContext: RequestEventContext = { + requestId: this.nextRequestId(), + url, + method: 'GET', + resourceKey: resource, + cacheKey: cacheHash, + startedAt: Date.now(), + }; + let cacheRevalidationStartedAt: number | undefined; + + const emitCacheRevalidationScheduled = ( + entry: CacheEntry, + ): void => { + cacheRevalidationStartedAt = Date.now(); + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:revalidate', + cacheKey: cacheHash, + phase: 'scheduled', + status: entry.metadata.statusCode, + }); + }; + + const emitCacheRevalidationOutcome = ( + phase: 'success' | 'error' | 'notModified', + details: { + status?: number; + error?: Error; + } = {}, + ): void => { + if (cacheRevalidationStartedAt === undefined) { + return; + } + + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:revalidate', + cacheKey: cacheHash, + phase, + status: details.status, + error: details.error, + durationMs: Date.now() - cacheRevalidationStartedAt, + }); + cacheRevalidationStartedAt = undefined; + }; // Track stale entry for conditional requests and stale-if-error fallback let staleEntry: CacheEntry | undefined; let staleCandidate: CacheEntry | undefined; + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'request:start', + }); + try { - await this.enforceServerCooldown(url, signal); + await this.enforceServerCooldown(url, signal, false, requestContext); // 1. Cache — check for cached response if (this.stores.cache) { const cachedResult = await this.stores.cache.get(cacheHash); - if (cachedResult !== undefined && isCacheEntry(cachedResult)) { + if (cachedResult === undefined) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:miss', + cacheKey: cacheHash, + cacheStatus: 'miss', + }); + } else if (!isCacheEntry(cachedResult)) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:miss', + cacheKey: cacheHash, + cacheStatus: 'unusable', + }); + } else { const entry = cachedResult as CacheEntry; // Vary mismatch → treat as cache miss @@ -1229,31 +1607,90 @@ export class HttpClient implements HttpClientContract { headers ?? {}, ) ) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:miss', + cacheKey: cacheHash, + cacheStatus: 'vary-mismatch', + }); // fall through to fetch } else { const status = getFreshnessStatus(entry.metadata); switch (status) { case 'fresh': + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:hit', + cacheKey: cacheHash, + cacheStatus: 'fresh', + status: entry.metadata.statusCode, + }); + this.emitRequestSuccess( + requestContext, + entry.metadata.statusCode, + ); return entry.value as Result; case 'no-cache': if (cacheConfig.cacheOverrides?.ignoreNoCache) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:hit', + cacheKey: cacheHash, + cacheStatus: 'no-cache', + status: entry.metadata.statusCode, + }); + this.emitRequestSuccess( + requestContext, + entry.metadata.statusCode, + ); return entry.value as Result; } + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:stale', + cacheKey: cacheHash, + cacheStatus: 'no-cache', + status: entry.metadata.statusCode, + }); staleEntry = entry; + emitCacheRevalidationScheduled(entry); break; case 'must-revalidate': + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:stale', + cacheKey: cacheHash, + cacheStatus: 'must-revalidate', + status: entry.metadata.statusCode, + }); staleEntry = entry; + emitCacheRevalidationScheduled(entry); break; case 'stale-while-revalidate': { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:stale', + cacheKey: cacheHash, + cacheStatus: 'stale-while-revalidate', + status: entry.metadata.statusCode, + }); + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:revalidate', + cacheKey: cacheHash, + phase: 'scheduled', + status: entry.metadata.statusCode, + }); // Serve stale immediately, revalidate in background const revalidation = this.backgroundRevalidate( url, cacheHash, entry, + requestContext, headers, cacheConfig, options.cache?.tags, @@ -1265,17 +1702,44 @@ export class HttpClient implements HttpClientContract { (p) => p !== revalidation, ); }); + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:hit', + cacheKey: cacheHash, + cacheStatus: 'stale-while-revalidate', + status: entry.metadata.statusCode, + }); + this.emitRequestSuccess( + requestContext, + entry.metadata.statusCode, + ); return entry.value as Result; } case 'stale-if-error': + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:stale', + cacheKey: cacheHash, + cacheStatus: 'stale-if-error', + status: entry.metadata.statusCode, + }); // Attempt fresh fetch, fall back to stale on error staleCandidate = entry; staleEntry = entry; // Also use for conditional request + emitCacheRevalidationScheduled(entry); break; case 'stale': + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:stale', + cacheKey: cacheHash, + cacheStatus: 'stale', + status: entry.metadata.statusCode, + }); staleEntry = entry; + emitCacheRevalidationScheduled(entry); break; } } @@ -1284,8 +1748,16 @@ export class HttpClient implements HttpClientContract { // 2. Deduplication — check for in-progress request if (this.stores.dedupe) { + const dedupeStartedAt = Date.now(); const existingResult = await this.stores.dedupe.waitFor(rawHash); if (existingResult !== undefined) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'dedupe:join', + dedupeKey: rawHash, + durationMs: Date.now() - dedupeStartedAt, + }); + this.emitRequestSuccess(requestContext); return existingResult as Result; } @@ -1293,13 +1765,30 @@ export class HttpClient implements HttpClientContract { const registration = await this.stores.dedupe.registerOrJoin(rawHash); if (!registration.isOwner) { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'dedupe:join', + dedupeKey: rawHash, + }); const joinedResult = await this.stores.dedupe.waitFor(rawHash); if (joinedResult !== undefined) { + this.emitRequestSuccess(requestContext); return joinedResult as Result; } + } else { + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'dedupe:owner', + dedupeKey: rawHash, + }); } } else { await this.stores.dedupe.register(rawHash); + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'dedupe:owner', + dedupeKey: rawHash, + }); } } @@ -1310,6 +1799,7 @@ export class HttpClient implements HttpClientContract { resource, priority, signal, + requestContext, ); } @@ -1335,6 +1825,7 @@ export class HttpClient implements HttpClientContract { signal, retryConfig, staleEntry, + requestContext, ); // Handle 304 Not Modified @@ -1351,6 +1842,7 @@ export class HttpClient implements HttpClientContract { ttl, options.cache?.tags, ); + emitCacheRevalidationOutcome('notModified', { status: 304 }); const result = refreshedEntry.value as Result; @@ -1358,6 +1850,10 @@ export class HttpClient implements HttpClientContract { await this.stores.dedupe.complete(rawHash, result); } + this.emitRequestSuccess( + requestContext, + refreshedEntry.metadata.statusCode, + ); return result; } @@ -1406,24 +1902,49 @@ export class HttpClient implements HttpClientContract { } } + if (staleEntry) { + emitCacheRevalidationOutcome('success', { status: response.status }); + } + // 9. Mark deduplication as complete if (this.stores.dedupe) { await this.stores.dedupe.complete(rawHash, result); } + this.emitRequestSuccess(requestContext, response.status); return result; } catch (error) { // stale-if-error fallback: serve stale entry when origin fails if (staleCandidate && this.isServerErrorOrNetworkFailure(error)) { const result = staleCandidate.value as Result; + emitCacheRevalidationOutcome('error', { + status: this.statusFromError(error), + error: this.toError(error), + }); + this.emitEvent({ + ...this.eventBase(requestContext), + type: 'cache:hit', + cacheKey: cacheHash, + cacheStatus: 'stale-if-error', + status: staleCandidate.metadata.statusCode, + }); if (this.stores.dedupe) { await this.stores.dedupe.complete(rawHash, result); } + this.emitRequestSuccess( + requestContext, + staleCandidate.metadata.statusCode, + ); return result; } + emitCacheRevalidationOutcome('error', { + status: this.statusFromError(error), + error: this.toError(error), + }); + // Mark deduplication as failed if (this.stores.dedupe) { await this.stores.dedupe.fail(rawHash, error as Error); @@ -1431,15 +1952,34 @@ export class HttpClient implements HttpClientContract { // Allow callers to detect aborts distinctly – do not wrap AbortError. if (error instanceof Error && error.name === 'AbortError') { + this.emitRequestError(requestContext, error); throw error; } // Already a processed error from the !response.ok branch above if (error instanceof HttpClientError) { + this.emitRequestError(requestContext, error, error.statusCode); throw error; } - throw this.generateClientError(error); + let clientError: Error; + try { + clientError = this.generateClientError(error); + } catch (generatedError) { + const emittedError = this.toError(generatedError); + this.emitRequestError( + requestContext, + emittedError, + this.statusFromError(error) ?? this.statusFromError(emittedError), + ); + throw generatedError; + } + this.emitRequestError( + requestContext, + clientError, + this.statusFromError(error) ?? this.statusFromError(clientError), + ); + throw clientError; } } } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 81d7e60..7ca50b7 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1 +1,2 @@ export * from './http-client.js'; +export * from './observability.js'; diff --git a/packages/core/src/types/observability.ts b/packages/core/src/types/observability.ts new file mode 100644 index 0000000..72deedd --- /dev/null +++ b/packages/core/src/types/observability.ts @@ -0,0 +1,174 @@ +import type { HttpErrorContext } from './http-client.js'; + +export type HttpClientEventType = + | 'request:start' + | 'request:success' + | 'request:error' + | 'cache:hit' + | 'cache:miss' + | 'cache:stale' + | 'cache:revalidate' + | 'dedupe:owner' + | 'dedupe:join' + | 'rateLimit:wait' + | 'rateLimit:throw' + | 'serverCooldown:set' + | 'retry:scheduled' + | 'retry:exhausted'; + +export interface HttpClientEventBase { + type: HttpClientEventType; + clientName: string; + requestId: string; + url: string; + method: string; + resourceKey?: string; + timestamp: number; +} + +export type HttpClientCacheStatus = + | 'fresh' + | 'no-cache' + | 'must-revalidate' + | 'stale-while-revalidate' + | 'stale-if-error' + | 'stale' + | 'miss' + | 'vary-mismatch' + | 'unusable'; + +export type HttpClientCacheRevalidationPhase = + | 'scheduled' + | 'success' + | 'error' + | 'notModified'; + +export type HttpClientRateLimitSource = 'store' | 'serverCooldown'; + +export interface HttpClientRequestStartEvent extends HttpClientEventBase { + type: 'request:start'; +} + +export interface HttpClientRequestSuccessEvent extends HttpClientEventBase { + type: 'request:success'; + durationMs: number; + status?: number; + cacheKey?: string; +} + +export interface HttpClientRequestErrorEvent extends HttpClientEventBase { + type: 'request:error'; + durationMs: number; + status?: number; + error: Error; + cacheKey?: string; +} + +export interface HttpClientCacheHitEvent extends HttpClientEventBase { + type: 'cache:hit'; + cacheKey: string; + cacheStatus: Extract< + HttpClientCacheStatus, + 'fresh' | 'no-cache' | 'stale-while-revalidate' | 'stale-if-error' + >; + status?: number; +} + +export interface HttpClientCacheMissEvent extends HttpClientEventBase { + type: 'cache:miss'; + cacheKey: string; + cacheStatus: Extract< + HttpClientCacheStatus, + 'miss' | 'vary-mismatch' | 'unusable' + >; +} + +export interface HttpClientCacheStaleEvent extends HttpClientEventBase { + type: 'cache:stale'; + cacheKey: string; + cacheStatus: Extract< + HttpClientCacheStatus, + | 'no-cache' + | 'must-revalidate' + | 'stale-while-revalidate' + | 'stale-if-error' + | 'stale' + >; + status?: number; +} + +export interface HttpClientCacheRevalidateEvent extends HttpClientEventBase { + type: 'cache:revalidate'; + cacheKey: string; + phase: HttpClientCacheRevalidationPhase; + durationMs?: number; + status?: number; + error?: Error; +} + +export interface HttpClientDedupeOwnerEvent extends HttpClientEventBase { + type: 'dedupe:owner'; + dedupeKey: string; +} + +export interface HttpClientDedupeJoinEvent extends HttpClientEventBase { + type: 'dedupe:join'; + dedupeKey: string; + durationMs?: number; +} + +export interface HttpClientRateLimitWaitEvent extends HttpClientEventBase { + type: 'rateLimit:wait'; + resourceKey: string; + source: HttpClientRateLimitSource; + waitMs: number; + attempt?: number; +} + +export interface HttpClientRateLimitThrowEvent extends HttpClientEventBase { + type: 'rateLimit:throw'; + resourceKey: string; + source: HttpClientRateLimitSource; + waitMs?: number; + error: Error; + attempt?: number; +} + +export interface HttpClientServerCooldownSetEvent extends HttpClientEventBase { + type: 'serverCooldown:set'; + resourceKey: string; + waitMs: number; + status?: number; + attempt?: number; +} + +export interface HttpClientRetryScheduledEvent extends HttpClientEventBase { + type: 'retry:scheduled'; + attempt: number; + waitMs: number; + status?: number; + error?: Error | HttpErrorContext; +} + +export interface HttpClientRetryExhaustedEvent extends HttpClientEventBase { + type: 'retry:exhausted'; + attempt: number; + status?: number; + error?: Error | HttpErrorContext; +} + +export type HttpClientEvent = + | HttpClientRequestStartEvent + | HttpClientRequestSuccessEvent + | HttpClientRequestErrorEvent + | HttpClientCacheHitEvent + | HttpClientCacheMissEvent + | HttpClientCacheStaleEvent + | HttpClientCacheRevalidateEvent + | HttpClientDedupeOwnerEvent + | HttpClientDedupeJoinEvent + | HttpClientRateLimitWaitEvent + | HttpClientRateLimitThrowEvent + | HttpClientServerCooldownSetEvent + | HttpClientRetryScheduledEvent + | HttpClientRetryExhaustedEvent;