Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ repos:
entry: pnpm exec eslint --fix --max-warnings 0
language: system
files: ^packages/cachekit/src/.*\.(ts|tsx)$
# eslint.config.js ignores declaration files, and an ignored file passed by name warns.
exclude: \.d\.ts$
pass_filenames: true

- id: prettier
Expand Down
69 changes: 66 additions & 3 deletions packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -870,9 +870,72 @@ The logger may be `async`. If it throws or its promise rejects, the SDK reports
that failure, with the original message, to `console.error`; it never reaches
the cache operation and never surfaces as an unhandled rejection.

With the CachekitIO backend, the `X-CacheKit-L1-*` telemetry headers are wired
automatically from the cache's live L1/L2 hit and miss counters; pass your own
`metricsProvider` in the backend config to override. Those counters, and the
### Telemetry (CachekitIO only)

Only the CachekitIO backend sends telemetry, and only to the API it is
configured with; Redis, Memcached, File, Workers KV, the Cache API and an
L1-only cache send nothing anywhere. While telemetry is on, the default during
the closed beta, each CachekitIO request carries these optional headers:

- the session headers: `X-CacheKit-Session-ID` and `-Session-Start`, and the
cache's hit and miss counters `X-CacheKit-L1-Hits`, `-L2-Hits`, `-Misses`
and `-L1-Hit-Rate`, the share of all counted operations L1 served,
`L1-Hits / (L1-Hits + L2-Hits + Misses)`;
- unreleased: the L1 hit-latency report, `X-CacheKit-L1-Latency-Buckets`,
`-Sum-Ns` and `-One-In`. On Node.js the cache times one call in 1024,
chosen at random before its outcome is known, with `process.hrtime.bigint()`;
each timed call that L1 serves adds its whole duration to a histogram of
sixteen buckets. A report rides the backend's next request, never its health
check, and is never sent twice, not even when Node's `fetch` resends a
request on its own after a `421`. A report is sent only through an `Agent`
exactly as `new Agent()` builds it, which Node's own default dispatcher is:
with any other global dispatcher (`setGlobalDispatcher`), such as a
`RetryAgent`, a `MockAgent`, a proxy, an `Agent` composed with
interceptors, or an `Agent` created with any option set, the SDK cannot
rule out a resend, so the report waits and no latency headers are sent.
A report can be lost:
- when the global dispatcher, or the one an npm undici `fetch` of another
major than Node's reads, is anything but an `Agent` built with defaults;
- when the `Agent` has to queue the request: on Node.js 26, whose undici
probes an `https:` API for HTTP/2, requests made while a new connection
is being set up queue, so reports go missing more often around a cold
start or a reconnect.

The SDK cannot see resend code in a `fetch` wrapper you install: one that
retries with a new init must not resend a request that carries the
`X-CacheKit-L1-Latency-*` headers. A `fetch` wrapper that spreads the SDK's
options over its own `dispatcher`, such as a proxy, has it overridden on a
request that carries a report, which then goes straight to the configured
API: set a proxy with `setGlobalDispatcher` instead, or turn telemetry off.
Cloudflare Workers, Bun and Deno send no
latency report: Workers' clock does not advance during an in-memory hit, and
the protocol names no clock for the other two.

The counters are wired automatically when the backend is given as a config
(`backend: { apiKey }` or `createCache.io`); a `cachekitio()` instance passed
as `backend` gets them only from its own `metricsProvider`, and the Workers
entry wires none. The latency report needs no wiring: it works in both forms.

Turn telemetry off (unreleased) with `telemetry: false` in the CachekitIO
backend config or `createCache.io` options, or with the environment variable
`CACHEKIT_TELEMETRY` set to `false`, `0`, `off` or `no` (case-insensitive;
`true`, `1`, `on` and `yes` mean on, and any other value means off, with one
warning). On Workers the variable is read from `process.env` and from the
Worker's bindings, through `import { env } from 'cloudflare:workers'`. A
variable the SDK cannot read counts as off, with one warning. Telemetry is
therefore off wherever the variable cannot be read, including under the
`disallow_importable_env` compatibility flag, which hides the bindings, on
a workerd that exposes no `Cloudflare.compatibilityFlags` or no importable
`env`, and on Deno without `--allow-env`. Off from either control wins over on from the other, and
both are read once, when the backend is constructed. With telemetry off a
request carries none of the headers above, only `X-CacheKit-L1-Status`, which
the API requires, and the `User-Agent` below.

```typescript
createCache({ backend: { apiKey: process.env.CACHEKIT_API_KEY!, telemetry: false } });
```

Pass your own `metricsProvider` in the backend config to override the counters. Those counters, and the
Prometheus hit and miss counters, count one outcome per read: a plain `get()` or
`exists()` counts a hit by the layer that served it, or a miss. A `wrap()` call
also counts one outcome: an L1 or L2 hit by the layer that served its value
Expand Down
4 changes: 3 additions & 1 deletion packages/cachekit/bench/ir-seconds.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@
"wasm-encrypted-64kb": 92,
"secure-set-get": 73,
"wrap-l1-hit": 22,
"wrap-l1-hit-int": 20
"wrap-l1-hit-int": 20,
"wrap-l1-hit-cachekitio": 22,
"wrap-l1-hit-int-cachekitio": 20
}
45 changes: 45 additions & 0 deletions packages/cachekit/bench/ir-workload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ async function wasm() {
}

const MASTER_KEY = 'a'.repeat(64);
// Never sent anywhere: the CachekitIO workloads stub fetch.
const FAKE_KEY = 'ck_test_fake-not-a-secret'; // pragma: allowlist secret

/** The CachekitIO workloads' API: every read misses, every write succeeds. */
function stubCachekitIO() {
globalThis.fetch = async (_url, init) =>
new globalThis.Response(null, { status: init?.method === 'GET' ? 404 : 200 });
}
const KEY = 'bench:getUser:' + 'a'.repeat(64);
const ARGS = [42, 'user-profile', { region: 'au', tier: 3 }];
// 1,055 B of msgpack. Keep fixtures under the serializer's 10,000-element
Expand Down Expand Up @@ -410,6 +418,43 @@ export const WORKLOADS = {
50,
20
),
// wrap-l1-hit on a CachekitIO backend, configured as `{ apiKey }`, so its
// telemetry is on and one call in L1_LATENCY_ONE_IN is timed into the
// backend's latency report. It sets no option a build before the report
// lacks, so the gate measures it on the base too. fetch is stubbed: the
// warm-up's miss and write never leave the process, and the measured hits
// make no request.
'wrap-l1-hit-cachekitio': {
n: 10_000,
setup: async () => {
stubCachekitIO();
const { createCache } = await load('index.js');
const cache = createCache({ backend: { apiKey: FAKE_KEY }, metrics: false });
const getUser = cache.wrap(async (id, kind, opts) => ({ id, kind, opts }), {
namespace: 'bench',
ttl: 3600,
});
return async () => {
sink += (await getUser(...ARGS)).id;
};
},
},
// wrap-l1-hit-int on the same CachekitIO backend: the memoised hit, where
// the timing draw is the largest share of a hit
'wrap-l1-hit-int-cachekitio': {
n: 20_000,
setup: async () => {
stubCachekitIO();
const { createCache } = await load('index.js');
const cache = createCache({ backend: { apiKey: FAKE_KEY }, metrics: false });
const getThing = cache.wrap(async (id) => ({ id }), { namespace: 'bench', ttl: 3600 });
for (let i = 0; i < 64; i++) await getThing(i);
let i = 0;
return async () => {
sink += (await getThing(i++ & 63)).id;
};
},
},
// the README benchmark's hit (bench/runtime/probe.mjs): one integer argument,
// cycling over 64 values that are all in L1
'wrap-l1-hit-int': {
Expand Down
2 changes: 2 additions & 0 deletions packages/cachekit/scripts/check-workers-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ try {
conditions: ['workerd'],
loader: { '.wasm': 'binary' },
plugins: [guardPlugin],
// workerd's built-in modules, as wrangler leaves them.
external: ['cloudflare:*'],
write: false,
logLevel: 'silent',
});
Expand Down
5 changes: 5 additions & 0 deletions packages/cachekit/src/backends/cachekitio-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Backend, CachekitIOBackendConfig, LockableBackend, TTLBackend } fr
import { CachekitIOCore } from './cachekitio.js';
import { LockableCachekitIO } from './cachekitio-lockable.js';
import { TTLCachekitIO } from './cachekitio-ttl.js';
import type { L1LatencyReport } from './telemetry.js';

export function cachekitio(config: CachekitIOBackendConfig): Backend {
return new CachekitIOCore(config);
Expand Down Expand Up @@ -35,6 +36,10 @@ class CachekitIO
get transformsKeys(): boolean | undefined {
return this.lockable.transformsKeys;
}
/** @internal Forwarded like keyPrefix — see Backend.l1Latency. */
get l1Latency(): L1LatencyReport | undefined {
return this.lockable.l1Latency;
}
get(key: string) {
return this.lockable.get(key);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cachekit/src/backends/cachekitio-lockable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Backend, LockableBackend } from './types.js';
import { CachekitIOCore, encodeKey } from './cachekitio.js';
import { BackendError, TimeoutError } from '../errors.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';
import type { L1LatencyReport } from './telemetry.js';

/**
* Lock capability token travels in this request header, never the query string
Expand All @@ -24,6 +25,10 @@ export class LockableCachekitIO
get transformsKeys(): boolean | undefined {
return this.inner.transformsKeys;
}
/** @internal Forwarded like keyPrefix — see Backend.l1Latency. */
get l1Latency(): L1LatencyReport | undefined {
return this.inner.l1Latency;
}

get(key: string) {
return this.inner.get(key);
Expand Down
5 changes: 5 additions & 0 deletions packages/cachekit/src/backends/cachekitio-ttl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Backend, TTLBackend } from './types.js';
import { CachekitIOCore, validateTtl, encodeKey } from './cachekitio.js';
import { BackendError, TimeoutError } from '../errors.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';
import type { L1LatencyReport } from './telemetry.js';

// Required<…getWithTtl | setWithStaleTtl>: tsc, not only tests, enforces the forwards.
export class TTLCachekitIO
Expand All @@ -16,6 +17,10 @@ export class TTLCachekitIO
get transformsKeys(): boolean | undefined {
return this.inner.transformsKeys;
}
/** @internal Forwarded like keyPrefix — see Backend.l1Latency. */
get l1Latency(): L1LatencyReport | undefined {
return this.inner.l1Latency;
}

get(key: string) {
return this.inner.get(key);
Expand Down
4 changes: 2 additions & 2 deletions packages/cachekit/src/backends/cachekitio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,9 @@ describe('CachekitIO Backend', () => {
await cache.close();
});

// The SaaS telemetry headers are auto-wired from the cache's
// The X-CacheKit-L1-* counter headers are auto-wired from the cache's
// live hit/miss counters — no user-supplied metricsProvider needed.
it('auto-wires X-CacheKit-L1-* telemetry headers from live cache counters', async () => {
it('auto-wires the X-CacheKit-L1-* counter headers from live cache counters', async () => {
const { createCache } = await import('../cache.js');

const cache = createCache({
Expand Down
58 changes: 52 additions & 6 deletions packages/cachekit/src/backends/cachekitio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { BackendError, ConfigurationError, TimeoutError } from '../errors.js';
import { DEFAULT_TTL_SECONDS } from '../constants.js';
import { getSessionHeaders } from './session.js';
import { buildMetricsHeaders } from './metrics-headers.js';
import { L1_LATENCY_ONE_IN, L1LatencyReport, resolveTelemetry } from './telemetry.js';
import { oneAttemptDispatcher } from './one-attempt-dispatcher.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';
import { validateCachekitUrl } from './url-validator.js';
import { USER_AGENT } from './user-agent.js';
Expand Down Expand Up @@ -163,6 +165,14 @@ export class CachekitIOCore implements Backend {
private readonly defaultTtl: number;
private readonly timeout: number;
private readonly metricsProvider?: () => import('./types.js').L1Metrics | null;
/** The telemetry switch, resolved once at construction (API-95). */
private readonly telemetry: boolean;
/**
* This backend's one L1 hit-latency accumulator (API-83 to API-86); absent
* while telemetry is off, so nothing is timed into it. See Backend.l1Latency.
* @internal
*/
readonly l1Latency?: L1LatencyReport;
private closed = false;

constructor(config: CachekitIOBackendConfig) {
Expand All @@ -175,6 +185,8 @@ export class CachekitIOCore implements Backend {
this.defaultTtl = validateTtl(config.defaultTtl ?? DEFAULT_TTL_SECONDS);
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
this.metricsProvider = config.metricsProvider;
this.telemetry = resolveTelemetry(config.telemetry);
if (this.telemetry) this.l1Latency = new L1LatencyReport(L1_LATENCY_ONE_IN);
}

async get(key: string): Promise<Uint8Array | null> {
Expand Down Expand Up @@ -300,7 +312,10 @@ export class CachekitIOCore implements Backend {
async health(): Promise<{ healthy: boolean; latencyMs: number; version?: string }> {
const start = performance.now();
try {
const response = await this.request('GET', `${this.apiUrl}/v1/cache/health`);
// No latency report: the server does not record health checks (API-84).
const response = await this.request('GET', `${this.apiUrl}/v1/cache/health`, {
latencyReport: false,
});
const latencyMs = performance.now() - start;
if (response.status === 429) return { healthy: false, latencyMs };
if (!response.ok) return { healthy: false, latencyMs };
Expand Down Expand Up @@ -356,23 +371,34 @@ export class CachekitIOCore implements Backend {
return `${this.apiUrl}/v1/cache/${encodeKey(key)}`;
}

/**
* One attempt. With telemetry off it carries no optional metrics header
* (API-96). A latency report is taken here, on every call, so each attempt
* the cache's retries make takes its own: the one it takes is consumed
* whatever the outcome, and never re-sent (API-86), not even by the resend
* fetch makes on its own after a 421 (see takeLatencyReport). The headers
* go only to this backend's configured API, and a redirect is never
* followed (API-77).
*/
private async request(
method: string,
url: string,
opts?: { body?: Uint8Array; headers?: Record<string, string> }
opts?: { body?: Uint8Array; headers?: Record<string, string>; latencyReport?: false }
): Promise<Response> {
const report = opts?.latencyReport === false ? undefined : this.takeLatencyReport();
const headers: Record<string, string> = {
'User-Agent': USER_AGENT,
Authorization: `Bearer ${this.apiKey}`,
...getSessionHeaders(),
...buildMetricsHeaders(this.metricsProvider),
...(this.telemetry && getSessionHeaders()),
...buildMetricsHeaders(this.metricsProvider, this.telemetry),
...report?.headers,
...opts?.headers,
};
if (opts?.body) {
headers['Content-Type'] = opts.headers?.['Content-Type'] ?? 'application/octet-stream';
}

return fetch(url, {
const init: RequestInit = {
method,
headers,
body: opts?.body,
Expand All @@ -382,7 +408,27 @@ export class CachekitIOCore implements Backend {
// Not 'error': Node raises that as a network failure, which is retried.
redirect: 'manual',
signal: AbortSignal.timeout(this.timeout),
});
};
// Typed loosely: the dispatcher implements the one method of undici's
// Dispatcher that fetch calls, and the package does not depend on undici.
if (report) (init as { dispatcher?: object }).dispatcher = report.dispatcher;
return fetch(url, init);
}

/**
* This backend's latency report for one request, with the fetch dispatcher
* that strips it from any resend of that request (one-attempt-dispatcher.ts).
* Undefined, leaving the report in place, while there is nothing to report
* (API-85) or no such dispatcher: on any runtime but Node, which alone
* times L1 hits, and on Node while its global dispatcher is anything but
* undici's own Agent, which might resend the request.
*/
private takeLatencyReport(): { headers: Record<string, string>; dispatcher: object } | undefined {
const report = this.l1Latency;
if (report === undefined || report.empty) return undefined;
const dispatcher = oneAttemptDispatcher();
if (dispatcher === undefined) return undefined;
return { headers: report.take()!, dispatcher };
}

private async httpError(operation: string, response: Response): Promise<BackendError> {
Expand Down
Loading
Loading