@columenlabs/lumen-ops 0.1.88 → 0.1.89
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 +15 -0
- package/dist/bindings.d.ts +22 -0
- package/dist/bindings.js +131 -0
- package/dist/hono.d.ts +7 -2
- package/dist/hono.js +19 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/requests.d.ts +22 -0
- package/dist/requests.js +47 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,21 @@ app.use('*', requestMeter({
|
|
|
126
126
|
|
|
127
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
128
|
|
|
129
|
+
## Bindings and outbound calls
|
|
130
|
+
|
|
131
|
+
Wrap the env once at the entry and patch `fetch` once, and every Durable Object RPC method, Workers AI, Vectorize, KV, R2, service or VPC call and every outbound `fetch` is timed and charged to the request in scope:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { meterFetch, meteredBindings } from '@columenlabs/lumen-ops';
|
|
135
|
+
import { requestMeter } from '@columenlabs/lumen-ops/hono';
|
|
136
|
+
|
|
137
|
+
meterFetch();
|
|
138
|
+
app.use('*', requestMeter({ app: 'myapp', serverTiming: true }));
|
|
139
|
+
export default { fetch: (req, env, ctx) => app.fetch(req, meteredBindings(env, { kinds: { AI: 'ai', INDEX: 'vectorize' }, skip: ['METRICS'] }), ctx) };
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A call is named by its binding and method (`DIRECTORY.seen`, `AI.run`, `fetch.api.example.com`), never its arguments. The request point's `upstream ms` and `subrequests` add them up; `serverTiming: true` sends the split back as `Server-Timing` (`do`, `ai`, `fetch`, ..., `app`, `total`), and when the request ends each call code is one `call` event (`code` the call, `ref` its kind, `ms` the total, counts: calls, failed). Outside a request (an alarm) each call is its own event. `app` may be a function and `route` names the route when one Worker hands requests on to other apps by path.
|
|
143
|
+
|
|
129
144
|
## Events
|
|
130
145
|
|
|
131
146
|
`@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.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface BindingMeterOptions {
|
|
2
|
+
/** The kind each binding counts as (`{ AI: 'ai', CAREER_INDEX: 'vectorize' }`); a Durable Object
|
|
3
|
+
* namespace is `do` and a binding with only `fetch` is `fetch` without saying. */
|
|
4
|
+
kinds?: Record<string, string>;
|
|
5
|
+
/** Bindings left as they are (an Analytics Engine dataset, a Secrets Store secret). */
|
|
6
|
+
skip?: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The env with every object binding metered. Strings (vars) and skipped bindings pass through as
|
|
10
|
+
* they are. Wrap once per request at the entry and hand the result to the app.
|
|
11
|
+
*/
|
|
12
|
+
export declare function meteredBindings<E extends object>(env: E, opts?: BindingMeterOptions): E;
|
|
13
|
+
/**
|
|
14
|
+
* Patches the global `fetch` once per isolate: every outbound request is a `fetch` call coded by
|
|
15
|
+
* its host (`fetch.api.example.com`), never its path or query.
|
|
16
|
+
*/
|
|
17
|
+
export declare function meterFetch(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Writes one `call` event per call code the request in scope made (counts: calls, failed; ms:
|
|
20
|
+
* their total). The request meter's Hono middleware calls this itself after the answer.
|
|
21
|
+
*/
|
|
22
|
+
export declare function flushCalls(): void;
|
package/dist/bindings.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binding meter: every call a Worker makes to its bindings (a Durable Object's RPC method,
|
|
3
|
+
* Workers AI, Vectorize, KV, R2, a service or VPC binding) and every outbound `fetch` is timed and
|
|
4
|
+
* charged to the request in scope, without touching a call site. Wrap the env once at the entry
|
|
5
|
+
* (`meteredBindings`), patch `fetch` once (`meterFetch`), and the request meter then has, per
|
|
6
|
+
* request, how many calls of each kind ran and how long they took (`Server-Timing`, and one `call`
|
|
7
|
+
* event per call code when the request ends: `flushCalls`).
|
|
8
|
+
*
|
|
9
|
+
* A call is named by its binding and method only (`DIRECTORY.seen`, `AI.run`, `fetch.api.x.com`),
|
|
10
|
+
* never its arguments, so no id, key or person's data is ever written.
|
|
11
|
+
*/
|
|
12
|
+
import { meterEvent } from './events.js';
|
|
13
|
+
import { addCall, requestCalls } from './requests.js';
|
|
14
|
+
const isThenable = (v) => !!v &&
|
|
15
|
+
(typeof v === 'object' || typeof v === 'function') &&
|
|
16
|
+
typeof v.then === 'function';
|
|
17
|
+
/** Times one call: charged to the request in scope, else written as its own event. */
|
|
18
|
+
function charge(kind, code, t0, ok) {
|
|
19
|
+
const ms = performance.now() - t0;
|
|
20
|
+
if (!addCall(kind, code, ms, ok))
|
|
21
|
+
meterEvent({ event: 'call', code, ok, ms, counts: [1, ok ? 0 : 1] });
|
|
22
|
+
}
|
|
23
|
+
function timed(kind, code, run) {
|
|
24
|
+
const t0 = performance.now();
|
|
25
|
+
let out;
|
|
26
|
+
try {
|
|
27
|
+
out = run();
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
charge(kind, code, t0, false);
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (!isThenable(out))
|
|
34
|
+
return out;
|
|
35
|
+
return Promise.resolve(out).then((v) => {
|
|
36
|
+
charge(kind, code, t0, true);
|
|
37
|
+
return v;
|
|
38
|
+
}, (err) => {
|
|
39
|
+
charge(kind, code, t0, false);
|
|
40
|
+
throw err;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Methods that hand back another handle rather than doing work: their result is wrapped too. */
|
|
44
|
+
const HANDLES = new Set(['get', 'getByName']);
|
|
45
|
+
/** Methods that only build ids: left alone, since the runtime checks what it gets back. */
|
|
46
|
+
const PLAIN = new Set(['idFromName', 'idFromString', 'newUniqueId']);
|
|
47
|
+
const isNamespace = (v) => typeof v.idFromName === 'function';
|
|
48
|
+
function meterObject(target, name, kind, stub) {
|
|
49
|
+
return new Proxy(target, {
|
|
50
|
+
get(t, prop, receiver) {
|
|
51
|
+
const v = Reflect.get(t, prop, receiver);
|
|
52
|
+
if (typeof prop !== 'string' || prop === 'then' || PLAIN.has(prop)) {
|
|
53
|
+
return typeof v === 'function' ? v.bind(t) : v;
|
|
54
|
+
}
|
|
55
|
+
// A stub's property is an RPC call whether or not it reads as a function.
|
|
56
|
+
if (typeof v !== 'function' && !stub)
|
|
57
|
+
return v;
|
|
58
|
+
const code = `${name}.${prop}`;
|
|
59
|
+
return (...args) => {
|
|
60
|
+
if (!stub && HANDLES.has(prop) && isNamespace(t)) {
|
|
61
|
+
const handle = t[prop]?.(...args);
|
|
62
|
+
return handle ? meterObject(handle, name, kind, true) : handle;
|
|
63
|
+
}
|
|
64
|
+
return timed(kind, code, () => t[prop]?.(...args));
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The env with every object binding metered. Strings (vars) and skipped bindings pass through as
|
|
71
|
+
* they are. Wrap once per request at the entry and hand the result to the app.
|
|
72
|
+
*/
|
|
73
|
+
export function meteredBindings(env, opts = {}) {
|
|
74
|
+
const skip = new Set(opts.skip ?? []);
|
|
75
|
+
const cache = new Map();
|
|
76
|
+
return new Proxy(env, {
|
|
77
|
+
get(t, prop, receiver) {
|
|
78
|
+
const v = Reflect.get(t, prop, receiver);
|
|
79
|
+
if (typeof prop !== 'string' || !v || typeof v !== 'object' || skip.has(prop))
|
|
80
|
+
return v;
|
|
81
|
+
const hit = cache.get(prop);
|
|
82
|
+
if (hit)
|
|
83
|
+
return hit;
|
|
84
|
+
const keys = Object.keys(v);
|
|
85
|
+
const kind = opts.kinds?.[prop] ??
|
|
86
|
+
(isNamespace(v)
|
|
87
|
+
? 'do'
|
|
88
|
+
: typeof v.fetch === 'function' && keys.length <= 1
|
|
89
|
+
? 'fetch'
|
|
90
|
+
: prop.toLowerCase());
|
|
91
|
+
const wrapped = meterObject(v, prop, kind, false);
|
|
92
|
+
cache.set(prop, wrapped);
|
|
93
|
+
return wrapped;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
let fetchPatched = false;
|
|
98
|
+
/**
|
|
99
|
+
* Patches the global `fetch` once per isolate: every outbound request is a `fetch` call coded by
|
|
100
|
+
* its host (`fetch.api.example.com`), never its path or query.
|
|
101
|
+
*/
|
|
102
|
+
export function meterFetch() {
|
|
103
|
+
if (fetchPatched)
|
|
104
|
+
return;
|
|
105
|
+
fetchPatched = true;
|
|
106
|
+
const native = globalThis.fetch;
|
|
107
|
+
globalThis.fetch = ((input, init) => {
|
|
108
|
+
let host = 'other';
|
|
109
|
+
try {
|
|
110
|
+
host = new URL(input instanceof Request ? input.url : String(input)).hostname;
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
return timed('fetch', `fetch.${host}`, () => native(input, init));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Writes one `call` event per call code the request in scope made (counts: calls, failed; ms:
|
|
118
|
+
* their total). The request meter's Hono middleware calls this itself after the answer.
|
|
119
|
+
*/
|
|
120
|
+
export function flushCalls() {
|
|
121
|
+
for (const [code, c] of requestCalls()) {
|
|
122
|
+
meterEvent({
|
|
123
|
+
event: 'call',
|
|
124
|
+
code,
|
|
125
|
+
ok: c.errors === 0,
|
|
126
|
+
ms: c.ms,
|
|
127
|
+
counts: [c.n, c.errors],
|
|
128
|
+
ref: c.kind,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/hono.d.ts
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
import type { Context, MiddlewareHandler } from 'hono';
|
|
8
8
|
import { type RequestPoint } from './requests.js';
|
|
9
9
|
export interface RequestMeterOptions {
|
|
10
|
-
/** The app, or the part of it, answering. */
|
|
11
|
-
app: string;
|
|
10
|
+
/** The app, or the part of it, answering; a function when one Worker serves several apps. */
|
|
11
|
+
app: string | ((c: Context) => string);
|
|
12
|
+
/** The route template, when Hono's own can't name it (a Worker that hands requests on to other
|
|
13
|
+
* apps by path sees only its catch-all); fold ids out of it with `foldRoute`. */
|
|
14
|
+
route?: (c: Context) => string | undefined;
|
|
15
|
+
/** Send the request's time per kind of call back as `Server-Timing` (do, ai, fetch, app, total). */
|
|
16
|
+
serverTiming?: boolean;
|
|
12
17
|
env?: (c: Context) => string | undefined;
|
|
13
18
|
version?: (c: Context) => string | undefined;
|
|
14
19
|
/** Which part was asked and who called, read after the answer (auth has run by then). */
|
package/dist/hono.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { routePath } from 'hono/route';
|
|
2
|
+
import { flushCalls } from './bindings.js';
|
|
2
3
|
import { codeOf } from './fields.js';
|
|
3
4
|
import { foldRoute } from './queries.js';
|
|
4
|
-
import { meterRequest, withRequest } from './requests.js';
|
|
5
|
+
import { meterRequest, serverTiming, withRequest } from './requests.js';
|
|
5
6
|
const newId = () => crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
6
7
|
const size = (h) => (h ? Number(h) || 0 : 0);
|
|
7
8
|
/** The `error` field of a JSON error answer under 4 KB, if it reads as a code. */
|
|
@@ -23,17 +24,31 @@ export function requestMeter(opts) {
|
|
|
23
24
|
return async (c, next) => {
|
|
24
25
|
const t0 = performance.now();
|
|
25
26
|
const requestId = codeOf(c.var.requestId) || newId();
|
|
26
|
-
const
|
|
27
|
+
const app = typeof opts.app === 'function' ? opts.app(c) : opts.app;
|
|
28
|
+
const scope = { app, requestId, env: opts.env?.(c), version: opts.version?.(c) };
|
|
27
29
|
await withRequest(scope, foldRoute(c.req.path), async () => {
|
|
28
30
|
await next();
|
|
29
31
|
try {
|
|
30
32
|
// The template of the deepest route that ran (Hono leaves its index set once the chain
|
|
31
33
|
// unwinds); `-1` would name the last route that merely matched, a catch-all say. A path
|
|
32
34
|
// only this middleware matched is `/*`, so junk never lands.
|
|
33
|
-
const
|
|
35
|
+
const hono = routePath(c);
|
|
36
|
+
const route = opts.route?.(c) || (hono && hono !== '*' && hono !== '/*' ? hono : '') || hono || '*';
|
|
34
37
|
const outcome = c.error
|
|
35
38
|
? 'exception'
|
|
36
39
|
: await (opts.outcome ? opts.outcome(c) : errorCode(c));
|
|
40
|
+
const ms = performance.now() - t0;
|
|
41
|
+
if (opts.serverTiming && c.res.status !== 101) {
|
|
42
|
+
try {
|
|
43
|
+
c.res.headers.set('server-timing', serverTiming(ms));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// A response with frozen headers (a passed-through fetch): copy it once.
|
|
47
|
+
c.res = new Response(c.res.body, c.res);
|
|
48
|
+
c.res.headers.set('server-timing', serverTiming(ms));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
flushCalls();
|
|
37
52
|
const cf = c.req.raw.cf;
|
|
38
53
|
meterRequest({
|
|
39
54
|
surface: 'other',
|
|
@@ -41,7 +56,7 @@ export function requestMeter(opts) {
|
|
|
41
56
|
method: c.req.method,
|
|
42
57
|
route,
|
|
43
58
|
status: c.res.status,
|
|
44
|
-
ms
|
|
59
|
+
ms,
|
|
45
60
|
outcome,
|
|
46
61
|
colo: cf?.colo,
|
|
47
62
|
country: cf?.country,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/requests.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface RequestPoint {
|
|
|
36
36
|
/** Defaults to the request in scope. */
|
|
37
37
|
scope?: RequestScope;
|
|
38
38
|
}
|
|
39
|
+
/** What the request spent, per kind (`do`, `ai`, `fetch`) and per call (`DIRECTORY.seen`). */
|
|
40
|
+
export interface CallTally {
|
|
41
|
+
n: number;
|
|
42
|
+
ms: number;
|
|
43
|
+
errors: number;
|
|
44
|
+
}
|
|
39
45
|
/** The entry point sets the dataset once per isolate; `log` also prints one JSON line a point. */
|
|
40
46
|
export declare function setRequestSink(s: PointSink | undefined, opts?: {
|
|
41
47
|
log?: boolean;
|
|
@@ -46,6 +52,22 @@ export declare function withRequest<T>(r: RequestScope, route: string, fn: () =>
|
|
|
46
52
|
export declare function currentRequest(): RequestScope | undefined;
|
|
47
53
|
/** Adds a call to another service to the request in scope (the event meter does this itself). */
|
|
48
54
|
export declare function addUpstream(ms: number): void;
|
|
55
|
+
/**
|
|
56
|
+
* Adds one binding or outbound call to the request in scope: its kind (`do`, `ai`, `vectorize`,
|
|
57
|
+
* `kv`, `fetch`) and its code (`DIRECTORY.seen`, `fetch.api.example.com`). The binding meter does
|
|
58
|
+
* this itself. Returns false when no request is in scope (an alarm, a job).
|
|
59
|
+
*/
|
|
60
|
+
export declare function addCall(kind: string, code: string, ms: number, ok: boolean): boolean;
|
|
61
|
+
/** The calls of the request in scope, by code; empty outside a request. */
|
|
62
|
+
export declare function requestCalls(): [string, CallTally & {
|
|
63
|
+
kind: string;
|
|
64
|
+
}][];
|
|
65
|
+
/**
|
|
66
|
+
* A `Server-Timing` header value for the request in scope: one entry per kind with its count, then
|
|
67
|
+
* `app` for the time outside them (`total` minus calls run one after another, so it can go to 0
|
|
68
|
+
* when calls ran side by side). Names and counts only, never a path or an id.
|
|
69
|
+
*/
|
|
70
|
+
export declare function serverTiming(totalMs: number): string;
|
|
49
71
|
/** Prints the point as one JSON line for the platform's logs, when the sink was set with `log`. */
|
|
50
72
|
export declare function logLine(kind: string, fields: Record<string, unknown>): void;
|
|
51
73
|
/**
|
package/dist/requests.js
CHANGED
|
@@ -22,7 +22,7 @@ export function setRequestSink(s, opts = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
/** Runs `fn` as request `r`: points written inside carry its id, and queries its route. */
|
|
24
24
|
export function withRequest(r, route, fn) {
|
|
25
|
-
return scope.run({ ...r, upstreamMs: 0, subrequests: 0 }, () => withQueryCaller({ app: r.app, kind: 'fetch', route }, fn));
|
|
25
|
+
return scope.run({ ...r, upstreamMs: 0, subrequests: 0, kinds: new Map(), calls: new Map() }, () => withQueryCaller({ app: r.app, kind: 'fetch', route }, fn));
|
|
26
26
|
}
|
|
27
27
|
/** The request in scope, if any. */
|
|
28
28
|
export function currentRequest() {
|
|
@@ -36,6 +36,52 @@ export function addUpstream(ms) {
|
|
|
36
36
|
s.upstreamMs += ms;
|
|
37
37
|
s.subrequests += 1;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Adds one binding or outbound call to the request in scope: its kind (`do`, `ai`, `vectorize`,
|
|
41
|
+
* `kv`, `fetch`) and its code (`DIRECTORY.seen`, `fetch.api.example.com`). The binding meter does
|
|
42
|
+
* this itself. Returns false when no request is in scope (an alarm, a job).
|
|
43
|
+
*/
|
|
44
|
+
export function addCall(kind, code, ms, ok) {
|
|
45
|
+
const s = scope.getStore();
|
|
46
|
+
if (!s)
|
|
47
|
+
return false;
|
|
48
|
+
s.upstreamMs += ms;
|
|
49
|
+
s.subrequests += 1;
|
|
50
|
+
const k = s.kinds.get(kind) ?? { n: 0, ms: 0, errors: 0 };
|
|
51
|
+
k.n += 1;
|
|
52
|
+
k.ms += ms;
|
|
53
|
+
if (!ok)
|
|
54
|
+
k.errors += 1;
|
|
55
|
+
s.kinds.set(kind, k);
|
|
56
|
+
const c = s.calls.get(code) ?? { kind, n: 0, ms: 0, errors: 0 };
|
|
57
|
+
c.n += 1;
|
|
58
|
+
c.ms += ms;
|
|
59
|
+
if (!ok)
|
|
60
|
+
c.errors += 1;
|
|
61
|
+
s.calls.set(code, c);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/** The calls of the request in scope, by code; empty outside a request. */
|
|
65
|
+
export function requestCalls() {
|
|
66
|
+
return [...(scope.getStore()?.calls ?? new Map())];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A `Server-Timing` header value for the request in scope: one entry per kind with its count, then
|
|
70
|
+
* `app` for the time outside them (`total` minus calls run one after another, so it can go to 0
|
|
71
|
+
* when calls ran side by side). Names and counts only, never a path or an id.
|
|
72
|
+
*/
|
|
73
|
+
export function serverTiming(totalMs) {
|
|
74
|
+
const s = scope.getStore();
|
|
75
|
+
const parts = [];
|
|
76
|
+
let spent = 0;
|
|
77
|
+
for (const [kind, t] of s?.kinds ?? []) {
|
|
78
|
+
spent += t.ms;
|
|
79
|
+
parts.push(`${kind};dur=${t.ms.toFixed(1)};desc="${t.n}${t.errors ? ` ${t.errors} failed` : ''}"`);
|
|
80
|
+
}
|
|
81
|
+
parts.push(`app;dur=${Math.max(0, totalMs - spent).toFixed(1)}`);
|
|
82
|
+
parts.push(`total;dur=${totalMs.toFixed(1)}`);
|
|
83
|
+
return parts.join(', ');
|
|
84
|
+
}
|
|
39
85
|
/** Prints the point as one JSON line for the platform's logs, when the sink was set with `log`. */
|
|
40
86
|
export function logLine(kind, fields) {
|
|
41
87
|
if (!logLines)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ops",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
4
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",
|