@columenlabs/lumen-ops 0.1.79 → 0.1.81
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/README.md +82 -7
- package/dist/client.d.ts +41 -0
- package/dist/client.js +70 -0
- package/dist/events.d.ts +53 -0
- package/dist/events.js +66 -0
- package/dist/fields.d.ts +4 -0
- package/dist/fields.js +8 -0
- package/dist/hono.d.ts +19 -0
- package/dist/hono.js +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/queries.d.ts +16 -5
- package/dist/queries.js +120 -37
- package/dist/requests.d.ts +56 -0
- package/dist/requests.js +102 -0
- package/package.json +23 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @columenlabs/lumen-ops
|
|
2
2
|
|
|
3
|
-
Lumen's ops parts for Cloudflare Workers: every
|
|
3
|
+
Lumen's ops parts for Cloudflare Workers: every request your app answers, every event and every data query becomes one Analytics Engine point, so you can find errors, mismatches and slow or heavy queries and fix them. The meter wraps your bindings once, so new code is metered without opting in. It writes only each query's shape, never a value. It also meters tool calls (`/tools`) and sends signed webhooks (`/webhooks`).
|
|
4
4
|
|
|
5
5
|
- Docs: https://lumen.columenlabs.com/docs/
|
|
6
6
|
- For agents: https://lumen.columenlabs.com/llms.txt
|
|
@@ -57,7 +57,7 @@ Another engine (R2 SQL, an HTTP database) calls `meterQuery({ engine, sql, metho
|
|
|
57
57
|
|
|
58
58
|
## What a point holds
|
|
59
59
|
|
|
60
|
-
One point per statement (a
|
|
60
|
+
One point per kind of statement per turn: the same statement run many times in one go (a loop of inserts) is one point whose numbers are totals, with `statements` saying how many ran. Divide by it for a mean. The column order is the contract for reading the dataset back:
|
|
61
61
|
|
|
62
62
|
| Column | Holds |
|
|
63
63
|
|---|---|
|
|
@@ -65,18 +65,20 @@ One point per statement (a D1 batch writes one per statement in it). The column
|
|
|
65
65
|
| `blob1` … `blob5` | Engine, app, kind (`fetch`, `cron`, `queue`, `alarm`, `do`), route (ids folded to `:id`), tool |
|
|
66
66
|
| `blob6` … `blob8` | Op (`select`, `insert`, `update`, `delete`, `ddl`, `pragma`, `other`), fingerprint, the query's shape (up to 1 KB) |
|
|
67
67
|
| `blob9`, `blob10` | `ok` or `error`, method (`all`, `first`, `run`, `raw`, `batch`, `query`, `insert`, `exec`) |
|
|
68
|
-
| `double1` … `
|
|
68
|
+
| `double1` … `double10` | Wall ms, the engine's own ms, rows read, bytes read, rows returned, rows written, response bytes (each a total), HTTP status, statements, slowest ms |
|
|
69
69
|
|
|
70
70
|
The shape has every string and number literal as `?`, `IN` lists folded to `(?...)`, comments and ClickHouse's `FORMAT` dropped, and an insert's rows cut off. The fingerprint is a hash of the shape, one per kind of query.
|
|
71
71
|
|
|
72
72
|
Read it back with the Analytics Engine SQL API, for example the slowest shapes of the last day:
|
|
73
73
|
|
|
74
74
|
```sql
|
|
75
|
-
SELECT blob7 AS fingerprint, any(blob8) AS shape,
|
|
76
|
-
|
|
75
|
+
SELECT blob7 AS fingerprint, any(blob8) AS shape,
|
|
76
|
+
sum(IF(double9 > 0, double9, 1) * _sample_interval) AS calls,
|
|
77
|
+
sum(double1 * _sample_interval) / sum(IF(double9 > 0, double9, 1) * _sample_interval) AS mean_ms,
|
|
78
|
+
max(double10) AS slowest_ms
|
|
77
79
|
FROM myapp_queries
|
|
78
80
|
WHERE timestamp > NOW() - INTERVAL '1' DAY
|
|
79
|
-
GROUP BY fingerprint ORDER BY
|
|
81
|
+
GROUP BY fingerprint ORDER BY mean_ms DESC LIMIT 20
|
|
80
82
|
```
|
|
81
83
|
|
|
82
84
|
## Exports
|
|
@@ -88,10 +90,83 @@ GROUP BY fingerprint ORDER BY p95_ms DESC LIMIT 20
|
|
|
88
90
|
| `withQueryCaller(caller, fn)` | Charges every query inside `fn` to `caller` |
|
|
89
91
|
| `meteredD1(db)` | D1, metered: `prepare` and `batch`, with D1's own rows read and written and duration |
|
|
90
92
|
| `meteredClickHouse(binding)` | A ClickHouse HTTP service binding, metered when the answer has been read: wall time to the last byte, ClickHouse's own statistics |
|
|
91
|
-
| `meteredSql(sql, caller)` | A Durable Object's `storage.sql`, metered |
|
|
93
|
+
| `meteredSql(sql, caller?)` | A Durable Object's `storage.sql`, metered; its cursor keeps `toArray`, `one`, `raw`, `next`, iteration and `columnNames` |
|
|
92
94
|
| `meterQuery(point)` | One point by hand, for any other engine |
|
|
93
95
|
| `queryShape(sql)` | The shape, fingerprint and op of a query |
|
|
94
96
|
| `foldRoute(path)` | A path with id-like segments as `:id` |
|
|
97
|
+
| `setQueryRowCap(n)` | The most rows one statement should return; one that returns more is marked `capped` in `blob11` |
|
|
98
|
+
|
|
99
|
+
## Requests
|
|
100
|
+
|
|
101
|
+
`@columenlabs/lumen-ops/hono` meters every request a Hono app answers: mount it once, first, and every route the app has or adds later writes one point with its route template, status, outcome code and time. It names the request in scope, so events and queries inside carry its id.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { requestId } from 'hono/request-id';
|
|
105
|
+
import { requestMeter } from '@columenlabs/lumen-ops/hono';
|
|
106
|
+
import { setRequestSink } from '@columenlabs/lumen-ops/requests';
|
|
107
|
+
|
|
108
|
+
app.use('*', async (c, next) => {
|
|
109
|
+
setRequestSink(c.env.REQUESTS, { log: true }); // also one JSON line per point in Workers Logs
|
|
110
|
+
await next();
|
|
111
|
+
});
|
|
112
|
+
app.use('*', requestId());
|
|
113
|
+
app.use('*', requestMeter({
|
|
114
|
+
app: 'myapp',
|
|
115
|
+
env: (c) => c.env.DEPLOY_ENV,
|
|
116
|
+
describe: (c) => ({ surface: 'api', subject: c.get('accountId'), callerKind: 'key' }),
|
|
117
|
+
}));
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Column | Holds |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `index1` | Subject: an opaque id the app makes for who called, or `anon` |
|
|
123
|
+
| `blob1` … `blob7` | App, surface, method, route template, outcome (`ok`, an `error` code from a JSON answer, `exception`, `http_<status>`), caller kind, caller id |
|
|
124
|
+
| `blob8` … `blob13` | Region, env, version, colo, country, request id |
|
|
125
|
+
| `double1` … `double6` | Status, ms, bytes in, bytes out, upstream ms, subrequests |
|
|
126
|
+
|
|
127
|
+
A path no route matched is written as `*`, so a scanner's junk never lands. Without Hono, wrap the handler in `withRequest(scope, route, fn)` and call `meterRequest(point)` yourself.
|
|
128
|
+
|
|
129
|
+
## Events
|
|
130
|
+
|
|
131
|
+
`@columenlabs/lumen-ops/events` writes what an app does that isn't a request it answers: a call to another service, a webhook in or out, a sync's start and end, a sign-in. With the request points they show whether what came in was acted on.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { meterEvent, setEventSink } from '@columenlabs/lumen-ops/events';
|
|
135
|
+
|
|
136
|
+
setEventSink(env.EVENTS);
|
|
137
|
+
meterEvent({ event: 'bank.call', code: '/accounts/get', ok: res.ok, ms, status: res.status, upstream: true });
|
|
138
|
+
meterEvent({ event: 'sync.done', ok: true, subject: accountId, ref: connectionId, counts: [added, modified, removed] });
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| Column | Holds |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `index1` | Subject, or `anon` |
|
|
144
|
+
| `blob1` … `blob10` | App, event, code, outcome, ref, trigger, request id, upstream id, env, version |
|
|
145
|
+
| `double1` … `double6` | ms, status, count1, count2, count3, attempt |
|
|
146
|
+
|
|
147
|
+
`upstream: true` adds the call's time to the request's `upstream ms`. In a Durable Object no request is in scope: give `setEventSink(binding, { app, env, version })` the defaults, and pass a `requestId` the caller sent if you have one.
|
|
148
|
+
|
|
149
|
+
## Browser telemetry
|
|
150
|
+
|
|
151
|
+
`useLumenTelemetry` in `@columenlabs/lumen-ui` sends screens opened, uncaught errors (class and a hash of the stack, never the message), failed requests and Web Vitals in batches to `/api/t`. The Worker checks and writes them:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { meterClientBatch, setClientSink } from '@columenlabs/lumen-ops/client';
|
|
155
|
+
|
|
156
|
+
setClientSink(env.CLIENT);
|
|
157
|
+
app.post('/api/t', async (c) => {
|
|
158
|
+
meterClientBatch(await c.req.json().catch(() => null), { app: 'console', subject: c.get('accountId') });
|
|
159
|
+
return c.body(null, 204);
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| Column | Holds |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `index1` | Subject, or `anon` |
|
|
166
|
+
| `blob1` … `blob9` | App, kind (`view`, `error`, `fetch_fail`, `vital`), route template, name, stack fingerprint, request id, version, device, env |
|
|
167
|
+
| `double1`, `double2` | Value (ms or score), status |
|
|
168
|
+
|
|
169
|
+
A batch that fails the schema is dropped whole; only codes, templates and numbers pass.
|
|
95
170
|
|
|
96
171
|
## Tool calls
|
|
97
172
|
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Worker's side of the browser's telemetry (`useLumenTelemetry` in `@columenlabs/lumen-ui`):
|
|
3
|
+
* checks a batch against a strict schema, so only enums, templates, codes and numbers pass, and
|
|
4
|
+
* writes one Analytics Engine point per entry. A batch that doesn't pass is dropped whole.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { PointSink } from './points.js';
|
|
8
|
+
export declare const clientBatch: z.ZodObject<{
|
|
9
|
+
app: z.ZodString;
|
|
10
|
+
version: z.ZodOptional<z.ZodString>;
|
|
11
|
+
points: z.ZodArray<z.ZodObject<{
|
|
12
|
+
kind: z.ZodEnum<{
|
|
13
|
+
error: "error";
|
|
14
|
+
fetch_fail: "fetch_fail";
|
|
15
|
+
view: "view";
|
|
16
|
+
vital: "vital";
|
|
17
|
+
}>;
|
|
18
|
+
route: z.ZodString;
|
|
19
|
+
name: z.ZodOptional<z.ZodString>;
|
|
20
|
+
fingerprint: z.ZodOptional<z.ZodString>;
|
|
21
|
+
requestId: z.ZodOptional<z.ZodString>;
|
|
22
|
+
value: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
status: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
device: z.ZodOptional<z.ZodEnum<{
|
|
25
|
+
desktop: "desktop";
|
|
26
|
+
phone: "phone";
|
|
27
|
+
}>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type ClientBatch = z.infer<typeof clientBatch>;
|
|
31
|
+
/** The entry point sets the dataset once per isolate. */
|
|
32
|
+
export declare function setClientSink(s: PointSink | undefined): void;
|
|
33
|
+
/**
|
|
34
|
+
* Writes a checked batch; says how many points it wrote (0 when the batch didn't pass). Column
|
|
35
|
+
* order is the contract: index1 subject (or `anon`); blobs app, kind, route, name, fingerprint,
|
|
36
|
+
* request id, version, device, env; doubles value, status.
|
|
37
|
+
*/
|
|
38
|
+
export declare function meterClientBatch(body: unknown, opts?: {
|
|
39
|
+
subject?: string;
|
|
40
|
+
app?: string;
|
|
41
|
+
}): number;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Worker's side of the browser's telemetry (`useLumenTelemetry` in `@columenlabs/lumen-ui`):
|
|
3
|
+
* checks a batch against a strict schema, so only enums, templates, codes and numbers pass, and
|
|
4
|
+
* writes one Analytics Engine point per entry. A batch that doesn't pass is dropped whole.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { cap, num } from './fields.js';
|
|
8
|
+
import { currentRequest } from './requests.js';
|
|
9
|
+
const code = z.string().regex(/^[A-Za-z0-9_.:/$-]{1,64}$/);
|
|
10
|
+
const route = z.string().regex(/^[A-Za-z0-9_.:/$-]{0,256}$/);
|
|
11
|
+
const point = z.object({
|
|
12
|
+
kind: z.enum(['view', 'error', 'fetch_fail', 'vital']),
|
|
13
|
+
route,
|
|
14
|
+
name: code.optional(),
|
|
15
|
+
fingerprint: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^[0-9a-f]{1,16}$/)
|
|
18
|
+
.optional(),
|
|
19
|
+
requestId: code.optional(),
|
|
20
|
+
value: z.number().finite().optional(),
|
|
21
|
+
status: z.number().int().min(0).max(999).optional(),
|
|
22
|
+
device: z.enum(['phone', 'desktop']).optional(),
|
|
23
|
+
});
|
|
24
|
+
export const clientBatch = z.object({
|
|
25
|
+
app: code,
|
|
26
|
+
version: code.optional(),
|
|
27
|
+
points: z.array(point).max(50),
|
|
28
|
+
});
|
|
29
|
+
let sink;
|
|
30
|
+
/** The entry point sets the dataset once per isolate. */
|
|
31
|
+
export function setClientSink(s) {
|
|
32
|
+
sink = s;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Writes a checked batch; says how many points it wrote (0 when the batch didn't pass). Column
|
|
36
|
+
* order is the contract: index1 subject (or `anon`); blobs app, kind, route, name, fingerprint,
|
|
37
|
+
* request id, version, device, env; doubles value, status.
|
|
38
|
+
*/
|
|
39
|
+
export function meterClientBatch(body, opts = {}) {
|
|
40
|
+
const parsed = clientBatch.safeParse(body);
|
|
41
|
+
if (!parsed.success || !sink)
|
|
42
|
+
return 0;
|
|
43
|
+
const b = parsed.data;
|
|
44
|
+
const env = currentRequest()?.env;
|
|
45
|
+
// The app named by the server wins: a page can't write as another app.
|
|
46
|
+
const app = opts.app ?? b.app;
|
|
47
|
+
for (const p of b.points) {
|
|
48
|
+
try {
|
|
49
|
+
sink.writeDataPoint({
|
|
50
|
+
indexes: [cap(opts.subject || 'anon')],
|
|
51
|
+
blobs: [
|
|
52
|
+
cap(app),
|
|
53
|
+
p.kind,
|
|
54
|
+
p.route,
|
|
55
|
+
p.name ?? '',
|
|
56
|
+
p.fingerprint ?? '',
|
|
57
|
+
p.requestId ?? '',
|
|
58
|
+
b.version ?? '',
|
|
59
|
+
p.device ?? '',
|
|
60
|
+
cap(env),
|
|
61
|
+
],
|
|
62
|
+
doubles: [num(p.value), num(p.status)],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
console.error('meterClientBatch', err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return b.points.length;
|
|
70
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event meter: the things an app does that are not a request it answers, one Analytics Engine
|
|
3
|
+
* point each. A call to another service (`bank.call`, `billing.call`), a webhook in or out, a
|
|
4
|
+
* sync's start and end, a sign-in. With the request meter they let anyone check that what came in
|
|
5
|
+
* was acted on: a webhook received and a sync finished, an event paid for and a plan changed.
|
|
6
|
+
*
|
|
7
|
+
* A point holds an event name, a code (an upstream's error code or endpoint), counts and ids the
|
|
8
|
+
* app makes. Never the payload, a token, or a person's name or email.
|
|
9
|
+
*/
|
|
10
|
+
import type { PointSink } from './points.js';
|
|
11
|
+
export interface EventPoint {
|
|
12
|
+
/** What happened, dotted: `bank.call`, `bank.webhook`, `sync.done`, `webhook.out`, `auth.sign_in`. */
|
|
13
|
+
event: string;
|
|
14
|
+
/** The detail as a code: an endpoint path, an upstream's error code, a webhook's type. */
|
|
15
|
+
code?: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
/** An opaque id the app makes for whom it happened to, or empty. */
|
|
18
|
+
subject?: string;
|
|
19
|
+
/** The thing it happened to, an id the app makes (a connection's, an endpoint's). */
|
|
20
|
+
ref?: string;
|
|
21
|
+
/** What started it: schedule, webhook, link, manual, or your own. */
|
|
22
|
+
trigger?: string;
|
|
23
|
+
/** The other service's own request id, for its support. */
|
|
24
|
+
upstreamId?: string;
|
|
25
|
+
ms?: number;
|
|
26
|
+
status?: number;
|
|
27
|
+
/** Up to three counts, named by the event (a sync: added, modified, removed). */
|
|
28
|
+
counts?: [number?, number?, number?];
|
|
29
|
+
attempt?: number;
|
|
30
|
+
/** `true` when the event was a call to another service: its time adds to the request's. */
|
|
31
|
+
upstream?: boolean;
|
|
32
|
+
/** Defaults to the request in scope; pass these from a Durable Object or a job. */
|
|
33
|
+
app?: string;
|
|
34
|
+
requestId?: string;
|
|
35
|
+
env?: string;
|
|
36
|
+
version?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface EventDefaults {
|
|
39
|
+
app?: string;
|
|
40
|
+
env?: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The entry point (and each Durable Object) sets the dataset once per isolate, with the app, env
|
|
45
|
+
* and version its points carry when no request is in scope (an object's alarm, a job).
|
|
46
|
+
*/
|
|
47
|
+
export declare function setEventSink(s: PointSink | undefined, d?: EventDefaults): void;
|
|
48
|
+
/**
|
|
49
|
+
* Column order is the contract with whoever reads the dataset back: index1 subject (or `anon`);
|
|
50
|
+
* blobs app, event, code, outcome, ref, trigger, request id, upstream id, env, version; doubles
|
|
51
|
+
* ms, status, count1, count2, count3, attempt.
|
|
52
|
+
*/
|
|
53
|
+
export declare function meterEvent(p: EventPoint): void;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { cap, codeOf, num } from './fields.js';
|
|
2
|
+
import { addUpstream, currentRequest, logLine } from './requests.js';
|
|
3
|
+
let sink;
|
|
4
|
+
let defaults = {};
|
|
5
|
+
/**
|
|
6
|
+
* The entry point (and each Durable Object) sets the dataset once per isolate, with the app, env
|
|
7
|
+
* and version its points carry when no request is in scope (an object's alarm, a job).
|
|
8
|
+
*/
|
|
9
|
+
export function setEventSink(s, d = {}) {
|
|
10
|
+
sink = s;
|
|
11
|
+
defaults = d;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Column order is the contract with whoever reads the dataset back: index1 subject (or `anon`);
|
|
15
|
+
* blobs app, event, code, outcome, ref, trigger, request id, upstream id, env, version; doubles
|
|
16
|
+
* ms, status, count1, count2, count3, attempt.
|
|
17
|
+
*/
|
|
18
|
+
export function meterEvent(p) {
|
|
19
|
+
try {
|
|
20
|
+
const r = currentRequest();
|
|
21
|
+
if (p.upstream && p.ms !== undefined)
|
|
22
|
+
addUpstream(p.ms);
|
|
23
|
+
const fields = {
|
|
24
|
+
app: p.app ?? r?.app ?? defaults.app,
|
|
25
|
+
requestId: p.requestId ?? r?.requestId,
|
|
26
|
+
env: p.env ?? r?.env ?? defaults.env,
|
|
27
|
+
version: p.version ?? r?.version ?? defaults.version,
|
|
28
|
+
};
|
|
29
|
+
const code = codeOf(p.code);
|
|
30
|
+
sink?.writeDataPoint({
|
|
31
|
+
indexes: [cap(p.subject || 'anon')],
|
|
32
|
+
blobs: [
|
|
33
|
+
cap(fields.app),
|
|
34
|
+
codeOf(p.event, 'other'),
|
|
35
|
+
code,
|
|
36
|
+
p.ok ? 'ok' : 'error',
|
|
37
|
+
cap(p.ref),
|
|
38
|
+
cap(p.trigger),
|
|
39
|
+
cap(fields.requestId),
|
|
40
|
+
cap(p.upstreamId),
|
|
41
|
+
cap(fields.env),
|
|
42
|
+
cap(fields.version),
|
|
43
|
+
],
|
|
44
|
+
doubles: [
|
|
45
|
+
num(p.ms),
|
|
46
|
+
num(p.status),
|
|
47
|
+
num(p.counts?.[0]),
|
|
48
|
+
num(p.counts?.[1]),
|
|
49
|
+
num(p.counts?.[2]),
|
|
50
|
+
num(p.attempt),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
logLine('event', {
|
|
54
|
+
id: fields.requestId,
|
|
55
|
+
event: p.event,
|
|
56
|
+
code,
|
|
57
|
+
ok: p.ok,
|
|
58
|
+
ref: p.ref,
|
|
59
|
+
ms: p.ms === undefined ? undefined : Math.round(p.ms),
|
|
60
|
+
status: p.status,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error('meterEvent', err);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const cap: (s: string | undefined, n?: number) => string;
|
|
2
|
+
export declare const num: (v: unknown) => number;
|
|
3
|
+
/** A code is a short lower-case word with `_`, `.` or `-`, so free text never passes as one. */
|
|
4
|
+
export declare const codeOf: (v: unknown, fallback?: string) => string;
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Field helpers the meters share; not exported from the package.
|
|
2
|
+
export const cap = (s, n = 96) => {
|
|
3
|
+
const v = s ?? '';
|
|
4
|
+
return v.length > n ? v.slice(0, n) : v;
|
|
5
|
+
};
|
|
6
|
+
export const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
7
|
+
/** A code is a short lower-case word with `_`, `.` or `-`, so free text never passes as one. */
|
|
8
|
+
export const codeOf = (v, fallback = '') => typeof v === 'string' && /^[A-Za-z0-9_.:/-]{1,64}$/.test(v) ? v : fallback;
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request meter as Hono middleware: mount it once, first, and every route the app has or adds
|
|
3
|
+
* later is metered. It names the request (`withRequest`), so events and queries inside carry its
|
|
4
|
+
* id, then writes one request point after the answer. Put Hono's own `requestId()` before it to
|
|
5
|
+
* send the id back as `X-Request-Id`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
8
|
+
import { type RequestPoint } from './requests.js';
|
|
9
|
+
export interface RequestMeterOptions {
|
|
10
|
+
/** The app, or the part of it, answering. */
|
|
11
|
+
app: string;
|
|
12
|
+
env?: (c: Context) => string | undefined;
|
|
13
|
+
version?: (c: Context) => string | undefined;
|
|
14
|
+
/** Which part was asked and who called, read after the answer (auth has run by then). */
|
|
15
|
+
describe?: (c: Context) => Partial<Pick<RequestPoint, 'surface' | 'subject' | 'callerKind' | 'callerId' | 'region'>>;
|
|
16
|
+
/** The outcome code of an answer; by default the `error` field of a small JSON error answer. */
|
|
17
|
+
outcome?: (c: Context) => Promise<string | undefined> | string | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare function requestMeter(opts: RequestMeterOptions): MiddlewareHandler;
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { routePath } from 'hono/route';
|
|
2
|
+
import { codeOf } from './fields.js';
|
|
3
|
+
import { foldRoute } from './queries.js';
|
|
4
|
+
import { meterRequest, withRequest } from './requests.js';
|
|
5
|
+
const newId = () => crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
6
|
+
const size = (h) => (h ? Number(h) || 0 : 0);
|
|
7
|
+
/** The `error` field of a JSON error answer under 4 KB, if it reads as a code. */
|
|
8
|
+
async function errorCode(c) {
|
|
9
|
+
const res = c.res;
|
|
10
|
+
if (res.status < 400 || !res.headers.get('content-type')?.includes('json'))
|
|
11
|
+
return;
|
|
12
|
+
if (size(res.headers.get('content-length')) > 4096)
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
const body = (await res.clone().json());
|
|
16
|
+
return codeOf(body?.error) || undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function requestMeter(opts) {
|
|
23
|
+
return async (c, next) => {
|
|
24
|
+
const t0 = performance.now();
|
|
25
|
+
const requestId = codeOf(c.var.requestId) || newId();
|
|
26
|
+
const scope = { app: opts.app, requestId, env: opts.env?.(c), version: opts.version?.(c) };
|
|
27
|
+
await withRequest(scope, foldRoute(c.req.path), async () => {
|
|
28
|
+
await next();
|
|
29
|
+
try {
|
|
30
|
+
// The template of the deepest route that ran (Hono leaves its index set once the chain
|
|
31
|
+
// unwinds); `-1` would name the last route that merely matched, a catch-all say. A path
|
|
32
|
+
// only this middleware matched is `/*`, so junk never lands.
|
|
33
|
+
const route = routePath(c) || '*';
|
|
34
|
+
const outcome = c.error
|
|
35
|
+
? 'exception'
|
|
36
|
+
: await (opts.outcome ? opts.outcome(c) : errorCode(c));
|
|
37
|
+
const cf = c.req.raw.cf;
|
|
38
|
+
meterRequest({
|
|
39
|
+
surface: 'other',
|
|
40
|
+
...opts.describe?.(c),
|
|
41
|
+
method: c.req.method,
|
|
42
|
+
route,
|
|
43
|
+
status: c.res.status,
|
|
44
|
+
ms: performance.now() - t0,
|
|
45
|
+
outcome,
|
|
46
|
+
colo: cf?.colo,
|
|
47
|
+
country: cf?.country,
|
|
48
|
+
bytesIn: size(c.req.header('content-length')),
|
|
49
|
+
bytesOut: size(c.res.headers.get('content-length')),
|
|
50
|
+
scope,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.error('requestMeter', err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/queries.d.ts
CHANGED
|
@@ -34,6 +34,11 @@ export interface QueryPoint {
|
|
|
34
34
|
}
|
|
35
35
|
/** The entry point sets the dataset once per isolate. */
|
|
36
36
|
export declare function setQuerySink(s: PointSink | undefined): void;
|
|
37
|
+
/**
|
|
38
|
+
* The most rows one statement should return (an app's page size cap); a statement that returns
|
|
39
|
+
* more is marked `capped`, so a query that dodged paging shows up. 0 turns the check off.
|
|
40
|
+
*/
|
|
41
|
+
export declare function setQueryRowCap(n: number | undefined): void;
|
|
37
42
|
/** How to name the request in scope when no job is (the app's request telemetry). */
|
|
38
43
|
export declare function setQueryCallerOf(f: (() => QueryCaller | undefined) | undefined): void;
|
|
39
44
|
/** Runs `fn` with `caller` as the caller of every query inside (cron runs, queue batches, alarms). */
|
|
@@ -52,8 +57,13 @@ export interface QueryShape {
|
|
|
52
57
|
export declare function queryShape(sql: string): QueryShape;
|
|
53
58
|
/**
|
|
54
59
|
* Column order is the contract with whoever reads the dataset back: index1 engine; blobs
|
|
55
|
-
* engine, app, kind, route, tool, op, fingerprint, sql shape, outcome, method
|
|
56
|
-
*
|
|
60
|
+
* engine, app, kind, route, tool, op, fingerprint, sql shape, outcome, method, cap (`capped` when
|
|
61
|
+
* it returned more rows than `setQueryRowCap` allows); doubles wall ms, engine ms, rows read,
|
|
62
|
+
* bytes read, rows out, rows written, bytes out, status, statements, slowest ms.
|
|
63
|
+
*
|
|
64
|
+
* The same statement run many times in one turn (a loop of inserts) is one point: doubles 1 to 7
|
|
65
|
+
* are its totals, `statements` how many ran and `slowest ms` the longest, so a sync that writes
|
|
66
|
+
* thousands of rows stays a handful of points. Divide a total by `statements` for the mean.
|
|
57
67
|
*/
|
|
58
68
|
export declare function meterQuery(p: QueryPoint): void;
|
|
59
69
|
/** The D1 binding, metered: one point per statement, a batch's each with its own meta. */
|
|
@@ -68,8 +78,9 @@ export declare function meteredClickHouse<T extends {
|
|
|
68
78
|
fetch: Fetch;
|
|
69
79
|
}>(binding: T | undefined): T | undefined;
|
|
70
80
|
/**
|
|
71
|
-
* A Durable Object's `storage.sql`, metered
|
|
72
|
-
*
|
|
81
|
+
* A Durable Object's `storage.sql`, metered. Each statement is read to the end at `exec`, so its
|
|
82
|
+
* rows read and written are known; the cursor it returns reads like the native one. Its queries
|
|
83
|
+
* are charged to the job in scope (`withQueryCaller`, an alarm say), else to `caller`.
|
|
73
84
|
*/
|
|
74
|
-
export declare function meteredSql<T>(sql: T, caller
|
|
85
|
+
export declare function meteredSql<T>(sql: T, caller?: QueryCaller): T;
|
|
75
86
|
export {};
|
package/dist/queries.js
CHANGED
|
@@ -14,12 +14,20 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
16
|
let sink;
|
|
17
|
+
let rowCap = 0;
|
|
17
18
|
let callerOf;
|
|
18
19
|
const scope = new AsyncLocalStorage();
|
|
19
20
|
/** The entry point sets the dataset once per isolate. */
|
|
20
21
|
export function setQuerySink(s) {
|
|
21
22
|
sink = s;
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The most rows one statement should return (an app's page size cap); a statement that returns
|
|
26
|
+
* more is marked `capped`, so a query that dodged paging shows up. 0 turns the check off.
|
|
27
|
+
*/
|
|
28
|
+
export function setQueryRowCap(n) {
|
|
29
|
+
rowCap = n && n > 0 ? n : 0;
|
|
30
|
+
}
|
|
23
31
|
/** How to name the request in scope when no job is (the app's request telemetry). */
|
|
24
32
|
export function setQueryCallerOf(f) {
|
|
25
33
|
callerOf = f;
|
|
@@ -107,10 +115,32 @@ export function queryShape(sql) {
|
|
|
107
115
|
}
|
|
108
116
|
const cap = (s, n = 96) => (s.length > n ? s.slice(0, n) : s);
|
|
109
117
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : Number(v) || 0);
|
|
118
|
+
const pending = new Map();
|
|
119
|
+
function flush() {
|
|
120
|
+
const batches = [...pending.values()];
|
|
121
|
+
pending.clear();
|
|
122
|
+
for (const b of batches) {
|
|
123
|
+
try {
|
|
124
|
+
sink?.writeDataPoint({
|
|
125
|
+
indexes: [b.index],
|
|
126
|
+
blobs: b.blobs,
|
|
127
|
+
doubles: [...b.sums, b.status, b.n, b.max],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
console.error('meterQuery', err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
110
135
|
/**
|
|
111
136
|
* Column order is the contract with whoever reads the dataset back: index1 engine; blobs
|
|
112
|
-
* engine, app, kind, route, tool, op, fingerprint, sql shape, outcome, method
|
|
113
|
-
*
|
|
137
|
+
* engine, app, kind, route, tool, op, fingerprint, sql shape, outcome, method, cap (`capped` when
|
|
138
|
+
* it returned more rows than `setQueryRowCap` allows); doubles wall ms, engine ms, rows read,
|
|
139
|
+
* bytes read, rows out, rows written, bytes out, status, statements, slowest ms.
|
|
140
|
+
*
|
|
141
|
+
* The same statement run many times in one turn (a loop of inserts) is one point: doubles 1 to 7
|
|
142
|
+
* are its totals, `statements` how many ran and `slowest ms` the longest, so a sync that writes
|
|
143
|
+
* thousands of rows stays a handful of points. Divide a total by `statements` for the mean.
|
|
114
144
|
*/
|
|
115
145
|
export function meterQuery(p) {
|
|
116
146
|
if (!sink)
|
|
@@ -118,30 +148,48 @@ export function meterQuery(p) {
|
|
|
118
148
|
try {
|
|
119
149
|
const shape = queryShape(p.sql);
|
|
120
150
|
const c = p.caller ?? currentCaller();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
151
|
+
const blobs = [
|
|
152
|
+
p.engine,
|
|
153
|
+
cap(c.app),
|
|
154
|
+
cap(c.kind),
|
|
155
|
+
cap(c.route),
|
|
156
|
+
cap(c.tool ?? ''),
|
|
157
|
+
shape.op,
|
|
158
|
+
shape.fingerprint,
|
|
159
|
+
shape.text,
|
|
160
|
+
p.ok ? 'ok' : 'error',
|
|
161
|
+
p.method,
|
|
162
|
+
rowCap && num(p.rowsOut) > rowCap ? 'capped' : '',
|
|
163
|
+
];
|
|
164
|
+
const values = [
|
|
165
|
+
num(p.ms),
|
|
166
|
+
num(p.engineMs),
|
|
167
|
+
num(p.rowsRead),
|
|
168
|
+
num(p.bytesRead),
|
|
169
|
+
num(p.rowsOut),
|
|
170
|
+
num(p.rowsWritten),
|
|
171
|
+
num(p.bytesOut),
|
|
172
|
+
];
|
|
173
|
+
const key = blobs.filter((_, i) => i !== 7).join('\u0000');
|
|
174
|
+
const b = pending.get(key);
|
|
175
|
+
if (b) {
|
|
176
|
+
values.forEach((v, i) => {
|
|
177
|
+
b.sums[i] = (b.sums[i] ?? 0) + v;
|
|
178
|
+
});
|
|
179
|
+
b.n += 1;
|
|
180
|
+
b.max = Math.max(b.max, num(p.ms));
|
|
181
|
+
b.status = num(p.status) || b.status;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (pending.size === 0)
|
|
185
|
+
queueMicrotask(flush);
|
|
186
|
+
pending.set(key, {
|
|
187
|
+
blobs,
|
|
188
|
+
index: p.engine,
|
|
189
|
+
sums: values,
|
|
190
|
+
status: num(p.status),
|
|
191
|
+
n: 1,
|
|
192
|
+
max: num(p.ms),
|
|
145
193
|
});
|
|
146
194
|
}
|
|
147
195
|
catch (err) {
|
|
@@ -363,11 +411,50 @@ export function meteredClickHouse(binding) {
|
|
|
363
411
|
});
|
|
364
412
|
}
|
|
365
413
|
/**
|
|
366
|
-
*
|
|
367
|
-
* `
|
|
414
|
+
* The rows of one statement, read to the end, behind the same cursor a Durable Object's
|
|
415
|
+
* `storage.sql` hands back: `toArray`, `one`, `raw`, `next`, iteration and `columnNames` all work,
|
|
416
|
+
* each reading on from where the last stopped.
|
|
417
|
+
*/
|
|
418
|
+
function readCursor(raws, columnNames, rowsRead, rowsWritten) {
|
|
419
|
+
let at = 0;
|
|
420
|
+
const row = (r) => Object.fromEntries(columnNames.map((c, i) => [c, r[i]]));
|
|
421
|
+
const rest = () => {
|
|
422
|
+
const out = raws.slice(at);
|
|
423
|
+
at = raws.length;
|
|
424
|
+
return out;
|
|
425
|
+
};
|
|
426
|
+
const cursor = {
|
|
427
|
+
columnNames,
|
|
428
|
+
rowsRead,
|
|
429
|
+
rowsWritten,
|
|
430
|
+
toArray: () => rest().map(row),
|
|
431
|
+
one: () => {
|
|
432
|
+
const left = rest();
|
|
433
|
+
if (left.length !== 1)
|
|
434
|
+
throw new Error(`Expected exactly one result from SQL query, but got ${left.length ? 'more than one' : 'no'} results.`);
|
|
435
|
+
return row(left[0]);
|
|
436
|
+
},
|
|
437
|
+
raw: function* () {
|
|
438
|
+
while (at < raws.length)
|
|
439
|
+
yield raws[at++];
|
|
440
|
+
},
|
|
441
|
+
next: () => at < raws.length
|
|
442
|
+
? { done: false, value: row(raws[at++]) }
|
|
443
|
+
: { done: true, value: undefined },
|
|
444
|
+
[Symbol.iterator]() {
|
|
445
|
+
return cursor;
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
return cursor;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A Durable Object's `storage.sql`, metered. Each statement is read to the end at `exec`, so its
|
|
452
|
+
* rows read and written are known; the cursor it returns reads like the native one. Its queries
|
|
453
|
+
* are charged to the job in scope (`withQueryCaller`, an alarm say), else to `caller`.
|
|
368
454
|
*/
|
|
369
455
|
export function meteredSql(sql, caller) {
|
|
370
456
|
const native = sql;
|
|
457
|
+
const callerNow = () => scope.getStore() ?? caller ?? currentCaller();
|
|
371
458
|
return new Proxy(native, {
|
|
372
459
|
get(target, prop, receiver) {
|
|
373
460
|
if (prop === 'exec')
|
|
@@ -375,7 +462,7 @@ export function meteredSql(sql, caller) {
|
|
|
375
462
|
const t0 = performance.now();
|
|
376
463
|
try {
|
|
377
464
|
const cursor = target.exec(query, ...bindings);
|
|
378
|
-
const
|
|
465
|
+
const raws = Array.from(cursor.raw());
|
|
379
466
|
meterQuery({
|
|
380
467
|
engine: 'do_sqlite',
|
|
381
468
|
sql: query,
|
|
@@ -384,14 +471,10 @@ export function meteredSql(sql, caller) {
|
|
|
384
471
|
ms: performance.now() - t0,
|
|
385
472
|
rowsRead: cursor.rowsRead,
|
|
386
473
|
rowsWritten: cursor.rowsWritten,
|
|
387
|
-
rowsOut:
|
|
388
|
-
caller,
|
|
474
|
+
rowsOut: raws.length,
|
|
475
|
+
caller: callerNow(),
|
|
389
476
|
});
|
|
390
|
-
return
|
|
391
|
-
toArray: () => rows,
|
|
392
|
-
rowsRead: cursor.rowsRead,
|
|
393
|
-
rowsWritten: cursor.rowsWritten,
|
|
394
|
-
};
|
|
477
|
+
return readCursor(raws, cursor.columnNames, cursor.rowsRead, cursor.rowsWritten);
|
|
395
478
|
}
|
|
396
479
|
catch (err) {
|
|
397
480
|
meterQuery({
|
|
@@ -400,7 +483,7 @@ export function meteredSql(sql, caller) {
|
|
|
400
483
|
method: 'exec',
|
|
401
484
|
ok: false,
|
|
402
485
|
ms: performance.now() - t0,
|
|
403
|
-
caller,
|
|
486
|
+
caller: callerNow(),
|
|
404
487
|
});
|
|
405
488
|
throw err;
|
|
406
489
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PointSink } from './points.js';
|
|
2
|
+
export interface RequestScope {
|
|
3
|
+
/** The app, or the part of it, answering. */
|
|
4
|
+
app: string;
|
|
5
|
+
/** A short id the app makes per request, sent back as `X-Request-Id`. */
|
|
6
|
+
requestId: string;
|
|
7
|
+
/** production, preview, or your own. */
|
|
8
|
+
env?: string;
|
|
9
|
+
/** The deploy's version id. */
|
|
10
|
+
version?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RequestPoint {
|
|
13
|
+
/** Which part of the app was asked: console, admin, api, mcp, webhook, site, or your own. */
|
|
14
|
+
surface: string;
|
|
15
|
+
method: string;
|
|
16
|
+
/** The route template (`/v1/accounts/:id`), never the raw path. */
|
|
17
|
+
route: string;
|
|
18
|
+
status: number;
|
|
19
|
+
ms: number;
|
|
20
|
+
/** `ok`, or a short code (`rate_limited`, `invalid_signature`, `exception`), never a message. */
|
|
21
|
+
outcome?: string;
|
|
22
|
+
/** Who called: an opaque id the app makes (a person's or account's id), or empty. */
|
|
23
|
+
subject?: string;
|
|
24
|
+
/** How they called: session, key, oauth, admin, webhook, anon, or your own. */
|
|
25
|
+
callerKind?: string;
|
|
26
|
+
/** The key's or OAuth client's id, never the key itself. */
|
|
27
|
+
callerId?: string;
|
|
28
|
+
region?: string;
|
|
29
|
+
colo?: string;
|
|
30
|
+
country?: string;
|
|
31
|
+
bytesIn?: number;
|
|
32
|
+
bytesOut?: number;
|
|
33
|
+
/** Time spent waiting on other services in this request, ms. */
|
|
34
|
+
upstreamMs?: number;
|
|
35
|
+
subrequests?: number;
|
|
36
|
+
/** Defaults to the request in scope. */
|
|
37
|
+
scope?: RequestScope;
|
|
38
|
+
}
|
|
39
|
+
/** The entry point sets the dataset once per isolate; `log` also prints one JSON line a point. */
|
|
40
|
+
export declare function setRequestSink(s: PointSink | undefined, opts?: {
|
|
41
|
+
log?: boolean;
|
|
42
|
+
}): void;
|
|
43
|
+
/** Runs `fn` as request `r`: points written inside carry its id, and queries its route. */
|
|
44
|
+
export declare function withRequest<T>(r: RequestScope, route: string, fn: () => T): T;
|
|
45
|
+
/** The request in scope, if any. */
|
|
46
|
+
export declare function currentRequest(): RequestScope | undefined;
|
|
47
|
+
/** Adds a call to another service to the request in scope (the event meter does this itself). */
|
|
48
|
+
export declare function addUpstream(ms: number): void;
|
|
49
|
+
/** Prints the point as one JSON line for the platform's logs, when the sink was set with `log`. */
|
|
50
|
+
export declare function logLine(kind: string, fields: Record<string, unknown>): void;
|
|
51
|
+
/**
|
|
52
|
+
* Column order is the contract with whoever reads the dataset back: index1 subject (or `anon`);
|
|
53
|
+
* blobs app, surface, method, route, outcome, caller kind, caller id, region, env, version, colo,
|
|
54
|
+
* country, request id; doubles status, ms, bytes in, bytes out, upstream ms, subrequests.
|
|
55
|
+
*/
|
|
56
|
+
export declare function meterRequest(p: RequestPoint): void;
|
package/dist/requests.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request meter: every request a Worker answers is one Analytics Engine point, with its route
|
|
3
|
+
* template, status, outcome code, who called and how long it took, so errors, slow routes and a bad
|
|
4
|
+
* deploy show up without any code in the routes themselves.
|
|
5
|
+
*
|
|
6
|
+
* It also holds the request in scope (`currentRequest`), so the event meter and the query meter
|
|
7
|
+
* charge their points to it and carry the same request id.
|
|
8
|
+
*
|
|
9
|
+
* A point holds enums, templates, ids the app makes and numbers only: never a raw path, a query
|
|
10
|
+
* string, a header, a body or a person's name, email or address.
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
import { cap, codeOf, num } from './fields.js';
|
|
14
|
+
import { withQueryCaller } from './queries.js';
|
|
15
|
+
let sink;
|
|
16
|
+
let logLines = false;
|
|
17
|
+
const scope = new AsyncLocalStorage();
|
|
18
|
+
/** The entry point sets the dataset once per isolate; `log` also prints one JSON line a point. */
|
|
19
|
+
export function setRequestSink(s, opts = {}) {
|
|
20
|
+
sink = s;
|
|
21
|
+
logLines = Boolean(opts.log);
|
|
22
|
+
}
|
|
23
|
+
/** Runs `fn` as request `r`: points written inside carry its id, and queries its route. */
|
|
24
|
+
export function withRequest(r, route, fn) {
|
|
25
|
+
return scope.run({ ...r, upstreamMs: 0, subrequests: 0 }, () => withQueryCaller({ app: r.app, kind: 'fetch', route }, fn));
|
|
26
|
+
}
|
|
27
|
+
/** The request in scope, if any. */
|
|
28
|
+
export function currentRequest() {
|
|
29
|
+
return scope.getStore();
|
|
30
|
+
}
|
|
31
|
+
/** Adds a call to another service to the request in scope (the event meter does this itself). */
|
|
32
|
+
export function addUpstream(ms) {
|
|
33
|
+
const s = scope.getStore();
|
|
34
|
+
if (!s)
|
|
35
|
+
return;
|
|
36
|
+
s.upstreamMs += ms;
|
|
37
|
+
s.subrequests += 1;
|
|
38
|
+
}
|
|
39
|
+
/** Prints the point as one JSON line for the platform's logs, when the sink was set with `log`. */
|
|
40
|
+
export function logLine(kind, fields) {
|
|
41
|
+
if (!logLines)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
console.log(JSON.stringify({ lumen: kind, ...fields }));
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Column order is the contract with whoever reads the dataset back: index1 subject (or `anon`);
|
|
50
|
+
* blobs app, surface, method, route, outcome, caller kind, caller id, region, env, version, colo,
|
|
51
|
+
* country, request id; doubles status, ms, bytes in, bytes out, upstream ms, subrequests.
|
|
52
|
+
*/
|
|
53
|
+
export function meterRequest(p) {
|
|
54
|
+
if (!sink && !logLines)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
const s = p.scope ?? scope.getStore();
|
|
58
|
+
const live = scope.getStore();
|
|
59
|
+
const upstreamMs = p.upstreamMs ?? live?.upstreamMs;
|
|
60
|
+
const subrequests = p.subrequests ?? live?.subrequests;
|
|
61
|
+
const outcome = codeOf(p.outcome, p.status < 400 ? 'ok' : `http_${p.status}`);
|
|
62
|
+
sink?.writeDataPoint({
|
|
63
|
+
indexes: [cap(p.subject || 'anon')],
|
|
64
|
+
blobs: [
|
|
65
|
+
cap(s?.app),
|
|
66
|
+
cap(p.surface),
|
|
67
|
+
cap(p.method, 8),
|
|
68
|
+
cap(p.route, 256),
|
|
69
|
+
outcome,
|
|
70
|
+
cap(p.callerKind),
|
|
71
|
+
cap(p.callerId),
|
|
72
|
+
cap(p.region),
|
|
73
|
+
cap(s?.env),
|
|
74
|
+
cap(s?.version),
|
|
75
|
+
cap(p.colo, 8),
|
|
76
|
+
cap(p.country, 4),
|
|
77
|
+
cap(s?.requestId),
|
|
78
|
+
],
|
|
79
|
+
doubles: [
|
|
80
|
+
num(p.status),
|
|
81
|
+
num(p.ms),
|
|
82
|
+
num(p.bytesIn),
|
|
83
|
+
num(p.bytesOut),
|
|
84
|
+
num(upstreamMs),
|
|
85
|
+
num(subrequests),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
logLine('request', {
|
|
89
|
+
id: s?.requestId,
|
|
90
|
+
surface: p.surface,
|
|
91
|
+
method: p.method,
|
|
92
|
+
route: p.route,
|
|
93
|
+
status: p.status,
|
|
94
|
+
outcome,
|
|
95
|
+
ms: Math.round(p.ms),
|
|
96
|
+
caller: p.callerKind,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error('meterRequest', err);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ops",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Lumen's ops parts: every data query and tool call an app runs as one Analytics Engine point (shapes and hashes only), and signed webhooks out.",
|
|
3
|
+
"version": "0.1.81",
|
|
4
|
+
"description": "Lumen's ops parts: every request, event, data query and tool call an app runs as one Analytics Engine point (templates, codes, shapes and hashes only), and signed webhooks out.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./dist/client.d.ts",
|
|
16
|
+
"default": "./dist/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./events": {
|
|
19
|
+
"types": "./dist/events.d.ts",
|
|
20
|
+
"default": "./dist/events.js"
|
|
21
|
+
},
|
|
22
|
+
"./hono": {
|
|
23
|
+
"types": "./dist/hono.d.ts",
|
|
24
|
+
"default": "./dist/hono.js"
|
|
25
|
+
},
|
|
14
26
|
"./points": {
|
|
15
27
|
"types": "./dist/points.d.ts",
|
|
16
28
|
"default": "./dist/points.js"
|
|
@@ -19,6 +31,10 @@
|
|
|
19
31
|
"types": "./dist/queries.d.ts",
|
|
20
32
|
"default": "./dist/queries.js"
|
|
21
33
|
},
|
|
34
|
+
"./requests": {
|
|
35
|
+
"types": "./dist/requests.d.ts",
|
|
36
|
+
"default": "./dist/requests.js"
|
|
37
|
+
},
|
|
22
38
|
"./tools": {
|
|
23
39
|
"types": "./dist/tools.d.ts",
|
|
24
40
|
"default": "./dist/tools.js"
|
|
@@ -48,6 +64,10 @@
|
|
|
48
64
|
"registry": "https://registry.npmjs.org/"
|
|
49
65
|
},
|
|
50
66
|
"dependencies": {
|
|
51
|
-
"standardwebhooks": "1.1.1"
|
|
67
|
+
"standardwebhooks": "1.1.1",
|
|
68
|
+
"zod": "^4.3.6"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"hono": ">=4.8.0"
|
|
52
72
|
}
|
|
53
73
|
}
|