@columenlabs/lumen-ops 0.1.80 → 0.1.82

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @columenlabs/lumen-ops
2
2
 
3
- Lumen's ops parts for Cloudflare Workers: every data query your app runs becomes one Analytics Engine point, so you can find slow and 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`).
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 D1 batch writes one per statement in it). The column order is the contract for reading the dataset back:
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` … `double8` | Wall ms, the engine's own ms, rows read, bytes read, rows returned, rows written, response bytes, HTTP status |
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, sum(_sample_interval) AS calls,
76
- quantileWeighted(0.95)(double1, _sample_interval) AS p95_ms
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 p95_ms DESC LIMIT 20
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
 
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -1,4 +1,6 @@
1
+ export * from './events.js';
1
2
  export * from './points.js';
2
3
  export * from './queries.js';
4
+ export * from './requests.js';
3
5
  export * from './tools.js';
4
6
  export * from './webhooks.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
+ export * from './events.js';
1
2
  export * from './points.js';
2
3
  export * from './queries.js';
4
+ export * from './requests.js';
3
5
  export * from './tools.js';
4
6
  export * from './webhooks.js';
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; doubles wall ms,
56
- * engine ms, rows read, bytes read, rows out, rows written, bytes out, status.
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 for `caller`. Each statement is read to the end at
72
- * `exec`, so its rows read and written are known; callers read rows only through `toArray`.
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: QueryCaller): T;
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; doubles wall ms,
113
- * engine ms, rows read, bytes read, rows out, rows written, bytes out, status.
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
- sink.writeDataPoint({
122
- indexes: [p.engine],
123
- blobs: [
124
- p.engine,
125
- cap(c.app),
126
- cap(c.kind),
127
- cap(c.route),
128
- cap(c.tool ?? ''),
129
- shape.op,
130
- shape.fingerprint,
131
- shape.text,
132
- p.ok ? 'ok' : 'error',
133
- p.method,
134
- ],
135
- doubles: [
136
- p.ms,
137
- num(p.engineMs),
138
- num(p.rowsRead),
139
- num(p.bytesRead),
140
- num(p.rowsOut),
141
- num(p.rowsWritten),
142
- num(p.bytesOut),
143
- num(p.status),
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
- * A Durable Object's `storage.sql`, metered for `caller`. Each statement is read to the end at
367
- * `exec`, so its rows read and written are known; callers read rows only through `toArray`.
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 rows = cursor.toArray();
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: rows.length,
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;
@@ -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.80",
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.82",
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
  }