caliper-timings 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-04
4
+
5
+ ### Added
6
+
7
+ - **Core:** `CaliperRun`, `MemoryStore`, `tokenMatches`, `isValidRunId`, `isValidMarkName` and the `TimingRecord` type, ported from Quill's backend. They now use only erasable TypeScript, so Node 22 runs the source directly.
8
+ - **Mark names** are now app-chosen, following SPEC v1: `<name>Start` / `<name>End` pairs, plus the reserved `modelCallSent` and `firstChunk`. An invalid name is skipped and reported in the record's new `problems` field.
9
+ - **Nothing throws into the app's request** (SPEC: never change the app). A store that throws synchronously no longer reaches the request; Quill's version only caught rejections. A store failure on the timings endpoint answers 500.
10
+ - **`MemoryStore`** now also deletes an expired record when a `get` finds it. Before, only the next `put` did.
11
+ - **`watchFirstChunk`** skips chunks with no bytes.
12
+ - **`caliper-timings/node`:** `createTimings({ token, store })`, with `handle(req, res)` for the timings endpoint and `start(req, res)` for a route.
13
+ - **`SPEC.md`:** the protocol, v1, with the rule that an implementation never changes the app's response or errors, and the `problems` field.
14
+ - **First published release.** The package ships the compiled JavaScript and type declarations (`dist/`), with this changelog, the README, SPEC.md and the license.
15
+ - **Measured cost** in SPEC.md and the README: on `examples/node-memory`, marked requests median 1.2 ms slower before the first byte (20 pairs), unmarked requests median 0.0 ms against the pre-wiring server (20 pairs). Cause not measured.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jiya Agrawal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # caliper-timings
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The server side of [Caliper](https://github.com/Jia-A/caliper)'s server timings. Caliper (`caliper-ai`) measures an AI feature in a real browser. With this on your server, it can also show what happened inside one marked request: how long it waited on the database, on the model, or on any step you mark.
4
+
5
+ Light enough to stay in production, deployed dark:
6
+
7
+ - No runtime dependencies.
8
+ - The core uses only `Date.now()`, Web Streams and `crypto.subtle`.
9
+ - Off unless `CALIPER_TOKEN` is set.
10
+ - Unmarked requests are never touched.
11
+
12
+ The wire format is in [SPEC.md](SPEC.md), so other languages can implement it too.
13
+
14
+ ## Install
15
+
16
+ Two steps, in two places. Step 2 is optional.
17
+
18
+ ### 1. On your machine: `caliper-ai`
19
+
20
+ The machine you run Caliper from (your laptop, or CI). It's the CLI that marks a request and reads the timings back:
21
+
22
+ ```sh
23
+ npm install -g caliper-ai
24
+ npx playwright install chromium
25
+ ```
26
+
27
+ See the [caliper-ai README](https://github.com/Jia-A/caliper/tree/main/packages/cli#readme).
28
+
29
+ ### 2. In your server's project: `caliper-timings` (optional)
30
+
31
+ Only if you want server timings. Install it in the project of the app Caliper measures, then wire it in as below:
32
+
33
+ ```sh
34
+ npm install caliper-timings
35
+ ```
36
+
37
+ Without it (or without `CALIPER_TOKEN` set on the server), Caliper still times everything it sees from the browser. Requires Node.js 20 or newer.
38
+
39
+ ## Node (`node:http`)
40
+
41
+ ```js
42
+ import { createServer } from "node:http";
43
+ import { createTimings, MemoryStore } from "caliper-timings/node";
44
+
45
+ const timings = createTimings({ token: process.env.CALIPER_TOKEN, store: new MemoryStore() });
46
+
47
+ createServer(async (req, res) => {
48
+ if (await timings.handle(req, res)) return; // GET /__caliper/timings/:id; false for everything else
49
+
50
+ if (req.method === "POST" && req.url === "/api/generate") {
51
+ const run = await timings.start(req, res); // null unless this request is marked
52
+ run?.mark("dbStart");
53
+ const row = await db.query(/* … */);
54
+ run?.mark("dbEnd");
55
+ run?.mark("modelCallSent");
56
+ res.writeHead(200, { "content-type": "text/event-stream" });
57
+ for await (const chunk of model.stream(/* … */)) res.write(chunk); // the first write with bytes: firstChunk
58
+ res.end();
59
+ return;
60
+ }
61
+ // … your other routes and your 404
62
+ }).listen(8787);
63
+ ```
64
+
65
+ - **`handle(req, res)`** answers `GET /__caliper/timings/:id` in SPEC.md's check order and returns `true`. For any other request it returns `false` and doesn't touch the response. With no token set it returns `false` for every request, so your own 404 answers.
66
+ - **`start(req, res)`** returns a `CaliperRun` only when the request carries a valid `x-caliper-run` and the right `x-caliper-token`. Otherwise it returns `null` and the request and response are left exactly as they were. Call it first thing in the route: marks are measured from the moment `start()` was called.
67
+ - **`run.mark(name)`** records ms since then. Name spans with `<name>Start` / `<name>End` (`dbStart` / `dbEnd`); Caliper shows each pair as a span called `<name>`. `modelCallSent` and `firstChunk` are reserved. A name outside `^[A-Za-z][A-Za-z0-9_]{0,63}$` is skipped, not thrown, and Caliper prints it under "problems reported by the server".
68
+ - **When the record is saved:** at the first `res.write` with bytes, with `firstChunk` marked then. Otherwise when the response closes, whether it finished or the client left. `status` is `res.statusCode`, or `null` if no headers were sent. Every write goes through unchanged.
69
+
70
+ ### It never changes your app
71
+
72
+ A marked request succeeds or fails exactly as it would unmarked; that's the protocol's [rule](SPEC.md#the-rule-never-change-the-app). Nothing in the wrapper throws into your request:
73
+ - **`start()`** returns `null` if anything inside it fails, and the request runs unmarked.
74
+ - **`mark()` and `save()`** never throw or reject.
75
+ - **A store that throws or rejects** on save is logged to `console.error`, and the record is dropped.
76
+ - **A URL `handle()` can't parse** is left to your app.
77
+ - **A store failure while Caliper reads the timings endpoint** answers that request with 500.
78
+
79
+ ### MemoryStore
80
+
81
+ `MemoryStore` keeps records in a `Map`, for 10 minutes after each write.
82
+
83
+ **Use it for single-process servers only.** On Workers, or several processes behind a load balancer, each one has its own `Map`, so Caliper's read can land where the write never happened (`404`).
84
+
85
+ **Cleanup:** every `put` deletes every expired record first, and a `get` that finds an expired record deletes it. After a write, the store holds only records written in the last 10 minutes. If writes stop, what's left stays until the next `put` or `get`. That's bounded by the last 10 minutes of marked requests, not a growing leak. Only requests carrying the right token create records.
86
+
87
+ ## Core (`caliper-timings`)
88
+
89
+ The core works in any runtime with Web Streams and `crypto.subtle`. It exports:
90
+
91
+ - `CaliperRun`, with `mark`, `save`, and `watchFirstChunk(stream)` for frameworks that return a `ReadableStream`;
92
+ - `MemoryStore` and the `TimingStore` interface;
93
+ - `tokenMatches`, `isValidRunId`, `isValidMarkName`;
94
+ - the header and path constants;
95
+ - the `TimingRecord` type.
96
+
97
+ There's no framework adapter other than Node yet.
98
+
99
+ ## Measured cost
100
+
101
+ The rule covers status, headers, bytes and errors, not time. This is what the reference implementation (`caliper-timings/node`) measured in time, on [`examples/node-memory`](https://github.com/Jia-A/caliper/tree/main/examples/node-memory): a single-process `node:http` app on 127.0.0.1, Node 22.22, one macOS machine. The client is `examples/node-memory/measure.mjs`, timing on its own clock from sending the request to the first body chunk arriving, one request at a time, each on a new connection.
102
+
103
+ - **Marked requests: about 1.2 ms slower before the first byte.** 20 marked and 20 unmarked requests to the same server, alternating, compared in pairs: the marked one was later by median 1.2 ms (min 0.1, max 4.1). The difference is already there when the headers arrive (paired median 1.4 ms); between headers and first chunk it's 0.1 ms. What causes it hasn't been measured.
104
+ - **Unmarked requests: no difference measured.** 20 unmarked requests to the wired server (`CALIPER_TOKEN` set) alternated with 20 to the same app before it was wired: paired difference median 0.0 ms (min −2.3, max 5.4).
105
+
106
+ That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
107
+
108
+ ## Tested
109
+
110
+ `npm test` runs Node 22.18+ directly on the TypeScript source. It covers:
111
+
112
+ - **The endpoint's check order**, over a real socket on 127.0.0.1: 404 with no or empty token (the app's own), 401 for a missing, wrong or non-`Bearer` token before the store is read, 400 for malformed ids including Caliper's `caliper.precheck`, 404 for no record, 200 with the record, and `no-store` on every answer.
113
+ - **Which requests qualify:** marked requests get the same status, headers and bytes as unmarked ones. Requests missing a header, with a wrong token or a malformed id, or on a server with no token, record nothing.
114
+ - **When the record is saved:** at the first write with bytes (an empty write doesn't count), at close for `res.end(body)` replies, and at close with `status: null` when the client leaves before headers.
115
+ - **Never changing the app:** with a store whose `put` throws, or one whose `put` rejects, the marked response is byte-identical to the unmarked one and nothing escapes as an unhandled error. With a broken `get`, the endpoint answers 500 and app requests are untouched. A URL that doesn't parse is left to the app. A `save()` with a throwing store or `defer` neither throws nor rejects. Each of these was checked by removing the guard, and the test then fails.
116
+ - **Marks and mark names:** marks in ms from `start()`. Bad names are skipped and reported in `problems`, each once, at most 20, then one line saying more were dropped. There's no `problems` field when nothing went wrong.
117
+ - **`runtime`:** `"workers"` only for `navigator.userAgent === "Cloudflare-Workers"`, and absent on Node.
118
+ - **`watchFirstChunk`:** chunks unchanged, saved once.
119
+ - **A failed save** is logged and not thrown.
120
+ - **MemoryStore expiry and cleanup,** including 3 hours of one write a minute never holding more than 10 records.
121
+
122
+ ## Known limits
123
+
124
+ - **Saved once, at the first chunk.** Marks made after the first chunk of a streamed reply aren't in the record.
125
+ - **`res.end(body)` doesn't mark `firstChunk`.** A reply sent only that way gets no `firstChunk` mark; only `res.write` is watched.
126
+ - **One process only.** `MemoryStore` is for single-process servers. There's no shared store for Node yet. The Durable Object store for Workers comes with the Hono adapter.
127
+ - **Node only, so far.** Only the `node:http` adapter exists. Express or Fastify can use it through their raw `req`/`res`, but that hasn't been tested.
128
+ - **No cross-origin test yet.** Cross-origin APIs need the CORS preflight to allow `x-caliper-run` and `x-caliper-token`.
129
+ - **Node 20: one end-to-end run, no test suite.** The packed package was installed into a fresh project and the [example server](https://github.com/Jia-A/caliper/tree/main/examples/node-memory) ran on Node 20.18.3 with Caliper's 3 marked visits. `npm test` runs on Node 22 only.
130
+ - **ES module only.** Use `import`. `require("caliper-timings/node")` works on Node 22.22 but fails on Node 20.18.3 with `ERR_REQUIRE_ESM`. The type declarations were checked from an ES module with TypeScript 5.0, 5.9 and 7.0.
package/SPEC.md ADDED
@@ -0,0 +1,107 @@
1
+ # Caliper server timings protocol, v1
2
+
3
+ A server that implements this lets [Caliper](https://github.com/Jia-A/caliper) read timings from inside one marked request: how long the request waited on the database, on the model, or on any other step the app chooses to mark. Any language can implement it. `caliper-timings` is the reference implementation (JavaScript).
4
+
5
+ The feature is off unless the server has a secret configured, so it can stay deployed in production, dark.
6
+
7
+ ## The rule: never change the app
8
+
9
+ **An implementation must never change the response or error behaviour of the app's request.** A marked request must succeed or fail exactly as it would unmarked: same status, headers, bytes and errors.
10
+
11
+ Every failure inside the implementation is caught there and never reaches the app's request. That includes a store that throws or rejects, a save that fails, a bad mark name, or a request it can't parse. When the record can still carry it, the failure is noted in the record's `problems` (below). Otherwise it's dropped; the reference logs it to the server's error log.
12
+
13
+ A failure on Caliper's own read of the timings endpoint answers that request with 500. The app's requests are unaffected.
14
+
15
+ ### Measured cost
16
+
17
+ The rule covers status, headers, bytes and errors, not time. This is what the reference implementation (`caliper-timings/node`) measured in time, on [`examples/node-memory`](https://github.com/Jia-A/caliper/tree/main/examples/node-memory): a single-process `node:http` app on 127.0.0.1, Node 22.22, one macOS machine. The client is `examples/node-memory/measure.mjs`, timing on its own clock from sending the request to the first body chunk arriving, one request at a time, each on a new connection.
18
+
19
+ - **Marked requests: about 1.2 ms slower before the first byte.** 20 marked and 20 unmarked requests to the same server, alternating, compared in pairs: the marked one was later by median 1.2 ms (min 0.1, max 4.1). The difference is already there when the headers arrive (paired median 1.4 ms); between headers and first chunk it's 0.1 ms. What causes it hasn't been measured.
20
+ - **Unmarked requests: no difference measured.** 20 unmarked requests to the wired server (`CALIPER_TOKEN` set) alternated with 20 to the same app before it was wired: paired difference median 0.0 ms (min −2.3, max 5.4).
21
+
22
+ That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
23
+
24
+ ## Secret
25
+
26
+ The server has a secret, `CALIPER_TOKEN`. Without one (unset or empty), the feature doesn't exist: nothing is recorded, and the endpoint below answers like any unknown route (404).
27
+
28
+ ## Marking a request
29
+
30
+ Caliper adds two headers to one request:
31
+
32
+ | Header | Value |
33
+ |---|---|
34
+ | `x-caliper-run` | the run id: 1–64 characters of `A-Z a-z 0-9 _ -` (`^[A-Za-z0-9_-]{1,64}$`) |
35
+ | `x-caliper-token` | the secret |
36
+
37
+ The server records timings for that request only when all of these hold:
38
+
39
+ - a secret is configured;
40
+ - the run id is valid;
41
+ - the token equals the secret. The comparison should be constant-time. The reference hashes both sides with SHA-256 and compares the digests byte by byte.
42
+
43
+ In every other case the request runs exactly like an unmarked one: same status, headers and bytes, no error, nothing recorded. A bad id or token on the request is silent; the endpoint reports it later.
44
+
45
+ ## Marks
46
+
47
+ A mark is a name and the number of milliseconds since the server's route received the request. It's a finite number; the reference records whole milliseconds from `Date.now()`.
48
+
49
+ - **Names** are case-sensitive and match `^[A-Za-z][A-Za-z0-9_]{0,63}$`: a letter, then up to 63 letters, digits or `_`. A mark with any other name is skipped, never thrown, and reported in `problems`.
50
+ - **Spans** are chosen by the app. Any pair `<name>Start` / `<name>End` is one span, called `<name>`: `dbStart` / `dbEnd` is the span `db`, and `vectorSearchStart` / `vectorSearchEnd` is the span `vectorSearch`. `<name>` must be at least one character, and the suffix must be exactly `Start` or `End`.
51
+ - **Reserved:** `modelCallSent` (just before the model call is sent) and `firstChunk` (the first response chunk with bytes leaves the server). Caliper shows the span between them as "model: sent → first chunk".
52
+ - **No span** when only one of the pair is recorded, or when `<name>End` is earlier than `<name>Start`. Caliper then shows the marks as they are and says why there's no span. It never estimates one.
53
+ - **A step the request never reached has no mark.** Marks are never zero-filled or estimated. Marking the same name twice keeps the later value.
54
+ - Other names are allowed, and Caliper shows them under their own name. Caliper computes spans only from pairs and the reserved model pair, never between unrelated marks.
55
+
56
+ ## Record
57
+
58
+ ```json
59
+ {
60
+ "id": "3f1c…",
61
+ "status": 200,
62
+ "marks": { "dbStart": 1, "dbEnd": 301, "modelCallSent": 301, "firstChunk": 402 },
63
+ "runtime": "workers"
64
+ }
65
+ ```
66
+
67
+ | Field | |
68
+ |---|---|
69
+ | `id` | the run id from `x-caliper-run` |
70
+ | `status` | the response status, or `null` when it isn't known (e.g. the client left before headers were sent) |
71
+ | `marks` | name → ms, as above; may be empty |
72
+ | `runtime` | optional, see below |
73
+ | `problems` | optional: a list of strings, in words, saying what the implementation skipped or couldn't do for this request. The reference writes `mark name "db-start" rejected — use a letter, then up to 63 letters, digits or _ (e.g. dbStart)`, keeps each one once and at most 20, then adds one line saying more were dropped. Leave the field out when there's nothing to report. Caliper prints each one as given, with control characters escaped and anything past 200 characters cut. A `problems` that isn't a list of strings makes Caliper reject the record. |
74
+
75
+ **When it's saved:** for a streamed response, at the first chunk with bytes, so marks made after that aren't in the record. For any other response, when it finishes or the connection closes.
76
+
77
+ Records should expire. The reference keeps them for 10 minutes after the last write.
78
+
79
+ ### `runtime`
80
+
81
+ Include it only when the server has detected where it runs. Never guess or default it. The only value defined so far is `"workers"`: Cloudflare Workers, detected by `navigator.userAgent === "Cloudflare-Workers"`. On Workers, `Date.now()` only advances at I/O, so a span with only computation inside can read 0 ms. When the record says `"workers"`, Caliper prints a note saying so. Without the field, Caliper prints no runtime note.
82
+
83
+ ## Endpoint
84
+
85
+ `GET /__caliper/timings/:id` with `Authorization: Bearer <secret>`.
86
+
87
+ The checks run in this order, and the first one that fails decides the answer:
88
+
89
+ | # | Check | Answer |
90
+ |---|---|---|
91
+ | 1 | no secret configured | **404**, like any unknown route |
92
+ | 2 | bearer missing, not in the `Bearer <token>` form, or wrong | **401**, before the store is read |
93
+ | 3 | id malformed | **400** |
94
+ | 4 | no record for the id, or it expired | **404** |
95
+ | 5 | otherwise | **200**, the record as JSON |
96
+
97
+ Caliper's token pre-check depends on this order. Before any marked request it asks for the id `caliper.precheck`. The `.` makes that id invalid, so it can never name a record: with the right token the answer is 400, with a wrong one 401, and with the feature off 404.
98
+
99
+ A failure reading the store (step 4) answers **500**.
100
+
101
+ The reference answers 401, 400, 404 and 500 with a JSON body `{ "error": { "code": …, "message": … } }`. The codes are `UNAUTHORIZED`, `INVALID_RUN_ID`, `NOT_FOUND` and `STORE_ERROR`, and every answer carries `cache-control: no-store`. Caliper reads only the status, so the bodies are informative.
102
+
103
+ The endpoint must not redirect. Caliper doesn't follow redirects, so the token can't be carried anywhere else.
104
+
105
+ ## Cross-origin
106
+
107
+ If the page calls the API on another origin, the CORS preflight must allow the `x-caliper-run` and `x-caliper-token` request headers. Caliper hasn't been tested against a cross-origin API yet.
@@ -0,0 +1,4 @@
1
+ export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, type TimingRecord, type TimingStore, } from "./protocol.ts";
2
+ export { CaliperRun, type RunOptions } from "./run.ts";
3
+ export { MemoryStore } from "./memoryStore.ts";
4
+ export { tokenMatches } from "./token.ts";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // The framework-free core: only Date.now(), Web Streams and crypto.subtle.
2
+ export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, } from "./protocol.js";
3
+ export { CaliperRun } from "./run.js";
4
+ export { MemoryStore } from "./memoryStore.js";
5
+ export { tokenMatches } from "./token.js";
@@ -0,0 +1,7 @@
1
+ import { type TimingRecord, type TimingStore } from "./protocol.ts";
2
+ export declare class MemoryStore implements TimingStore {
3
+ #private;
4
+ put(id: string, record: TimingRecord): Promise<void>;
5
+ get(id: string): Promise<TimingRecord | null>;
6
+ get size(): number;
7
+ }
@@ -0,0 +1,31 @@
1
+ import { RECORD_TTL_MS } from "./protocol.js";
2
+ // Single-process servers only: on Workers each isolate has its own Map (and so does each process
3
+ // behind a load balancer), so a read can land where the write never happened.
4
+ // Cleanup: every put deletes every expired record first, and a get that finds an expired record
5
+ // deletes it. So after a put, the Map holds only records written in the last RECORD_TTL_MS. If
6
+ // writes stop, what's left stays until the next put or get: a bounded leftover, not a leak.
7
+ export class MemoryStore {
8
+ #records = new Map();
9
+ async put(id, record) {
10
+ const now = Date.now();
11
+ for (const [key, entry] of this.#records) {
12
+ if (entry.expiresAt <= now)
13
+ this.#records.delete(key);
14
+ }
15
+ this.#records.set(id, { record, expiresAt: now + RECORD_TTL_MS });
16
+ }
17
+ async get(id) {
18
+ const entry = this.#records.get(id);
19
+ if (!entry)
20
+ return null;
21
+ if (entry.expiresAt <= Date.now()) {
22
+ this.#records.delete(id);
23
+ return null;
24
+ }
25
+ return entry.record;
26
+ }
27
+ // Records held, including expired ones not deleted yet.
28
+ get size() {
29
+ return this.#records.size;
30
+ }
31
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type TimingStore } from "./protocol.ts";
3
+ import { CaliperRun } from "./run.ts";
4
+ export * from "./index.ts";
5
+ export type TimingsOptions = {
6
+ token: string | undefined;
7
+ store: TimingStore;
8
+ };
9
+ export type Timings = {
10
+ handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
11
+ start(req: IncomingMessage, res: ServerResponse): Promise<CaliperRun | null>;
12
+ };
13
+ export declare function createTimings({ token, store }: TimingsOptions): Timings;
package/dist/node.js ADDED
@@ -0,0 +1,131 @@
1
+ import { isValidRunId, RUN_HEADER, TIMINGS_PATH, TOKEN_HEADER } from "./protocol.js";
2
+ import { CaliperRun, hasBytes } from "./run.js";
3
+ import { tokenMatches } from "./token.js";
4
+ export * from "./index.js";
5
+ const header = (req, name) => {
6
+ const value = req.headers[name];
7
+ return typeof value === "string" ? value : undefined;
8
+ };
9
+ function sendJson(res, status, body) {
10
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
11
+ res.end(JSON.stringify(body));
12
+ return true;
13
+ }
14
+ export function createTimings({ token, store }) {
15
+ const secret = token || undefined;
16
+ return {
17
+ // GET /__caliper/timings/:id, checked in SPEC.md's order. Returns false (and leaves the
18
+ // response alone) for any other request, and for every request when no secret is set, so
19
+ // the app answers it like any unknown route.
20
+ async handle(req, res) {
21
+ if (!secret || req.method !== "GET")
22
+ return false;
23
+ let path;
24
+ try {
25
+ path = new URL(req.url ?? "/", "http://localhost").pathname;
26
+ }
27
+ catch {
28
+ return false; // a URL that doesn't parse is the app's to answer
29
+ }
30
+ if (!path.startsWith(TIMINGS_PATH))
31
+ return false;
32
+ // Checked before anything else, so an unauthenticated call never reads the store.
33
+ const bearer = /^Bearer (.+)$/.exec(header(req, "authorization") ?? "")?.[1];
34
+ if (bearer === undefined || !(await tokenMatches(bearer, secret))) {
35
+ return sendJson(res, 401, { error: { code: "UNAUTHORIZED", message: "Missing or invalid Caliper token" } });
36
+ }
37
+ let id = "";
38
+ try {
39
+ id = decodeURIComponent(path.slice(TIMINGS_PATH.length));
40
+ }
41
+ catch {
42
+ // Not valid percent-encoding: rejected as malformed below.
43
+ }
44
+ if (!isValidRunId(id)) {
45
+ return sendJson(res, 400, {
46
+ error: { code: "INVALID_RUN_ID", message: "Run id must be 1-64 characters of A-Z, a-z, 0-9, _ or -" },
47
+ });
48
+ }
49
+ let record;
50
+ try {
51
+ record = await store.get(id);
52
+ }
53
+ catch (err) {
54
+ logFailure("Caliper timing record not read", err);
55
+ return sendJson(res, 500, { error: { code: "STORE_ERROR", message: "The timing store failed; see the server log" } });
56
+ }
57
+ if (!record)
58
+ return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "No timing record for this run id" } });
59
+ return sendJson(res, 200, record);
60
+ },
61
+ // A CaliperRun when the request carries a valid x-caliper-run and the right x-caliper-token,
62
+ // otherwise null and the request and response are left exactly as they were. A malformed id
63
+ // is ignored here; the endpoint rejects it with 400 when Caliper asks for it. Never throws:
64
+ // if anything fails, the request runs unmarked.
65
+ async start(req, res) {
66
+ try {
67
+ const arrived = Date.now();
68
+ const id = header(req, RUN_HEADER);
69
+ if (id === undefined || !secret)
70
+ return null;
71
+ const given = header(req, TOKEN_HEADER);
72
+ if (given === undefined || !isValidRunId(id))
73
+ return null;
74
+ if (!(await tokenMatches(given, secret)))
75
+ return null;
76
+ const run = new CaliperRun(id, store, { start: arrived });
77
+ watchResponse(run, res);
78
+ return run;
79
+ }
80
+ catch (err) {
81
+ logFailure("Caliper timings off for this request", err);
82
+ return null;
83
+ }
84
+ },
85
+ };
86
+ }
87
+ function logFailure(what, err) {
88
+ try {
89
+ console.error(what, err);
90
+ }
91
+ catch {
92
+ // Not even the log: drop it.
93
+ }
94
+ }
95
+ // The record is saved once: at the first res.write with bytes (a streamed reply: firstChunk is
96
+ // marked then), or else when the response closes (finished, or the client left). Every write
97
+ // goes through unchanged, and whatever the original write does (returns, throws) is what the app
98
+ // sees; after the first, res.write is the original again. The wrapper's own part can't throw.
99
+ function watchResponse(run, res) {
100
+ let saved = false;
101
+ const write = res.write;
102
+ res.write = function (...args) {
103
+ // Backstop for the never-change-the-app rule (SPEC.md). Nothing inside can throw today;
104
+ // this protects future edits. No test can trigger it.
105
+ try {
106
+ if (!saved && hasBytes(args[0])) {
107
+ saved = true;
108
+ res.write = write;
109
+ run.status = res.statusCode;
110
+ run.mark("firstChunk");
111
+ void run.save();
112
+ }
113
+ }
114
+ catch (err) {
115
+ logFailure("Caliper timing record not saved", err);
116
+ }
117
+ return write.apply(this, args);
118
+ };
119
+ res.once("close", () => {
120
+ try {
121
+ if (saved)
122
+ return;
123
+ saved = true;
124
+ run.status = res.headersSent ? res.statusCode : null;
125
+ void run.save();
126
+ }
127
+ catch (err) {
128
+ logFailure("Caliper timing record not saved", err);
129
+ }
130
+ });
131
+ }
@@ -0,0 +1,17 @@
1
+ export declare const RUN_HEADER = "x-caliper-run";
2
+ export declare const TOKEN_HEADER = "x-caliper-token";
3
+ export declare const TIMINGS_PATH = "/__caliper/timings/";
4
+ export type TimingRecord = {
5
+ id: string;
6
+ runtime?: "workers";
7
+ status: number | null;
8
+ marks: Record<string, number>;
9
+ problems?: string[];
10
+ };
11
+ export interface TimingStore {
12
+ put(id: string, record: TimingRecord): Promise<void>;
13
+ get(id: string): Promise<TimingRecord | null>;
14
+ }
15
+ export declare const RECORD_TTL_MS: number;
16
+ export declare const isValidRunId: (id: string) => boolean;
17
+ export declare const isValidMarkName: (name: unknown) => name is string;
@@ -0,0 +1,11 @@
1
+ // The wire format of SPEC.md: headers, ids, mark names, the record and where it's read.
2
+ export const RUN_HEADER = "x-caliper-run";
3
+ export const TOKEN_HEADER = "x-caliper-token";
4
+ export const TIMINGS_PATH = "/__caliper/timings/";
5
+ export const RECORD_TTL_MS = 10 * 60 * 1000;
6
+ const RUN_ID = /^[A-Za-z0-9_-]{1,64}$/;
7
+ export const isValidRunId = (id) => RUN_ID.test(id);
8
+ // Case-sensitive. <name>Start / <name>End is a span called <name>; modelCallSent and firstChunk
9
+ // are reserved for the model call.
10
+ const MARK_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
11
+ export const isValidMarkName = (name) => typeof name === "string" && MARK_NAME.test(name);
package/dist/run.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type TimingStore } from "./protocol.ts";
2
+ export declare const hasBytes: (chunk: unknown) => boolean;
3
+ export type RunOptions = {
4
+ start?: number;
5
+ defer?: (p: Promise<void>) => void;
6
+ };
7
+ export declare class CaliperRun {
8
+ #private;
9
+ readonly id: string;
10
+ status: number | null;
11
+ constructor(id: string, store: TimingStore, { start, defer }?: RunOptions);
12
+ mark(name: string): void;
13
+ watchFirstChunk<T>(stream: ReadableStream<T>): ReadableStream<T>;
14
+ save(): Promise<void>;
15
+ get savesOnStream(): boolean;
16
+ }
package/dist/run.js ADDED
@@ -0,0 +1,102 @@
1
+ import { isValidMarkName } from "./protocol.js";
2
+ // Cloudflare documents navigator.userAgent === "Cloudflare-Workers" as the way
3
+ // to tell code is running on Workers (global_navigator flag, on by default
4
+ // since compatibility date 2022-03-21):
5
+ // https://developers.cloudflare.com/workers/runtime-apis/web-standards/
6
+ const isWorkers = () => globalThis.navigator?.userAgent === "Cloudflare-Workers";
7
+ // A chunk with no bytes (an empty write to flush headers) isn't the first chunk.
8
+ export const hasBytes = (chunk) => typeof chunk === "string" ? chunk.length > 0
9
+ : ArrayBuffer.isView(chunk) ? chunk.byteLength > 0
10
+ : chunk !== undefined && chunk !== null;
11
+ const MAX_PROBLEMS = 20;
12
+ // Logged, never thrown: nothing the wrapper does may reach the app's request (SPEC.md).
13
+ function logFailure(err) {
14
+ try {
15
+ console.error("Caliper timing record not saved", err);
16
+ }
17
+ catch {
18
+ // Not even the log: drop it.
19
+ }
20
+ }
21
+ // One marked request. On Workers, Date.now() is frozen while code runs and only
22
+ // advances at I/O, so a span with only computation inside reads 0 ms there.
23
+ // No method throws or rejects: a marked request succeeds or fails exactly as unmarked.
24
+ export class CaliperRun {
25
+ id;
26
+ status = null;
27
+ #start;
28
+ #marks = {};
29
+ #problems = [];
30
+ #store;
31
+ #defer;
32
+ #watchingStream = false;
33
+ constructor(id, store, { start = Date.now(), defer = () => { } } = {}) {
34
+ this.id = id;
35
+ this.#store = store;
36
+ this.#start = start;
37
+ this.#defer = defer;
38
+ }
39
+ // A name SPEC.md doesn't allow is skipped and reported in the record's problems, never thrown.
40
+ mark(name) {
41
+ if (!isValidMarkName(name)) {
42
+ this.#problem(`mark name ${JSON.stringify(name)} rejected — use a letter, then up to 63 letters, digits or _ (e.g. dbStart)`);
43
+ return;
44
+ }
45
+ this.#marks[name] = Date.now() - this.#start;
46
+ }
47
+ // Each problem once; after MAX_PROBLEMS, one line says the rest were dropped.
48
+ #problem(text) {
49
+ if (this.#problems.includes(text) || this.#problems.length > MAX_PROBLEMS)
50
+ return;
51
+ this.#problems.push(this.#problems.length < MAX_PROBLEMS ? text : `more problems not recorded (only the first ${MAX_PROBLEMS} are kept)`);
52
+ }
53
+ // Passes every chunk through unchanged (same bytes, same chunk boundaries);
54
+ // it only notes when the first one with bytes arrives, then saves the record.
55
+ watchFirstChunk(stream) {
56
+ this.#watchingStream = true;
57
+ let seen = false;
58
+ return stream.pipeThrough(new TransformStream({
59
+ transform: (chunk, controller) => {
60
+ if (!seen && hasBytes(chunk)) {
61
+ seen = true;
62
+ this.mark("firstChunk");
63
+ void this.save();
64
+ }
65
+ controller.enqueue(chunk);
66
+ },
67
+ flush: () => {
68
+ if (!seen)
69
+ void this.save();
70
+ },
71
+ }));
72
+ }
73
+ // Never throws and never rejects, whatever the store or defer does: a failure is logged and
74
+ // the record is dropped (it can't carry its own failure).
75
+ save() {
76
+ let saved;
77
+ try {
78
+ const record = {
79
+ id: this.id,
80
+ ...(isWorkers() && { runtime: "workers" }),
81
+ status: this.status,
82
+ marks: { ...this.#marks },
83
+ ...(this.#problems.length && { problems: [...this.#problems] }),
84
+ };
85
+ saved = Promise.resolve(this.#store.put(this.id, record)).catch(logFailure);
86
+ }
87
+ catch (err) {
88
+ logFailure(err);
89
+ saved = Promise.resolve();
90
+ }
91
+ try {
92
+ this.#defer(saved);
93
+ }
94
+ catch (err) {
95
+ logFailure(err);
96
+ }
97
+ return saved;
98
+ }
99
+ get savesOnStream() {
100
+ return this.#watchingStream;
101
+ }
102
+ }
@@ -0,0 +1 @@
1
+ export declare function tokenMatches(given: string, expected: string): Promise<boolean>;
package/dist/token.js ADDED
@@ -0,0 +1,13 @@
1
+ // Constant-time check of a Caliper token against the CALIPER_TOKEN secret.
2
+ // Both sides are hashed first so the comparison always runs over 32 bytes,
3
+ // whatever the length of the given token.
4
+ export async function tokenMatches(given, expected) {
5
+ const enc = new TextEncoder();
6
+ const [a, b] = await Promise.all([given, expected].map((s) => crypto.subtle.digest("SHA-256", enc.encode(s))));
7
+ const x = new Uint8Array(a);
8
+ const y = new Uint8Array(b);
9
+ let diff = 0;
10
+ for (let i = 0; i < x.length; i++)
11
+ diff |= x[i] ^ y[i];
12
+ return diff === 0;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "caliper-timings",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Server side of Caliper's server timings: records when a marked request reaches each step, for caliper-ai to read back.",
5
+ "keywords": [
6
+ "caliper",
7
+ "ai",
8
+ "llm",
9
+ "performance",
10
+ "latency",
11
+ "server-timing"
12
+ ],
13
+ "author": "Jiya Agrawal",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Jia-A/caliper.git",
18
+ "directory": "packages/timings"
19
+ },
20
+ "homepage": "https://github.com/Jia-A/caliper#readme",
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./node": {
28
+ "types": "./dist/node.d.ts",
29
+ "default": "./dist/node.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist/",
34
+ "README.md",
35
+ "SPEC.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "node --test --test-timeout=20000 test/*.test.ts"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^20.19.43",
49
+ "typescript": "^7.0.2"
50
+ }
51
+ }