caliper-timings 0.0.0-stage → 0.2.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 +26 -0
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/SPEC.md +122 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/memoryStore.d.ts +7 -0
- package/dist/memoryStore.js +31 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.js +131 -0
- package/dist/protocol.d.ts +25 -0
- package/dist/protocol.js +15 -0
- package/dist/run.d.ts +17 -0
- package/dist/run.js +145 -0
- package/dist/token.d.ts +1 -0
- package/dist/token.js +13 -0
- package/package.json +49 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-11-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`run.prompt(messages)`: prompt sizes.** Records each message's role and the size of its text in bytes (UTF-8), in order, in the record's new optional `prompt` field (`{ "messages": [{ "role": "system", "bytes": 2310 }, …] }`). Never the text: a role that isn't a lowercase name of up to 32 letters or `_` is recorded as `"?"`, content that isn't a string gets `bytes: null` (never estimated), and problems name the message by number only. At most 1000 messages. Never throws.
|
|
8
|
+
- **SPEC v1: optional `prompt` field** ([Prompt sizes](SPEC.md#prompt-sizes)). Not breaking: a record without it is unchanged. caliper-ai 0.4.0's record reader (from the release commit) was run once on a record with `prompt`: it read the marks and ignored the field.
|
|
9
|
+
- `isValidRole` and the `PromptMessageSize` type are exported.
|
|
10
|
+
- **Known limit:** a role of up to 32 lowercase letters or `_` is recorded as it is, so text an app puts in a role in that form is recorded too. Message content never is.
|
|
11
|
+
- **Tests that `run.prompt()` never carries text or changes the app** (a marker in every part of a prompt; a Proxy that records writes; lists that throw when read; byte-identical responses over HTTP; no reads when unmarked), each checked by breaking the code.
|
|
12
|
+
- **Measured cost of `run.prompt()`:** median 2.5 µs per call for a Quill-sized prompt (8184 bytes), 10.0 µs for 40 messages, on Node 22.22 (Apple M2).
|
|
13
|
+
|
|
14
|
+
## 0.1.0 — 2026-11-04
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **`MemoryStore`** now also deletes an expired record when a `get` finds it. Before, only the next `put` did.
|
|
22
|
+
- **`watchFirstChunk`** skips chunks with no bytes.
|
|
23
|
+
- **`caliper-timings/node`:** `createTimings({ token, store })`, with `handle(req, res)` for the timings endpoint and `start(req, res)` for a route.
|
|
24
|
+
- **`SPEC.md`:** the protocol, v1, with the rule that an implementation never changes the app's response or errors, and the `problems` field.
|
|
25
|
+
- **First published release.** The package ships the compiled JavaScript and type declarations (`dist/`), with this changelog, the README, SPEC.md and the license.
|
|
26
|
+
- **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,137 @@
|
|
|
1
|
-
#
|
|
1
|
+
# caliper-timings
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
const messages = [{ role: "system", content: SYSTEM }, { role: "user", content: row.text }];
|
|
56
|
+
run?.prompt(messages); // each message's role and size in bytes, never its text
|
|
57
|
+
run?.mark("modelCallSent");
|
|
58
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
59
|
+
for await (const chunk of model.stream(/* … */)) res.write(chunk); // the first write with bytes: firstChunk
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// … your other routes and your 404
|
|
64
|
+
}).listen(8787);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **`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.
|
|
68
|
+
- **`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.
|
|
69
|
+
- **`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".
|
|
70
|
+
- **`run.prompt(messages)`** records each message's role and its size in bytes (UTF-8), in order, for Caliper's prompt sizes. Pass the same list you send to the model, as `{ role, content }` objects, just before the model call. With a separate system text (Anthropic's API), pass it as the first message. It never records the text. A role that isn't a lowercase name (`system`, `user`, `assistant`, …) is recorded as `"?"`, and content that isn't a string gets no size (`null`), with a problem saying which message, by number. It reads the list and never changes it. A later call replaces an earlier one. See [SPEC.md](SPEC.md#prompt-sizes).
|
|
71
|
+
- **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.
|
|
72
|
+
|
|
73
|
+
### It never changes your app
|
|
74
|
+
|
|
75
|
+
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:
|
|
76
|
+
- **`start()`** returns `null` if anything inside it fails, and the request runs unmarked.
|
|
77
|
+
- **`mark()` and `save()`** never throw or reject.
|
|
78
|
+
- **A store that throws or rejects** on save is logged to `console.error`, and the record is dropped.
|
|
79
|
+
- **A URL `handle()` can't parse** is left to your app.
|
|
80
|
+
- **A store failure while Caliper reads the timings endpoint** answers that request with 500.
|
|
81
|
+
|
|
82
|
+
### MemoryStore
|
|
83
|
+
|
|
84
|
+
`MemoryStore` keeps records in a `Map`, for 10 minutes after each write.
|
|
85
|
+
|
|
86
|
+
**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`).
|
|
87
|
+
|
|
88
|
+
**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.
|
|
89
|
+
|
|
90
|
+
## Core (`caliper-timings`)
|
|
91
|
+
|
|
92
|
+
The core works in any runtime with Web Streams and `crypto.subtle`. It exports:
|
|
93
|
+
|
|
94
|
+
- `CaliperRun`, with `mark`, `prompt`, `save`, and `watchFirstChunk(stream)` for frameworks that return a `ReadableStream`;
|
|
95
|
+
- `MemoryStore` and the `TimingStore` interface;
|
|
96
|
+
- `tokenMatches`, `isValidRunId`, `isValidMarkName`, `isValidRole`;
|
|
97
|
+
- the header and path constants;
|
|
98
|
+
- the `TimingRecord` and `PromptMessageSize` types.
|
|
99
|
+
|
|
100
|
+
There's no framework adapter other than Node yet.
|
|
101
|
+
|
|
102
|
+
## Measured cost
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
- **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.
|
|
107
|
+
- **`run.prompt()`: microseconds per call, measured on its own.** On Node 22.22 (Apple M2), 50 batches of 1000 calls each: a Quill-sized prompt (2 messages, 8184 bytes) took median 2.5 µs per call (min 2.4, max 4.6), and a 40-message chat (500 bytes each) median 10.0 µs (min 9.7, max 10.5). That's the call alone, not a request's time end to end, and it was measured on one machine, not on Workers. An unmarked request doesn't call it: with `run?.prompt(messages)`, `run` is `null`, so the list isn't even built (tested).
|
|
108
|
+
- **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).
|
|
109
|
+
|
|
110
|
+
That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
|
|
111
|
+
|
|
112
|
+
## Tested
|
|
113
|
+
|
|
114
|
+
`npm test` runs Node 22.18+ directly on the TypeScript source. It covers:
|
|
115
|
+
|
|
116
|
+
- **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.
|
|
117
|
+
- **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.
|
|
118
|
+
- **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.
|
|
119
|
+
- **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.
|
|
120
|
+
- **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.
|
|
121
|
+
- **Prompt sizes never carry text, never change the app:** a marker string put in a system prompt, a role, a list of parts and an extra key is nowhere in the record, its problems, the error log or the endpoint's answer. The messages see no write of any kind (a Proxy records every write; none), and a deeply frozen list reads fine. Lists that throw when read (a revoked Proxy, a throwing `length`, item, `role` or `content`, a `toString` that throws, a list containing itself, 100,000 messages) never throw out of `prompt()`, and the record still saves. Over HTTP, a marked request that calls `prompt()` with the marker list, a revoked Proxy or a throwing role gets the same status, headers and bytes as an unmarked one, with nothing escaping. An unmarked request reads the messages 0 times. Each was checked by breaking the code (storing the text, removing the try/catch, writing a tag on each message): the matching test then fails.
|
|
122
|
+
- **Prompt sizes (`run.prompt`):** role and UTF-8 bytes per message, in order, matching `TextEncoder` (lone surrogates included); a later call replaces an earlier one; no `prompt` field without a call. Roles that aren't lowercase names are recorded as `"?"`, content that isn't a string as `bytes: null`, and something that isn't a list as no `prompt`, each with a problem naming the message by number. At most 1000 messages.
|
|
123
|
+
- **`runtime`:** `"workers"` only for `navigator.userAgent === "Cloudflare-Workers"`, and absent on Node.
|
|
124
|
+
- **`watchFirstChunk`:** chunks unchanged, saved once.
|
|
125
|
+
- **A failed save** is logged and not thrown.
|
|
126
|
+
- **MemoryStore expiry and cleanup,** including 3 hours of one write a minute never holding more than 10 records.
|
|
127
|
+
|
|
128
|
+
## Known limits
|
|
129
|
+
|
|
130
|
+
- **Prompt sizes: a role can still carry a little text.** `run.prompt()` keeps a role that is up to 32 lowercase letters or `_` as it is, so an app that puts text there (`ignoreallpreviousinstructions`) has it recorded and shown. Anything with a space, a capital, a digit or punctuation is recorded as `"?"`. Message content is never recorded.
|
|
131
|
+
- **Saved once, at the first chunk.** Marks made after the first chunk of a streamed reply aren't in the record.
|
|
132
|
+
- **`res.end(body)` doesn't mark `firstChunk`.** A reply sent only that way gets no `firstChunk` mark; only `res.write` is watched.
|
|
133
|
+
- **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.
|
|
134
|
+
- **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.
|
|
135
|
+
- **No cross-origin test yet.** Cross-origin APIs need the CORS preflight to allow `x-caliper-run` and `x-caliper-token`.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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,122 @@
|
|
|
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
|
+
- **`run.prompt()`: microseconds per call, measured on its own.** On Node 22.22 (Apple M2), 50 batches of 1000 calls each: a Quill-sized prompt (2 messages, 8184 bytes) took median 2.5 µs per call (min 2.4, max 4.6), and a 40-message chat (500 bytes each) median 10.0 µs (min 9.7, max 10.5). That's the call alone, not a request's time end to end, and it was measured on one machine, not on Workers. An unmarked request doesn't call it: with `run?.prompt(messages)`, `run` is `null`, so the list isn't even built (tested).
|
|
21
|
+
- **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).
|
|
22
|
+
|
|
23
|
+
That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
|
|
24
|
+
|
|
25
|
+
## Secret
|
|
26
|
+
|
|
27
|
+
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).
|
|
28
|
+
|
|
29
|
+
## Marking a request
|
|
30
|
+
|
|
31
|
+
Caliper adds two headers to one request:
|
|
32
|
+
|
|
33
|
+
| Header | Value |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `x-caliper-run` | the run id: 1–64 characters of `A-Z a-z 0-9 _ -` (`^[A-Za-z0-9_-]{1,64}$`) |
|
|
36
|
+
| `x-caliper-token` | the secret |
|
|
37
|
+
|
|
38
|
+
The server records timings for that request only when all of these hold:
|
|
39
|
+
|
|
40
|
+
- a secret is configured;
|
|
41
|
+
- the run id is valid;
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
## Marks
|
|
47
|
+
|
|
48
|
+
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()`.
|
|
49
|
+
|
|
50
|
+
- **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`.
|
|
51
|
+
- **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`.
|
|
52
|
+
- **Reserved:** `modelCallSent` (just before the model call is sent) and `firstChunk` (the first response chunk with bytes leaves the server). Caliper's report shows `firstChunk` as "server: first bytes sent" and the span between them as "model call → first bytes sent". `firstChunk` is the server's first write to the browser: if the app writes anything before the model's reply (a heartbeat, an envelope), it marks that write. Only the labels changed; the field names are as above.
|
|
53
|
+
- **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.
|
|
54
|
+
- **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.
|
|
55
|
+
- 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.
|
|
56
|
+
|
|
57
|
+
## Record
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"id": "3f1c…",
|
|
62
|
+
"status": 200,
|
|
63
|
+
"marks": { "dbStart": 1, "dbEnd": 301, "modelCallSent": 301, "firstChunk": 402 },
|
|
64
|
+
"runtime": "workers"
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Field | |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `id` | the run id from `x-caliper-run` |
|
|
71
|
+
| `status` | the response status, or `null` when it isn't known (e.g. the client left before headers were sent) |
|
|
72
|
+
| `marks` | name → ms, as above; may be empty |
|
|
73
|
+
| `runtime` | optional, see below |
|
|
74
|
+
| `prompt` | optional, added in caliper-timings 0.2.0: the sizes of the prompt's messages, see [Prompt sizes](#prompt-sizes). Leave it out when the app didn't report a prompt. |
|
|
75
|
+
| `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. |
|
|
76
|
+
|
|
77
|
+
**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.
|
|
78
|
+
|
|
79
|
+
Records should expire. The reference keeps them for 10 minutes after the last write.
|
|
80
|
+
|
|
81
|
+
### Prompt sizes
|
|
82
|
+
|
|
83
|
+
`prompt` is `{ "messages": [ { "role": "system", "bytes": 2310 }, { "role": "user", "bytes": 5874 } ] }`: one entry per message the app passed to the model, in order, as the app reported them just before the model call.
|
|
84
|
+
|
|
85
|
+
- **`role`** is the message's role when it matches `^[a-z][a-z_]{0,31}$` (`system`, `user`, `assistant`, `tool`, …), and `"?"` otherwise, so text can never reach the record through it.
|
|
86
|
+
- **`bytes`** is the length of the message's text in UTF-8, or `null` when the text wasn't a string (a list of parts, an image). It's never estimated. It's the size of the text, not of the request to the model, and not tokens.
|
|
87
|
+
- **A role can still carry a little text.** A role is recorded when it matches the pattern above, so up to 32 lowercase letters or `_` that the app puts in a role (`ignoreallpreviousinstructions`) are recorded and shown as they are. Anything with a space, a capital, a digit or punctuation becomes `"?"`.
|
|
88
|
+
- **Never the text.** No part of a message's content is recorded, and `problems` about the prompt name a message by its number only (`prompt message 2: …`), never by its role or text.
|
|
89
|
+
- At most 1000 messages are recorded; `problems` then says how many weren't.
|
|
90
|
+
- When the app passes something that isn't a list, there's no `prompt`, and `problems` says so.
|
|
91
|
+
|
|
92
|
+
Older readers ignore the field, and a record without it is still valid: v1 is unchanged for servers that don't report a prompt.
|
|
93
|
+
|
|
94
|
+
### `runtime`
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
## Endpoint
|
|
99
|
+
|
|
100
|
+
`GET /__caliper/timings/:id` with `Authorization: Bearer <secret>`.
|
|
101
|
+
|
|
102
|
+
The checks run in this order, and the first one that fails decides the answer:
|
|
103
|
+
|
|
104
|
+
| # | Check | Answer |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| 1 | no secret configured | **404**, like any unknown route |
|
|
107
|
+
| 2 | bearer missing, not in the `Bearer <token>` form, or wrong | **401**, before the store is read |
|
|
108
|
+
| 3 | id malformed | **400** |
|
|
109
|
+
| 4 | no record for the id, or it expired | **404** |
|
|
110
|
+
| 5 | otherwise | **200**, the record as JSON |
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
A failure reading the store (step 4) answers **500**.
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
The endpoint must not redirect. Caliper doesn't follow redirects, so the token can't be carried anywhere else.
|
|
119
|
+
|
|
120
|
+
## Cross-origin
|
|
121
|
+
|
|
122
|
+
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.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, isValidRole, type TimingRecord, type TimingStore, type PromptMessageSize, } 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, isValidRole, } 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,25 @@
|
|
|
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
|
+
prompt?: {
|
|
11
|
+
messages: PromptMessageSize[];
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type PromptMessageSize = {
|
|
15
|
+
role: string;
|
|
16
|
+
bytes: number | null;
|
|
17
|
+
};
|
|
18
|
+
export interface TimingStore {
|
|
19
|
+
put(id: string, record: TimingRecord): Promise<void>;
|
|
20
|
+
get(id: string): Promise<TimingRecord | null>;
|
|
21
|
+
}
|
|
22
|
+
export declare const RECORD_TTL_MS: number;
|
|
23
|
+
export declare const isValidRunId: (id: string) => boolean;
|
|
24
|
+
export declare const isValidMarkName: (name: unknown) => name is string;
|
|
25
|
+
export declare const isValidRole: (role: unknown) => role is string;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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);
|
|
12
|
+
// Lowercase letters and _, up to 32: system, user, assistant, tool, function_call. Anything else
|
|
13
|
+
// could be text, so it's never recorded.
|
|
14
|
+
const ROLE = /^[a-z][a-z_]{0,31}$/;
|
|
15
|
+
export const isValidRole = (role) => typeof role === "string" && ROLE.test(role);
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
prompt(messages: unknown): void;
|
|
14
|
+
watchFirstChunk<T>(stream: ReadableStream<T>): ReadableStream<T>;
|
|
15
|
+
save(): Promise<void>;
|
|
16
|
+
get savesOnStream(): boolean;
|
|
17
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { isValidMarkName, isValidRole } 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
|
+
const MAX_PROMPT_MESSAGES = 1000;
|
|
13
|
+
const utf8 = new TextEncoder();
|
|
14
|
+
// Logged, never thrown: nothing the wrapper does may reach the app's request (SPEC.md).
|
|
15
|
+
function logFailure(err) {
|
|
16
|
+
try {
|
|
17
|
+
console.error("Caliper timing record not saved", err);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Not even the log: drop it.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// One marked request. On Workers, Date.now() is frozen while code runs and only
|
|
24
|
+
// advances at I/O, so a span with only computation inside reads 0 ms there.
|
|
25
|
+
// No method throws or rejects: a marked request succeeds or fails exactly as unmarked.
|
|
26
|
+
export class CaliperRun {
|
|
27
|
+
id;
|
|
28
|
+
status = null;
|
|
29
|
+
#start;
|
|
30
|
+
#marks = {};
|
|
31
|
+
#problems = [];
|
|
32
|
+
#prompt = null;
|
|
33
|
+
#store;
|
|
34
|
+
#defer;
|
|
35
|
+
#watchingStream = false;
|
|
36
|
+
constructor(id, store, { start = Date.now(), defer = () => { } } = {}) {
|
|
37
|
+
this.id = id;
|
|
38
|
+
this.#store = store;
|
|
39
|
+
this.#start = start;
|
|
40
|
+
this.#defer = defer;
|
|
41
|
+
}
|
|
42
|
+
// A name SPEC.md doesn't allow is skipped and reported in the record's problems, never thrown.
|
|
43
|
+
mark(name) {
|
|
44
|
+
if (!isValidMarkName(name)) {
|
|
45
|
+
this.#problem(`mark name ${JSON.stringify(name)} rejected — use a letter, then up to 63 letters, digits or _ (e.g. dbStart)`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.#marks[name] = Date.now() - this.#start;
|
|
49
|
+
}
|
|
50
|
+
// Each message's role and UTF-8 size, never its text; reads the messages and never changes them.
|
|
51
|
+
// Call it just before the model call. A later call replaces an earlier one. Never throws.
|
|
52
|
+
prompt(messages) {
|
|
53
|
+
try {
|
|
54
|
+
if (!Array.isArray(messages)) {
|
|
55
|
+
this.#problem("prompt not recorded: run.prompt() takes a list of { role, content } messages");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const sizes = [];
|
|
59
|
+
const kept = Math.min(messages.length, MAX_PROMPT_MESSAGES);
|
|
60
|
+
for (let i = 0; i < kept; i++)
|
|
61
|
+
sizes.push(this.#size(messages[i], i + 1));
|
|
62
|
+
if (messages.length > kept) {
|
|
63
|
+
this.#problem(`prompt: ${messages.length - kept} more messages not recorded (only the first ${MAX_PROMPT_MESSAGES} are kept)`);
|
|
64
|
+
}
|
|
65
|
+
this.#prompt = sizes;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.#problem("prompt not recorded: reading the messages failed");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Problems name the message by number only: its role or text could be anything.
|
|
72
|
+
#size(message, n) {
|
|
73
|
+
let role;
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
({ role, content } = (message ?? {}));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.#problem(`prompt message ${n}: reading it failed; recorded as "?" with no size`);
|
|
80
|
+
return { role: "?", bytes: null };
|
|
81
|
+
}
|
|
82
|
+
if (!isValidRole(role)) {
|
|
83
|
+
this.#problem(`prompt message ${n}: role isn't a lowercase name of up to 32 letters or _ (e.g. system, user, assistant); recorded as "?"`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof content !== "string")
|
|
86
|
+
this.#problem(`prompt message ${n}: content isn't a string; size not measured`);
|
|
87
|
+
return { role: isValidRole(role) ? role : "?", bytes: typeof content === "string" ? utf8.encode(content).length : null };
|
|
88
|
+
}
|
|
89
|
+
// Each problem once; after MAX_PROBLEMS, one line says the rest were dropped.
|
|
90
|
+
#problem(text) {
|
|
91
|
+
if (this.#problems.includes(text) || this.#problems.length > MAX_PROBLEMS)
|
|
92
|
+
return;
|
|
93
|
+
this.#problems.push(this.#problems.length < MAX_PROBLEMS ? text : `more problems not recorded (only the first ${MAX_PROBLEMS} are kept)`);
|
|
94
|
+
}
|
|
95
|
+
// Passes every chunk through unchanged (same bytes, same chunk boundaries);
|
|
96
|
+
// it only notes when the first one with bytes arrives, then saves the record.
|
|
97
|
+
watchFirstChunk(stream) {
|
|
98
|
+
this.#watchingStream = true;
|
|
99
|
+
let seen = false;
|
|
100
|
+
return stream.pipeThrough(new TransformStream({
|
|
101
|
+
transform: (chunk, controller) => {
|
|
102
|
+
if (!seen && hasBytes(chunk)) {
|
|
103
|
+
seen = true;
|
|
104
|
+
this.mark("firstChunk");
|
|
105
|
+
void this.save();
|
|
106
|
+
}
|
|
107
|
+
controller.enqueue(chunk);
|
|
108
|
+
},
|
|
109
|
+
flush: () => {
|
|
110
|
+
if (!seen)
|
|
111
|
+
void this.save();
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// Never throws and never rejects, whatever the store or defer does: a failure is logged and
|
|
116
|
+
// the record is dropped (it can't carry its own failure).
|
|
117
|
+
save() {
|
|
118
|
+
let saved;
|
|
119
|
+
try {
|
|
120
|
+
const record = {
|
|
121
|
+
id: this.id,
|
|
122
|
+
...(isWorkers() && { runtime: "workers" }),
|
|
123
|
+
status: this.status,
|
|
124
|
+
marks: { ...this.#marks },
|
|
125
|
+
...(this.#problems.length && { problems: [...this.#problems] }),
|
|
126
|
+
...(this.#prompt && { prompt: { messages: this.#prompt.map((m) => ({ ...m })) } }),
|
|
127
|
+
};
|
|
128
|
+
saved = Promise.resolve(this.#store.put(this.id, record)).catch(logFailure);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
logFailure(err);
|
|
132
|
+
saved = Promise.resolve();
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
this.#defer(saved);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
logFailure(err);
|
|
139
|
+
}
|
|
140
|
+
return saved;
|
|
141
|
+
}
|
|
142
|
+
get savesOnStream() {
|
|
143
|
+
return this.#watchingStream;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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
|
+
}
|