@columenlabs/lumen-ops 0.1.35 → 0.1.37
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 +58 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tools.d.ts +49 -0
- package/dist/tools.js +67 -0
- package/dist/webhooks.d.ts +34 -0
- package/dist/webhooks.js +49 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @columenlabs/lumen-ops
|
|
2
2
|
|
|
3
|
-
Lumen's ops
|
|
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`).
|
|
4
4
|
|
|
5
5
|
- Docs: https://lumen.columenlabs.com/docs/
|
|
6
6
|
- For agents: https://lumen.columenlabs.com/llms.txt
|
|
@@ -93,6 +93,63 @@ GROUP BY fingerprint ORDER BY p95_ms DESC LIMIT 20
|
|
|
93
93
|
| `queryShape(sql)` | The shape, fingerprint and op of a query |
|
|
94
94
|
| `foldRoute(path)` | A path with id-like segments as `:id` |
|
|
95
95
|
|
|
96
|
+
## Tool calls
|
|
97
|
+
|
|
98
|
+
`@columenlabs/lumen-ops/tools` meters each call an assistant or agent makes: one point per call with its latency, outcome and a hash of the code it ran, so slow or failing tools show up and the same script is grouped across runs.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { meteredCall, meterToolCall, setToolSink } from '@columenlabs/lumen-ops/tools';
|
|
102
|
+
|
|
103
|
+
setToolSink(env.TOOL_CALLS); // an Analytics Engine dataset binding
|
|
104
|
+
|
|
105
|
+
// Wrap a call: latency and outcome are taken for you.
|
|
106
|
+
const rows = await meteredCall({ app: 'trade', tool: 'portfolio_list', kind: 'call' }, () => run(args));
|
|
107
|
+
|
|
108
|
+
// Or write one by hand, with the code a script ran.
|
|
109
|
+
meterToolCall({ app: 'trade', tool: 'script', kind: 'script', ok: true, ms: 840, code: source, rows: 120 });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| Column | Holds |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `index1` | app |
|
|
115
|
+
| `blob1`…`blob9` | app, tool, kind, outcome (`ok` or `error`), error kind, code hash, source, tier, run id |
|
|
116
|
+
| `double1`…`double6` | ms, rows, bytes in, bytes out, tokens in, tokens out |
|
|
117
|
+
|
|
118
|
+
Arguments and results are never written; only their sizes.
|
|
119
|
+
|
|
120
|
+
| Export | What it does |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `setToolSink(binding)` | The dataset tool points go to; nothing is written until it is set |
|
|
123
|
+
| `meterToolCall(point)` | One point by hand |
|
|
124
|
+
| `meteredCall(point, fn, errorKind?)` | Runs `fn`, timing it and writing its outcome |
|
|
125
|
+
| `codeHash(code)` | A short hash of a script, whitespace folded |
|
|
126
|
+
|
|
127
|
+
## Webhooks out
|
|
128
|
+
|
|
129
|
+
`@columenlabs/lumen-ops/webhooks` sends one delivery, signed per the [Standard Webhooks](https://www.standardwebhooks.com) spec when a secret is given, so the receiver can verify it with any Standard Webhooks library. It never throws: the result says how it went, for the app's own delivery log and retries.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { newWebhookSecret, sendWebhook } from '@columenlabs/lumen-ops/webhooks';
|
|
133
|
+
|
|
134
|
+
const secret = newWebhookSecret(); // whsec_…, shown to its owner once, kept sealed
|
|
135
|
+
const r = await sendWebhook({
|
|
136
|
+
url: endpoint.url,
|
|
137
|
+
secret,
|
|
138
|
+
id: event.id, // the same id on a retry
|
|
139
|
+
body: JSON.stringify(event.payload),
|
|
140
|
+
headers: { authorization: `Bearer ${endpoint.token}` },
|
|
141
|
+
userAgent: 'MyApp-Webhooks/1',
|
|
142
|
+
});
|
|
143
|
+
if (!r.ok) scheduleRetry(event, r.status, r.error);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Export | What it does |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `sendWebhook(send)` | One POST: `{ ok, status, ms, id, error? }`; status 0 when the endpoint never answered |
|
|
149
|
+
| `newWebhookSecret()` | A new `whsec_` secret, 32 random bytes |
|
|
150
|
+
|
|
151
|
+
The endpoint's own headers go first, so they can never replace the signature headers. Redirects count as failures unless `redirect` says otherwise; the timeout is 10 s by default.
|
|
152
|
+
|
|
96
153
|
## Versions
|
|
97
154
|
|
|
98
155
|
0.x: any minor may break things until 1.0. Every merge to Lumen's `main` publishes the next patch of every Lumen package; see the [releases](https://github.com/columenlabs/lumen/releases).
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool-call meter: every tool an assistant or an agent calls (an MCP tool, a function, a script
|
|
3
|
+
* in a sandbox, a web read) is one Analytics Engine point, with how long it took, how it ended and
|
|
4
|
+
* a hash of the code it ran, so slow, failing and repeated calls can be found and made first class.
|
|
5
|
+
*
|
|
6
|
+
* A point holds names, numbers and hashes only: never the arguments, the result or the person.
|
|
7
|
+
*/
|
|
8
|
+
import type { PointSink } from './points.js';
|
|
9
|
+
export interface ToolCallPoint {
|
|
10
|
+
/** The app, or the part of it, the call ran in. */
|
|
11
|
+
app: string;
|
|
12
|
+
/** The tool or function called (`trade.portfolio_list`), or a name for the script. */
|
|
13
|
+
tool: string;
|
|
14
|
+
/** How it was called: call, script, sql, web, or your own. */
|
|
15
|
+
kind: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
/** Wall time of the call, ms. */
|
|
18
|
+
ms: number;
|
|
19
|
+
/** A short error kind when it failed (`bad_args`, `timeout`), never the message. */
|
|
20
|
+
error?: string;
|
|
21
|
+
/** The code the call ran (a script's source, a query); only its hash is written. */
|
|
22
|
+
code?: string;
|
|
23
|
+
/** Who started it: chat, brief, job, or your own. */
|
|
24
|
+
source?: string;
|
|
25
|
+
/** The model size or tier that made the call, never a model's name if the app keeps it private. */
|
|
26
|
+
tier?: string;
|
|
27
|
+
/** The turn or run the call belongs to, an id the app makes (not a person's). */
|
|
28
|
+
run?: string;
|
|
29
|
+
/** Rows the call returned. */
|
|
30
|
+
rows?: number;
|
|
31
|
+
/** Bytes of the call's input and output. */
|
|
32
|
+
bytesIn?: number;
|
|
33
|
+
bytesOut?: number;
|
|
34
|
+
/** Tokens, when the call itself ran a model. */
|
|
35
|
+
tokensIn?: number;
|
|
36
|
+
tokensOut?: number;
|
|
37
|
+
}
|
|
38
|
+
/** The entry point sets the dataset once per isolate. */
|
|
39
|
+
export declare function setToolSink(s: PointSink | undefined): void;
|
|
40
|
+
/** A 64-bit hash of the code with its whitespace folded: the same script hashes the same. */
|
|
41
|
+
export declare function codeHash(code: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Column order is the contract with whoever reads the dataset back: index1 app; blobs app, tool,
|
|
44
|
+
* kind, outcome, error, code hash, source, tier, run; doubles ms, rows, bytes in, bytes out,
|
|
45
|
+
* tokens in, tokens out.
|
|
46
|
+
*/
|
|
47
|
+
export declare function meterToolCall(p: ToolCallPoint): void;
|
|
48
|
+
/** Runs one tool call and meters it: the wall time, and `error` as the kind when it throws. */
|
|
49
|
+
export declare function meteredCall<T>(p: Omit<ToolCallPoint, 'ok' | 'ms'>, fn: () => Promise<T>, errorKind?: (err: unknown) => string): Promise<T>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
let sink;
|
|
2
|
+
/** The entry point sets the dataset once per isolate. */
|
|
3
|
+
export function setToolSink(s) {
|
|
4
|
+
sink = s;
|
|
5
|
+
}
|
|
6
|
+
const fnv = (s, seed) => {
|
|
7
|
+
let h = seed >>> 0;
|
|
8
|
+
for (let i = 0; i < s.length; i++)
|
|
9
|
+
h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0;
|
|
10
|
+
return h.toString(16).padStart(8, '0');
|
|
11
|
+
};
|
|
12
|
+
/** A 64-bit hash of the code with its whitespace folded: the same script hashes the same. */
|
|
13
|
+
export function codeHash(code) {
|
|
14
|
+
const s = code.replace(/\s+/g, ' ').trim();
|
|
15
|
+
return fnv(s, 2166136261) + fnv(s, 0x9e3779b9);
|
|
16
|
+
}
|
|
17
|
+
const cap = (s, n = 96) => (s.length > n ? s.slice(0, n) : s);
|
|
18
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
19
|
+
/**
|
|
20
|
+
* Column order is the contract with whoever reads the dataset back: index1 app; blobs app, tool,
|
|
21
|
+
* kind, outcome, error, code hash, source, tier, run; doubles ms, rows, bytes in, bytes out,
|
|
22
|
+
* tokens in, tokens out.
|
|
23
|
+
*/
|
|
24
|
+
export function meterToolCall(p) {
|
|
25
|
+
if (!sink)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
sink.writeDataPoint({
|
|
29
|
+
indexes: [cap(p.app)],
|
|
30
|
+
blobs: [
|
|
31
|
+
cap(p.app),
|
|
32
|
+
cap(p.tool, 128),
|
|
33
|
+
cap(p.kind),
|
|
34
|
+
p.ok ? 'ok' : 'error',
|
|
35
|
+
cap(p.error ?? ''),
|
|
36
|
+
p.code ? codeHash(p.code) : '',
|
|
37
|
+
cap(p.source ?? ''),
|
|
38
|
+
cap(p.tier ?? ''),
|
|
39
|
+
cap(p.run ?? ''),
|
|
40
|
+
],
|
|
41
|
+
doubles: [
|
|
42
|
+
num(p.ms),
|
|
43
|
+
num(p.rows),
|
|
44
|
+
num(p.bytesIn),
|
|
45
|
+
num(p.bytesOut),
|
|
46
|
+
num(p.tokensIn),
|
|
47
|
+
num(p.tokensOut),
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error('meterToolCall', err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Runs one tool call and meters it: the wall time, and `error` as the kind when it throws. */
|
|
56
|
+
export async function meteredCall(p, fn, errorKind = () => 'error') {
|
|
57
|
+
const t0 = performance.now();
|
|
58
|
+
try {
|
|
59
|
+
const out = await fn();
|
|
60
|
+
meterToolCall({ ...p, ok: true, ms: performance.now() - t0 });
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
meterToolCall({ ...p, ok: false, ms: performance.now() - t0, error: errorKind(err) });
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface WebhookSend {
|
|
2
|
+
url: string;
|
|
3
|
+
/** The body as sent and signed, usually JSON. */
|
|
4
|
+
body: string;
|
|
5
|
+
/** Signs the delivery: `whsec_…` (see `newWebhookSecret`). Left out, nothing is signed. */
|
|
6
|
+
secret?: string;
|
|
7
|
+
/** The message id the signature covers; the same id on a retry. Made up when left out. */
|
|
8
|
+
id?: string;
|
|
9
|
+
/** The endpoint's own headers (its auth). They can never replace the signature headers. */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
/** Default `application/json`. */
|
|
12
|
+
contentType?: string;
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
/** Default 10 s. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Default `manual`: a redirect counts as a failed delivery, never followed. */
|
|
17
|
+
redirect?: RequestRedirect;
|
|
18
|
+
/** For tests or a service binding; default the global fetch. */
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
export interface WebhookResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
/** The endpoint's HTTP status; 0 when it never answered (timeout, DNS, refused). */
|
|
24
|
+
status: number;
|
|
25
|
+
ms: number;
|
|
26
|
+
/** The message id sent. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** On a failure: the first 200 characters of the answer, or why there was none. */
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
/** A new signing secret, `whsec_` and 32 random bytes in base64. Show it to its owner once. */
|
|
32
|
+
export declare function newWebhookSecret(): string;
|
|
33
|
+
/** Sends one delivery. */
|
|
34
|
+
export declare function sendWebhook(send: WebhookSend): Promise<WebhookResult>;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks out: one POST to an endpoint, signed per the Standard Webhooks spec
|
|
3
|
+
* (https://www.standardwebhooks.com) when a secret is given, so any of its libraries verifies it.
|
|
4
|
+
* The send never throws: it returns how it went, for the app's own delivery log and retries.
|
|
5
|
+
*/
|
|
6
|
+
import { Webhook } from 'standardwebhooks';
|
|
7
|
+
/** A new signing secret, `whsec_` and 32 random bytes in base64. Show it to its owner once. */
|
|
8
|
+
export function newWebhookSecret() {
|
|
9
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
10
|
+
return `whsec_${btoa(String.fromCharCode(...bytes))}`;
|
|
11
|
+
}
|
|
12
|
+
/** Sends one delivery. */
|
|
13
|
+
export async function sendWebhook(send) {
|
|
14
|
+
const id = send.id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`;
|
|
15
|
+
const at = new Date();
|
|
16
|
+
const signed = send.secret
|
|
17
|
+
? {
|
|
18
|
+
'webhook-id': id,
|
|
19
|
+
'webhook-timestamp': String(Math.floor(at.getTime() / 1000)),
|
|
20
|
+
'webhook-signature': new Webhook(send.secret).sign(id, at, send.body),
|
|
21
|
+
}
|
|
22
|
+
: {};
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
try {
|
|
25
|
+
const res = await (send.fetch ?? fetch)(send.url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
redirect: send.redirect ?? 'manual',
|
|
28
|
+
signal: AbortSignal.timeout(send.timeoutMs ?? 10_000),
|
|
29
|
+
headers: {
|
|
30
|
+
...send.headers,
|
|
31
|
+
'content-type': send.contentType ?? 'application/json',
|
|
32
|
+
...(send.userAgent ? { 'user-agent': send.userAgent } : {}),
|
|
33
|
+
...signed,
|
|
34
|
+
},
|
|
35
|
+
body: send.body,
|
|
36
|
+
});
|
|
37
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
38
|
+
let error;
|
|
39
|
+
if (ok)
|
|
40
|
+
await res.body?.cancel();
|
|
41
|
+
else
|
|
42
|
+
error = (await res.text().catch(() => '')).slice(0, 200) || `HTTP ${res.status}`;
|
|
43
|
+
return { ok, status: res.status, ms: Date.now() - started, id, ...(error ? { error } : {}) };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
47
|
+
return { ok: false, status: 0, ms: Date.now() - started, id, error: error.slice(0, 200) };
|
|
48
|
+
}
|
|
49
|
+
}
|
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
|
|
3
|
+
"version": "0.1.37",
|
|
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.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
"./queries": {
|
|
19
19
|
"types": "./dist/queries.d.ts",
|
|
20
20
|
"default": "./dist/queries.js"
|
|
21
|
+
},
|
|
22
|
+
"./tools": {
|
|
23
|
+
"types": "./dist/tools.d.ts",
|
|
24
|
+
"default": "./dist/tools.js"
|
|
25
|
+
},
|
|
26
|
+
"./webhooks": {
|
|
27
|
+
"types": "./dist/webhooks.d.ts",
|
|
28
|
+
"default": "./dist/webhooks.js"
|
|
21
29
|
}
|
|
22
30
|
},
|
|
23
31
|
"files": [
|
|
@@ -39,5 +47,7 @@
|
|
|
39
47
|
"access": "public",
|
|
40
48
|
"registry": "https://registry.npmjs.org/"
|
|
41
49
|
},
|
|
42
|
-
"dependencies": {
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"standardwebhooks": "1.1.1"
|
|
52
|
+
}
|
|
43
53
|
}
|