agentmetrics-openclaw 0.2.3 → 0.2.4
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 +42 -19
- package/index.ts +675 -82
- package/openclaw.plugin.json +50 -2
- package/package.json +40 -27
package/README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# agentmetrics-openclaw
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/agentmetrics-openclaw)
|
|
4
|
+
[](../LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
→ **[agentmetrics.dev/docs/integrations/openclaw](https://agentmetrics.dev/docs/integrations/openclaw)**
|
|
6
|
+
AgentMetrics integration for [OpenClaw](https://openclaw.dev). Install the plugin, point it at your server, and every agent session reports back to your dashboard automatically showing tokens, cost, tools, subagents, context health, and reliability, all without changing your agent code.
|
|
9
7
|
|
|
10
8
|
---
|
|
11
9
|
|
|
@@ -13,7 +11,7 @@ Zero-code observability for [OpenClaw](https://openclaw.dev) agents. Install the
|
|
|
13
11
|
|
|
14
12
|
- OpenClaw 2026.3.2 or later
|
|
15
13
|
- Node.js 22 or later
|
|
16
|
-
-
|
|
14
|
+
- A running AgentMetrics server (see the [main README](../../../README.md) for setup)
|
|
17
15
|
|
|
18
16
|
---
|
|
19
17
|
|
|
@@ -27,35 +25,60 @@ openclaw plugins install agentmetrics-openclaw
|
|
|
27
25
|
|
|
28
26
|
## Setup
|
|
29
27
|
|
|
30
|
-
**1.
|
|
28
|
+
**1. Start AgentMetrics** (if not already running)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Docker
|
|
32
|
+
docker compose up
|
|
33
|
+
|
|
34
|
+
# Or Python CLI
|
|
35
|
+
pip install agentmetrics
|
|
36
|
+
agentmetrics dashboard
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**2. Set the server URL** (if not running on localhost)
|
|
31
40
|
|
|
32
41
|
```bash
|
|
33
|
-
# macOS / Linux
|
|
34
|
-
echo 'export
|
|
42
|
+
# macOS / Linux, permanent
|
|
43
|
+
echo 'export AGENTMETRICS_BASE_URL=http://your-server:8099' >> ~/.bashrc && source ~/.bashrc
|
|
35
44
|
|
|
36
45
|
# Windows (PowerShell)
|
|
37
|
-
$Env:
|
|
46
|
+
$Env:AGENTMETRICS_BASE_URL = "http://your-server:8099"
|
|
38
47
|
```
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
Omit this step if your server runs on `http://localhost:8099` (the default).
|
|
50
|
+
|
|
51
|
+
**3. Trust the plugin** (silences the security scan advisory)
|
|
41
52
|
|
|
42
53
|
```bash
|
|
43
54
|
openclaw config set plugins.allow '["agentmetrics"]'
|
|
44
55
|
```
|
|
45
56
|
|
|
46
|
-
**
|
|
57
|
+
**4. Restart the gateway**
|
|
47
58
|
|
|
48
59
|
```bash
|
|
49
60
|
openclaw gateway restart
|
|
50
61
|
```
|
|
51
62
|
|
|
52
|
-
**
|
|
63
|
+
**5. Verify**
|
|
53
64
|
|
|
54
65
|
```bash
|
|
55
66
|
openclaw plugins list
|
|
56
67
|
# agentmetrics loaded
|
|
57
68
|
```
|
|
58
69
|
|
|
70
|
+
**6. Set your agent name** (recommended)
|
|
71
|
+
|
|
72
|
+
In your agent's `openclaw.json`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"name": "my-agent"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `name` field becomes the agent ID in your dashboard. Give each agent a distinct name.
|
|
81
|
+
|
|
59
82
|
---
|
|
60
83
|
|
|
61
84
|
## What gets tracked
|
|
@@ -76,15 +99,15 @@ Every agent session reports automatically:
|
|
|
76
99
|
|
|
77
100
|
## Troubleshooting
|
|
78
101
|
|
|
79
|
-
**"dangerous code patterns" warning on install**
|
|
80
|
-
Safe to ignore. The plugin reads `
|
|
102
|
+
**"dangerous code patterns" warning on install**
|
|
103
|
+
Safe to ignore. The plugin reads `AGENTMETRICS_BASE_URL` and makes network calls to the AgentMetrics API. Add `agentmetrics` to `plugins.allow` to suppress it permanently.
|
|
81
104
|
|
|
82
|
-
**"manifest id does not match package name" warning**
|
|
105
|
+
**"manifest id does not match package name" warning**
|
|
83
106
|
Not an error. The plugin's internal manifest id is `agentmetrics`; the npm package name is `agentmetrics-openclaw`. Use `agentmetrics` (not the npm name) in `plugins.allow`.
|
|
84
107
|
|
|
85
|
-
**Runs not appearing in the dashboard**
|
|
86
|
-
1.
|
|
87
|
-
2.
|
|
108
|
+
**Runs not appearing in the dashboard**
|
|
109
|
+
1. Verify the plugin loads: `openclaw plugins list`
|
|
110
|
+
2. Check the server is reachable: `openclaw agentmetrics test`
|
|
88
111
|
3. Restart the gateway after any env var change
|
|
89
112
|
4. Confirm your `openclaw.json` has a `name` field
|
|
90
113
|
|
package/index.ts
CHANGED
|
@@ -1,9 +1,99 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { gzipSync } from "zlib";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
function _hashName(name: string): string {
|
|
6
|
+
let h = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < name.length; i++) {
|
|
8
|
+
h = (Math.imul(h ^ name.charCodeAt(i), 0x01000193)) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
return `t_${h.toString(16).padStart(8, "0")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const _SECRET_PATTERNS: RegExp[] = [
|
|
14
|
+
/sk-[A-Za-z0-9\-_]{20,}/g,
|
|
15
|
+
/am_[A-Za-z0-9\-_]{16,}/g,
|
|
16
|
+
/\bey[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}/g,
|
|
17
|
+
/(?:api[_\-]?key|apikey|api[_\-]?token|access[_\-]?token|secret|password|passwd|auth)[=:\s"']+([^\s"'&,\]}\n]{8,})/gi,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function _scrubSecrets(str: string): string {
|
|
21
|
+
let out = str;
|
|
22
|
+
for (const re of _SECRET_PATTERNS) {
|
|
23
|
+
out = out.replace(re, "[REDACTED]");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const _PRICING: Record<string, [number, number, number?, number?]> = {
|
|
29
|
+
"claude-opus-4-7": [15.0, 75.0, 1.50, 18.75],
|
|
30
|
+
"claude-opus-4-5": [15.0, 75.0, 1.50, 18.75],
|
|
31
|
+
"claude-opus-4": [15.0, 75.0, 1.50, 18.75],
|
|
32
|
+
"claude-sonnet-4-6": [ 3.0, 15.0, 0.30, 3.75],
|
|
33
|
+
"claude-sonnet-4-5": [ 3.0, 15.0, 0.30, 3.75],
|
|
34
|
+
"claude-haiku-4-5": [ 0.8, 4.0, 0.08, 1.00],
|
|
35
|
+
"claude-sonnet-3-7": [ 3.0, 15.0, 0.30, 3.75],
|
|
36
|
+
"claude-3-5-sonnet-20241022": [ 3.0, 15.0, 0.30, 3.75],
|
|
37
|
+
"claude-3-5-sonnet-20240620": [ 3.0, 15.0, 0.30, 3.75],
|
|
38
|
+
"claude-3-5-haiku-20241022": [ 0.8, 4.0, 0.08, 1.00],
|
|
39
|
+
"claude-3-opus-20240229": [15.0, 75.0, 1.50, 18.75],
|
|
40
|
+
"claude-3-sonnet-20240229": [ 3.0, 15.0],
|
|
41
|
+
"claude-3-haiku-20240307": [ 0.25, 1.25, 0.03, 0.30],
|
|
42
|
+
"gpt-4o": [ 2.5, 10.0],
|
|
43
|
+
"gpt-4o-mini": [ 0.15, 0.60],
|
|
44
|
+
"gpt-4-turbo": [10.0, 30.0],
|
|
45
|
+
"gpt-4": [30.0, 60.0],
|
|
46
|
+
"gpt-3.5-turbo": [ 0.50, 1.50],
|
|
47
|
+
"gemini-2.0-flash": [ 0.075, 0.30],
|
|
48
|
+
"gemini-2.5-pro": [ 1.25, 10.0],
|
|
49
|
+
"gemini-1.5-pro": [ 1.25, 5.0],
|
|
50
|
+
"gemini-1.5-flash": [ 0.075, 0.30],
|
|
51
|
+
};
|
|
52
|
+
|
|
2
53
|
|
|
3
|
-
// pluginConfig overrides env vars — set once in register()
|
|
4
54
|
let API_KEY: string | undefined;
|
|
5
55
|
let BASE_URL: string;
|
|
6
56
|
|
|
57
|
+
let ENABLED = true;
|
|
58
|
+
let REDACTION_MODE: "strict" | "moderate" | "debug" = "strict";
|
|
59
|
+
let EXPORTED_TOOL_NAMES: "allowlist" | "blocklist" | "hash" | "off" = "blocklist";
|
|
60
|
+
let REDACT_TOOL_NAMES: string[] = [];
|
|
61
|
+
let DEBUG_EXPIRES_AT: number | null = null;
|
|
62
|
+
|
|
63
|
+
let FLUSH_INTERVAL_MS = 10_000;
|
|
64
|
+
let MAX_BATCH_SIZE = 100;
|
|
65
|
+
let MAX_QUEUE_SIZE = 10_000;
|
|
66
|
+
let RETRY_MAX_ATTEMPTS = 5;
|
|
67
|
+
let COMPRESS_PAYLOADS = false;
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
const _metrics = { sent: 0, failed: 0, dropped: 0 };
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
interface QueuedEvent {
|
|
74
|
+
payload: Record<string, unknown>;
|
|
75
|
+
attempt: number;
|
|
76
|
+
enqueuedAt: number;
|
|
77
|
+
}
|
|
78
|
+
const _queue: QueuedEvent[] = [];
|
|
79
|
+
const _dlq: QueuedEvent[] = [];
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
const CB_THRESHOLD = 10;
|
|
83
|
+
const CB_PROBE_MS = 5 * 60_000;
|
|
84
|
+
type CbState = "closed" | "open" | "half-open";
|
|
85
|
+
let _cbState: CbState = "closed";
|
|
86
|
+
let _cbConsecFails = 0;
|
|
87
|
+
let _cbOpenAt: number | null = null;
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
let WAL_PATH: string | null = null;
|
|
91
|
+
let _flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
92
|
+
|
|
93
|
+
// Duplicate-registration guard
|
|
94
|
+
let _registered = false;
|
|
95
|
+
|
|
96
|
+
|
|
7
97
|
interface PluginApi {
|
|
8
98
|
config: Record<string, unknown>;
|
|
9
99
|
pluginConfig?: Record<string, unknown>;
|
|
@@ -11,12 +101,11 @@ interface PluginApi {
|
|
|
11
101
|
registerCli?: (registrar: {
|
|
12
102
|
name: string;
|
|
13
103
|
description: string;
|
|
14
|
-
commands: Array<{ name: string; description: string; handler: () => void }>;
|
|
104
|
+
commands: Array<{ name: string; description: string; handler: () => void | Promise<void> }>;
|
|
15
105
|
}) => void;
|
|
16
106
|
on: (hookName: string, handler: (...args: unknown[]) => void) => void;
|
|
17
107
|
}
|
|
18
108
|
|
|
19
|
-
// ─── Types (sourced from openclaw/src/plugins/hook-types.ts) ──────────────────
|
|
20
109
|
|
|
21
110
|
type AgentContext = {
|
|
22
111
|
runId?: string;
|
|
@@ -151,7 +240,6 @@ type GatewayStartEvent = { port: number };
|
|
|
151
240
|
type GatewayStopEvent = { reason?: string };
|
|
152
241
|
type GatewayContext = { port?: number };
|
|
153
242
|
|
|
154
|
-
// ─── Per-session and per-run state ────────────────────────────────────────────
|
|
155
243
|
|
|
156
244
|
interface SessionMeta {
|
|
157
245
|
traceId: string;
|
|
@@ -159,6 +247,15 @@ interface SessionMeta {
|
|
|
159
247
|
startedAt: number;
|
|
160
248
|
compactions: number;
|
|
161
249
|
resets: number;
|
|
250
|
+
// Session-level aggregates accumulated across all runs in this session
|
|
251
|
+
runCount: number;
|
|
252
|
+
totalInputTokens: number;
|
|
253
|
+
totalOutputTokens: number;
|
|
254
|
+
totalCacheReadTokens: number;
|
|
255
|
+
totalCacheWriteTokens: number;
|
|
256
|
+
totalToolCalls: number;
|
|
257
|
+
totalEstimatedCostUsd: number;
|
|
258
|
+
totalDurationMs: number;
|
|
162
259
|
}
|
|
163
260
|
|
|
164
261
|
interface RunMeta {
|
|
@@ -176,43 +273,201 @@ interface RunMeta {
|
|
|
176
273
|
model?: string;
|
|
177
274
|
provider?: string;
|
|
178
275
|
sessionKey?: string;
|
|
179
|
-
startedAt: number;
|
|
276
|
+
startedAt: number;
|
|
180
277
|
}
|
|
181
278
|
|
|
182
279
|
const sessions = new Map<string, SessionMeta>();
|
|
183
280
|
const runs = new Map<string, RunMeta>();
|
|
184
281
|
|
|
185
|
-
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
186
282
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!API_KEY) return;
|
|
283
|
+
function _walAppend(payload: Record<string, unknown>): void {
|
|
284
|
+
if (!WAL_PATH) return;
|
|
190
285
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
286
|
+
appendFileSync(WAL_PATH, JSON.stringify(payload) + "\n", "utf8");
|
|
287
|
+
} catch { /* non-fatal */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _walCompact(sentIds: Set<string>): void {
|
|
291
|
+
if (!WAL_PATH || !existsSync(WAL_PATH)) return;
|
|
292
|
+
try {
|
|
293
|
+
const lines = readFileSync(WAL_PATH, "utf8").split("\n").filter(Boolean);
|
|
294
|
+
const kept = lines.filter((line) => {
|
|
295
|
+
try {
|
|
296
|
+
const ev = JSON.parse(line) as Record<string, unknown>;
|
|
297
|
+
return !sentIds.has(String(ev.event_id ?? ""));
|
|
298
|
+
} catch { return false; }
|
|
198
299
|
});
|
|
300
|
+
writeFileSync(WAL_PATH, kept.length ? kept.join("\n") + "\n" : "", "utf8");
|
|
301
|
+
} catch { /* non-fatal */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _walRecover(): void {
|
|
305
|
+
if (!WAL_PATH || !existsSync(WAL_PATH)) return;
|
|
306
|
+
try {
|
|
307
|
+
const lines = readFileSync(WAL_PATH, "utf8").split("\n").filter(Boolean);
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
try {
|
|
310
|
+
const payload = JSON.parse(line) as Record<string, unknown>;
|
|
311
|
+
_enqueue(payload, true);
|
|
312
|
+
} catch { /* skip corrupt lines */ }
|
|
313
|
+
}
|
|
314
|
+
if (lines.length > 0) {
|
|
315
|
+
console.log(`AgentMetrics: recovered ${lines.length} event(s) from WAL`);
|
|
316
|
+
}
|
|
317
|
+
} catch { /* non-fatal */ }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
function _enqueue(payload: Record<string, unknown>, fromWal = false): void {
|
|
322
|
+
if (_queue.length >= MAX_QUEUE_SIZE) {
|
|
323
|
+
_queue.shift(); // FIFO: drop oldest on overflow
|
|
324
|
+
_metrics.dropped += 1;
|
|
325
|
+
}
|
|
326
|
+
_queue.push({ payload, attempt: 0, enqueuedAt: Date.now() });
|
|
327
|
+
if (!fromWal) _walAppend(payload);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
function _cbIsOpen(): boolean {
|
|
332
|
+
if (_cbState === "closed") return false;
|
|
333
|
+
if (_cbState === "open") {
|
|
334
|
+
if (_cbOpenAt !== null && Date.now() - _cbOpenAt >= CB_PROBE_MS) {
|
|
335
|
+
_cbState = "half-open";
|
|
336
|
+
return false; // let one probe through
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false; // half-open: probe is allowed
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function _cbOnSuccess(): void {
|
|
344
|
+
if (_cbState !== "closed") {
|
|
345
|
+
console.log("AgentMetrics: circuit breaker closed - delivery resumed");
|
|
346
|
+
}
|
|
347
|
+
_cbState = "closed";
|
|
348
|
+
_cbConsecFails = 0;
|
|
349
|
+
_cbOpenAt = null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _cbOnFailure(): void {
|
|
353
|
+
_cbConsecFails += 1;
|
|
354
|
+
if (_cbState === "half-open" || _cbConsecFails >= CB_THRESHOLD) {
|
|
355
|
+
_cbState = "open";
|
|
356
|
+
_cbOpenAt = Date.now();
|
|
357
|
+
console.log(
|
|
358
|
+
`AgentMetrics: circuit breaker opened after ${_cbConsecFails} consecutive failures - ` +
|
|
359
|
+
`probing again in ${CB_PROBE_MS / 60_000}min`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
function _buildHeaders(): Record<string, string> {
|
|
366
|
+
return {
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
"Authorization": `Bearer ${API_KEY}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _maybeGzip(body: string): { body: string | Uint8Array; extra: Record<string, string> } {
|
|
373
|
+
if (COMPRESS_PAYLOADS && body.length > 1024) {
|
|
374
|
+
try {
|
|
375
|
+
return {
|
|
376
|
+
body: gzipSync(Buffer.from(body, "utf8")),
|
|
377
|
+
extra: { "Content-Encoding": "gzip" },
|
|
378
|
+
};
|
|
379
|
+
} catch { /* fall through */ }
|
|
380
|
+
}
|
|
381
|
+
return { body, extra: {} };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _requeue(batch: QueuedEvent[]): void {
|
|
385
|
+
for (const item of batch) {
|
|
386
|
+
item.attempt += 1;
|
|
387
|
+
_metrics.failed += 1;
|
|
388
|
+
if (item.attempt >= RETRY_MAX_ATTEMPTS) {
|
|
389
|
+
_dlq.push(item);
|
|
390
|
+
} else {
|
|
391
|
+
_queue.push(item); // back of queue, not front - prevent starvation
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function _flushBatch(batch: QueuedEvent[]): Promise<void> {
|
|
397
|
+
const rawBody = JSON.stringify({ events: batch.map((e) => e.payload) });
|
|
398
|
+
const { body, extra } = _maybeGzip(rawBody);
|
|
399
|
+
try {
|
|
400
|
+
const resp = await fetch(`${BASE_URL}/v1/events/batch`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: { ..._buildHeaders(), ...extra },
|
|
403
|
+
body,
|
|
404
|
+
});
|
|
405
|
+
if (resp.ok) {
|
|
406
|
+
_cbOnSuccess();
|
|
407
|
+
_metrics.sent += batch.length;
|
|
408
|
+
_walCompact(new Set(batch.map((e) => String(e.payload.event_id ?? ""))));
|
|
409
|
+
} else if (resp.status === 404) {
|
|
410
|
+
await _flushIndividual(batch); // batch endpoint not yet available
|
|
411
|
+
} else {
|
|
412
|
+
_cbOnFailure();
|
|
413
|
+
_requeue(batch);
|
|
414
|
+
}
|
|
199
415
|
} catch {
|
|
200
|
-
|
|
416
|
+
_cbOnFailure();
|
|
417
|
+
_requeue(batch);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function _flushIndividual(batch: QueuedEvent[]): Promise<void> {
|
|
422
|
+
for (const item of batch) {
|
|
423
|
+
const rawBody = JSON.stringify(item.payload);
|
|
424
|
+
const { body, extra } = _maybeGzip(rawBody);
|
|
425
|
+
try {
|
|
426
|
+
const resp = await fetch(`${BASE_URL}/v1/events`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { ..._buildHeaders(), ...extra },
|
|
429
|
+
body,
|
|
430
|
+
});
|
|
431
|
+
if (resp.ok) {
|
|
432
|
+
_cbOnSuccess();
|
|
433
|
+
_metrics.sent += 1;
|
|
434
|
+
_walCompact(new Set([String(item.payload.event_id ?? "")]));
|
|
435
|
+
} else {
|
|
436
|
+
_cbOnFailure();
|
|
437
|
+
_requeue([item]);
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
_cbOnFailure();
|
|
441
|
+
_requeue([item]);
|
|
442
|
+
}
|
|
201
443
|
}
|
|
202
444
|
}
|
|
203
445
|
|
|
446
|
+
async function _flush(): Promise<void> {
|
|
447
|
+
if (!API_KEY || _queue.length === 0 || _cbIsOpen()) return;
|
|
448
|
+
const batch = _queue.splice(0, MAX_BATCH_SIZE);
|
|
449
|
+
await _flushBatch(batch);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
/** Enqueue a completed run summary; delivered by the flush loop. */
|
|
454
|
+
function send(payload: Record<string, unknown>): void {
|
|
455
|
+
if (!API_KEY || !ENABLED) return;
|
|
456
|
+
_enqueue(payload);
|
|
457
|
+
}
|
|
458
|
+
|
|
204
459
|
/**
|
|
205
460
|
* Send a real-time intermediate event to /v1/activity (in-memory, streamed
|
|
206
|
-
* to dashboard via SSE). Fire-and-forget
|
|
461
|
+
* to dashboard via SSE). Fire-and-forget - no retry, no crash on failure.
|
|
207
462
|
*/
|
|
208
463
|
function sendActivity(
|
|
209
|
-
type:
|
|
210
|
-
agentId:
|
|
211
|
-
sessionKey:
|
|
212
|
-
runId:
|
|
213
|
-
data?:
|
|
464
|
+
type: string,
|
|
465
|
+
agentId: string,
|
|
466
|
+
sessionKey: string | undefined,
|
|
467
|
+
runId: string | undefined,
|
|
468
|
+
data?: Record<string, unknown>,
|
|
214
469
|
): void {
|
|
215
|
-
if (!API_KEY) return;
|
|
470
|
+
if (!API_KEY || !ENABLED) return;
|
|
216
471
|
const payload = JSON.stringify({
|
|
217
472
|
type,
|
|
218
473
|
agent_id: agentId,
|
|
@@ -243,12 +498,73 @@ function emptyRun(sessionKey?: string): RunMeta {
|
|
|
243
498
|
};
|
|
244
499
|
}
|
|
245
500
|
|
|
246
|
-
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
function _activeMode(): "strict" | "moderate" | "debug" {
|
|
504
|
+
if (DEBUG_EXPIRES_AT !== null) {
|
|
505
|
+
if (Date.now() < DEBUG_EXPIRES_AT) return "debug";
|
|
506
|
+
DEBUG_EXPIRES_AT = null;
|
|
507
|
+
console.log("AgentMetrics: debug mode expired - reverting to strict redaction");
|
|
508
|
+
}
|
|
509
|
+
return REDACTION_MODE;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _redactError(err: string | undefined, activityStream = false): string | undefined {
|
|
513
|
+
if (!err) return err;
|
|
514
|
+
const mode = _activeMode();
|
|
515
|
+
if (mode === "debug") return err;
|
|
516
|
+
const maxLen = (mode === "strict" || activityStream) ? 200 : 500;
|
|
517
|
+
return _scrubSecrets(err).slice(0, maxLen);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _redactToolName(name: string): string {
|
|
521
|
+
const mode = _activeMode();
|
|
522
|
+
if (mode === "debug") return name;
|
|
523
|
+
// allowlist mode: redactToolNames is the permitted list; blocklist mode: it's the deny list
|
|
524
|
+
switch (EXPORTED_TOOL_NAMES) {
|
|
525
|
+
case "off":
|
|
526
|
+
return "[REDACTED]";
|
|
527
|
+
case "hash":
|
|
528
|
+
return _hashName(name);
|
|
529
|
+
case "allowlist":
|
|
530
|
+
return REDACT_TOOL_NAMES.includes(name) ? name : `[REDACTED:${_hashName(name)}]`;
|
|
531
|
+
case "blocklist":
|
|
532
|
+
default:
|
|
533
|
+
return REDACT_TOOL_NAMES.includes(name) ? `[REDACTED:${_hashName(name)}]` : name;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function _redactToolNames(names: string[]): string[] {
|
|
538
|
+
return names.map(_redactToolName);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
function _estimateCost(
|
|
544
|
+
model: string | undefined,
|
|
545
|
+
input: number,
|
|
546
|
+
output: number,
|
|
547
|
+
cacheRead: number,
|
|
548
|
+
cacheWrite: number,
|
|
549
|
+
): number | undefined {
|
|
550
|
+
if (!model) return undefined;
|
|
551
|
+
// Try exact match first, then strip date suffix (e.g. -20241022)
|
|
552
|
+
const rates = _PRICING[model.toLowerCase()] ?? _PRICING[model.toLowerCase().replace(/-\d{8}$/, "")];
|
|
553
|
+
if (!rates) return undefined;
|
|
554
|
+
const M = 1_000_000;
|
|
555
|
+
return (
|
|
556
|
+
input * rates[0] / M +
|
|
557
|
+
output * rates[1] / M +
|
|
558
|
+
cacheRead * (rates[2] ?? 0) / M +
|
|
559
|
+
cacheWrite * (rates[3] ?? 0) / M
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
247
563
|
|
|
248
564
|
const plugin = {
|
|
249
565
|
id: "agentmetrics",
|
|
250
566
|
name: "AgentMetrics",
|
|
251
|
-
description: "360-degree observability for every OpenClaw agent
|
|
567
|
+
description: "360-degree observability for every OpenClaw agent - real-time streaming, tokens, tools, latency, cost, subagents, and reliability.",
|
|
252
568
|
configSchema: {
|
|
253
569
|
type: "object",
|
|
254
570
|
properties: {
|
|
@@ -258,41 +574,125 @@ const plugin = {
|
|
|
258
574
|
},
|
|
259
575
|
endpoint: {
|
|
260
576
|
type: "string",
|
|
261
|
-
description: "Custom API endpoint (default:
|
|
577
|
+
description: "Custom API endpoint (default: http://localhost:8099)",
|
|
578
|
+
},
|
|
579
|
+
enabled: {
|
|
580
|
+
type: "boolean",
|
|
581
|
+
description: "Disable the plugin without removing it (default: true)",
|
|
582
|
+
},
|
|
583
|
+
flushIntervalSeconds: {
|
|
584
|
+
type: "number",
|
|
585
|
+
description: "How often to flush the event queue to the API (default: 10)",
|
|
586
|
+
},
|
|
587
|
+
maxBatchSize: {
|
|
588
|
+
type: "number",
|
|
589
|
+
description: "Maximum events per batch request (default: 100)",
|
|
590
|
+
},
|
|
591
|
+
maxQueueSize: {
|
|
592
|
+
type: "number",
|
|
593
|
+
description: "Maximum in-memory queue depth before FIFO drop (default: 10000)",
|
|
594
|
+
},
|
|
595
|
+
retryMaxAttempts: {
|
|
596
|
+
type: "number",
|
|
597
|
+
description: "Max retry attempts before moving event to DLQ (default: 5)",
|
|
598
|
+
},
|
|
599
|
+
redactionMode: {
|
|
600
|
+
type: "string",
|
|
601
|
+
enum: ["strict", "moderate", "debug"],
|
|
602
|
+
description: "PII redaction level applied to prompts/completions (default: strict)",
|
|
603
|
+
},
|
|
604
|
+
exportedToolNames: {
|
|
605
|
+
type: "string",
|
|
606
|
+
enum: ["allowlist", "blocklist", "hash", "off"],
|
|
607
|
+
description: "Which tool names to include in exports (default: blocklist)",
|
|
608
|
+
},
|
|
609
|
+
redactToolNames: {
|
|
610
|
+
type: "array",
|
|
611
|
+
items: { type: "string" },
|
|
612
|
+
description: "Tool names to redact when exportedToolNames is 'blocklist'",
|
|
613
|
+
},
|
|
614
|
+
compressPayloads: {
|
|
615
|
+
type: "boolean",
|
|
616
|
+
description: "Gzip-compress batch payloads larger than 1 KB (default: false)",
|
|
262
617
|
},
|
|
263
618
|
},
|
|
264
619
|
additionalProperties: false,
|
|
265
620
|
} as const,
|
|
266
621
|
|
|
267
622
|
register(api: PluginApi) {
|
|
268
|
-
|
|
623
|
+
if (_registered) {
|
|
624
|
+
console.warn(
|
|
625
|
+
"\n AgentMetrics: ⚠ register() called twice - possible duplicate instrumentation.\n" +
|
|
626
|
+
" If you have both the plugin and an SDK hook active, remove one to avoid\n" +
|
|
627
|
+
" double-counting runs and inflated token/cost totals.\n",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
_registered = true;
|
|
631
|
+
|
|
269
632
|
API_KEY = (api.pluginConfig?.apiKey as string | undefined) ?? process.env.AGENTMETRICS_API_KEY;
|
|
270
633
|
BASE_URL = (
|
|
271
634
|
(api.pluginConfig?.endpoint as string | undefined) ??
|
|
272
635
|
process.env.AGENTMETRICS_URL ??
|
|
273
|
-
"
|
|
636
|
+
"http://localhost:8099"
|
|
274
637
|
).replace(/\/$/, "");
|
|
275
638
|
|
|
276
|
-
|
|
639
|
+
ENABLED = (api.pluginConfig?.enabled as boolean | undefined) ?? true;
|
|
640
|
+
REDACTION_MODE = (api.pluginConfig?.redactionMode as typeof REDACTION_MODE | undefined) ?? "strict";
|
|
641
|
+
EXPORTED_TOOL_NAMES = (api.pluginConfig?.exportedToolNames as typeof EXPORTED_TOOL_NAMES | undefined) ?? "blocklist";
|
|
642
|
+
REDACT_TOOL_NAMES = (api.pluginConfig?.redactToolNames as string[] | undefined) ?? [];
|
|
643
|
+
FLUSH_INTERVAL_MS = ((api.pluginConfig?.flushIntervalSeconds as number | undefined) ?? 10) * 1000;
|
|
644
|
+
MAX_BATCH_SIZE = (api.pluginConfig?.maxBatchSize as number | undefined) ?? 100;
|
|
645
|
+
MAX_QUEUE_SIZE = (api.pluginConfig?.maxQueueSize as number | undefined) ?? 10_000;
|
|
646
|
+
RETRY_MAX_ATTEMPTS = (api.pluginConfig?.retryMaxAttempts as number | undefined) ?? 5;
|
|
647
|
+
COMPRESS_PAYLOADS = (api.pluginConfig?.compressPayloads as boolean | undefined) ?? false;
|
|
648
|
+
|
|
649
|
+
if (REDACTION_MODE === "debug") {
|
|
650
|
+
DEBUG_EXPIRES_AT = Date.now() + 60 * 60 * 1000;
|
|
651
|
+
console.log("AgentMetrics: ⚠ debug redaction mode active - expires in 1 hour");
|
|
652
|
+
}
|
|
653
|
+
|
|
277
654
|
if (typeof api.registerAutoEnableProbe === "function") {
|
|
278
|
-
api.registerAutoEnableProbe(() => !!API_KEY);
|
|
655
|
+
api.registerAutoEnableProbe(() => !!API_KEY && ENABLED);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!ENABLED) {
|
|
659
|
+
console.log("\n AgentMetrics: disabled via config (metrics.enabled: false)\n");
|
|
660
|
+
return;
|
|
279
661
|
}
|
|
280
662
|
|
|
281
663
|
if (!API_KEY) {
|
|
282
664
|
console.log(
|
|
283
665
|
"\n AgentMetrics: no API key found.\n" +
|
|
284
666
|
" Your agent runs are not being tracked.\n" +
|
|
285
|
-
"
|
|
667
|
+
" Start AgentMetrics (see README) and set AGENTMETRICS_API_KEY.\n" +
|
|
668
|
+
" AGENTMETRICS_URL defaults to http://localhost:8099.\n",
|
|
286
669
|
);
|
|
287
670
|
return;
|
|
288
671
|
}
|
|
289
672
|
|
|
673
|
+
try {
|
|
674
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd();
|
|
675
|
+
WAL_PATH = join(home, ".config", "openclaw", "agentmetrics-wal.jsonl");
|
|
676
|
+
mkdirSync(dirname(WAL_PATH), { recursive: true });
|
|
677
|
+
try { chmodSync(dirname(WAL_PATH), 0o700); } catch {}
|
|
678
|
+
_walRecover();
|
|
679
|
+
} catch {
|
|
680
|
+
WAL_PATH = null; // WAL unavailable - queue still works in-memory
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (_flushTimer) clearInterval(_flushTimer);
|
|
684
|
+
_flushTimer = setInterval(() => { _flush().catch(() => {}); }, FLUSH_INTERVAL_MS);
|
|
685
|
+
// Allow process to exit even with an active timer
|
|
686
|
+
if (typeof (_flushTimer as unknown as { unref?: () => void }).unref === "function") {
|
|
687
|
+
(_flushTimer as unknown as { unref: () => void }).unref();
|
|
688
|
+
}
|
|
689
|
+
|
|
290
690
|
console.log(
|
|
291
|
-
`\n AgentMetrics active
|
|
292
|
-
`
|
|
691
|
+
`\n AgentMetrics active - sending data to ${BASE_URL}\n` +
|
|
692
|
+
` Queue: max ${MAX_QUEUE_SIZE} events, batch ${MAX_BATCH_SIZE}, flush every ${FLUSH_INTERVAL_MS / 1000}s\n` +
|
|
693
|
+
` View your dashboard → http://localhost:3099\n`,
|
|
293
694
|
);
|
|
294
695
|
|
|
295
|
-
// CLI: openclaw agentmetrics status
|
|
296
696
|
if (typeof api.registerCli === "function") {
|
|
297
697
|
api.registerCli({
|
|
298
698
|
name: "agentmetrics",
|
|
@@ -300,39 +700,182 @@ const plugin = {
|
|
|
300
700
|
commands: [
|
|
301
701
|
{
|
|
302
702
|
name: "status",
|
|
303
|
-
description: "Show current
|
|
703
|
+
description: "Show current plugin status, config, delivery counters, and circuit breaker state",
|
|
304
704
|
handler() {
|
|
305
705
|
const keyPreview = API_KEY
|
|
306
706
|
? `${API_KEY.slice(0, 8)}...${API_KEY.slice(-4)}`
|
|
307
707
|
: "(not set)";
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
console.log(
|
|
708
|
+
const mode = _activeMode();
|
|
709
|
+
const cbInfo = _cbState === "open" && _cbOpenAt
|
|
710
|
+
? ` (opens probe at ${new Date(_cbOpenAt + CB_PROBE_MS).toLocaleTimeString()})`
|
|
711
|
+
: "";
|
|
712
|
+
console.log("AgentMetrics - status");
|
|
713
|
+
console.log(` API key : ${keyPreview}`);
|
|
714
|
+
console.log(` Endpoint : ${BASE_URL}`);
|
|
715
|
+
console.log(` Redaction : ${mode}${mode === "debug" && DEBUG_EXPIRES_AT ? ` (expires ${new Date(DEBUG_EXPIRES_AT).toLocaleTimeString()})` : ""}`);
|
|
716
|
+
console.log(` Tool names : ${EXPORTED_TOOL_NAMES}`);
|
|
717
|
+
console.log(` Compress payloads: ${COMPRESS_PAYLOADS}`);
|
|
718
|
+
console.log(` Flush interval : ${FLUSH_INTERVAL_MS / 1000}s`);
|
|
719
|
+
console.log(` WAL path : ${WAL_PATH ?? "(unavailable)"}`);
|
|
720
|
+
console.log("");
|
|
721
|
+
console.log(` Circuit breaker : ${_cbState}${cbInfo}`);
|
|
722
|
+
console.log(` Queue depth : ${_queue.length} / ${MAX_QUEUE_SIZE}`);
|
|
723
|
+
console.log(` DLQ depth : ${_dlq.length}`);
|
|
724
|
+
console.log(` Sessions tracked : ${sessions.size}`);
|
|
725
|
+
console.log(` Runs in flight : ${runs.size}`);
|
|
726
|
+
console.log("");
|
|
727
|
+
console.log(` Sent : ${_metrics.sent}`);
|
|
728
|
+
console.log(` Failed : ${_metrics.failed}`);
|
|
729
|
+
console.log(` Dropped (overflow): ${_metrics.dropped}`);
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "flush",
|
|
734
|
+
description: "Force-flush all queued events immediately",
|
|
735
|
+
async handler() {
|
|
736
|
+
const before = _queue.length;
|
|
737
|
+
if (before === 0) {
|
|
738
|
+
console.log("AgentMetrics flush - queue empty, nothing to flush");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (_cbIsOpen()) {
|
|
742
|
+
console.log(`AgentMetrics flush - circuit breaker is ${_cbState}, skipping`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
console.log(`AgentMetrics flush - flushing ${before} event(s)…`);
|
|
746
|
+
// Drain the entire queue in batches
|
|
747
|
+
while (_queue.length > 0 && !_cbIsOpen()) {
|
|
748
|
+
await _flush();
|
|
749
|
+
}
|
|
750
|
+
console.log(` Done - sent: ${_metrics.sent}, failed: ${_metrics.failed}, queued: ${_queue.length}`);
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "tail",
|
|
755
|
+
description: "Show recent in-flight run state",
|
|
756
|
+
handler() {
|
|
757
|
+
if (runs.size === 0 && sessions.size === 0) {
|
|
758
|
+
console.log("AgentMetrics tail - no active sessions or runs");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
console.log("AgentMetrics tail - active state");
|
|
762
|
+
if (sessions.size > 0) {
|
|
763
|
+
console.log(` Sessions (${sessions.size}):`);
|
|
764
|
+
for (const [key, s] of sessions) {
|
|
765
|
+
console.log(` ${key.slice(0, 12)}… agent=${s.agentId} compactions=${s.compactions} resets=${s.resets}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (runs.size > 0) {
|
|
769
|
+
console.log(` Runs in flight (${runs.size}):`);
|
|
770
|
+
for (const [id, r] of runs) {
|
|
771
|
+
const age = Math.round((Date.now() - r.startedAt) / 1000);
|
|
772
|
+
console.log(` ${id.slice(0, 12)}… llm=${r.llmCalls} tools=${r.toolCalls} ${age}s elapsed`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
name: "test",
|
|
779
|
+
description: "Send a test event and verify end-to-end delivery",
|
|
780
|
+
async handler() {
|
|
781
|
+
if (!API_KEY) {
|
|
782
|
+
console.log("AgentMetrics test - no API key set, cannot send");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
console.log(`AgentMetrics test - sending to ${BASE_URL}…`);
|
|
786
|
+
try {
|
|
787
|
+
const resp = await fetch(`${BASE_URL}/v1/events`, {
|
|
788
|
+
method: "POST",
|
|
789
|
+
headers: {
|
|
790
|
+
"Content-Type": "application/json",
|
|
791
|
+
"Authorization": `Bearer ${API_KEY}`,
|
|
792
|
+
},
|
|
793
|
+
body: JSON.stringify({
|
|
794
|
+
event_id: randomUUID(),
|
|
795
|
+
trace_id: randomUUID(),
|
|
796
|
+
agent_id: "agentmetrics-test",
|
|
797
|
+
platform: "openclaw",
|
|
798
|
+
event_name: "agent_end",
|
|
799
|
+
ts: Date.now(),
|
|
800
|
+
status: "success",
|
|
801
|
+
duration_ms: 1,
|
|
802
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
803
|
+
}),
|
|
804
|
+
});
|
|
805
|
+
if (resp.ok) {
|
|
806
|
+
console.log(` ✓ Delivered - HTTP ${resp.status}`);
|
|
807
|
+
} else {
|
|
808
|
+
const body = await resp.text().catch(() => "");
|
|
809
|
+
console.log(` ✗ Failed - HTTP ${resp.status} ${body.slice(0, 200)}`);
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.log(` ✗ Failed - ${err}`);
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
name: "redaction-check",
|
|
818
|
+
description: "Show what the current redaction policy does to a sample payload",
|
|
819
|
+
handler() {
|
|
820
|
+
const mode = _activeMode();
|
|
821
|
+
const sampleError = "Connection failed: Bearer sk-ant-abc123exampletoken and api_key=supersecret";
|
|
822
|
+
const sampleTools = ["bash", "read_file", "write_file", "send_email"];
|
|
823
|
+
const redactedError = _redactError(sampleError);
|
|
824
|
+
const redactedTools = _redactToolNames(sampleTools);
|
|
825
|
+
console.log("AgentMetrics redaction-check");
|
|
826
|
+
console.log(` Mode : ${mode}`);
|
|
827
|
+
console.log(` Tool export : ${EXPORTED_TOOL_NAMES}`);
|
|
828
|
+
console.log(` Blocked names : ${REDACT_TOOL_NAMES.length ? REDACT_TOOL_NAMES.join(", ") : "(none)"}`);
|
|
829
|
+
console.log("");
|
|
830
|
+
console.log(" Error sample:");
|
|
831
|
+
console.log(` Input : ${sampleError}`);
|
|
832
|
+
console.log(` Output : ${redactedError}`);
|
|
833
|
+
console.log("");
|
|
834
|
+
console.log(" Tool name sample:");
|
|
835
|
+
sampleTools.forEach((t, i) =>
|
|
836
|
+
console.log(` ${t.padEnd(16)} → ${redactedTools[i]}`),
|
|
837
|
+
);
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
name: "drain",
|
|
842
|
+
description: "Retry all events in the dead-letter queue",
|
|
843
|
+
async handler() {
|
|
844
|
+
if (_dlq.length === 0) {
|
|
845
|
+
console.log("AgentMetrics drain - DLQ is empty");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const count = _dlq.length;
|
|
849
|
+
console.log(`AgentMetrics drain - retrying ${count} DLQ event(s)…`);
|
|
850
|
+
// Reset attempts and move DLQ back to main queue
|
|
851
|
+
const items = _dlq.splice(0, _dlq.length);
|
|
852
|
+
for (const item of items) {
|
|
853
|
+
item.attempt = 0;
|
|
854
|
+
_queue.push(item);
|
|
855
|
+
}
|
|
856
|
+
// Flush immediately
|
|
857
|
+
while (_queue.length > 0 && !_cbIsOpen()) {
|
|
858
|
+
await _flush();
|
|
859
|
+
}
|
|
860
|
+
console.log(` Done - sent: ${_metrics.sent}, failed: ${_metrics.failed}, remaining DLQ: ${_dlq.length}`);
|
|
313
861
|
},
|
|
314
862
|
},
|
|
315
863
|
],
|
|
316
864
|
});
|
|
317
865
|
}
|
|
318
866
|
|
|
319
|
-
// ── gateway_start ─────────────────────────────────────────────────────
|
|
320
|
-
// Activity only — gateway events are infrastructure, not agent runs.
|
|
321
|
-
// We do NOT call send() here to avoid polluting the agents list.
|
|
322
867
|
api.on("gateway_start", (event: GatewayStartEvent, _ctx: GatewayContext) => {
|
|
323
868
|
sendActivity("gateway_start", "openclaw-gateway", undefined, undefined, {
|
|
324
869
|
port: event.port,
|
|
325
870
|
});
|
|
326
871
|
});
|
|
327
872
|
|
|
328
|
-
// ── gateway_stop ──────────────────────────────────────────────────────
|
|
329
873
|
api.on("gateway_stop", (event: GatewayStopEvent, _ctx: GatewayContext) => {
|
|
330
874
|
sendActivity("gateway_stop", "openclaw-gateway", undefined, undefined, {
|
|
331
875
|
reason: event.reason,
|
|
332
876
|
});
|
|
333
877
|
});
|
|
334
878
|
|
|
335
|
-
// ── session_start ─────────────────────────────────────────────────────
|
|
336
879
|
api.on("session_start", (event: SessionStartEvent, ctx: SessionContext) => {
|
|
337
880
|
const key = event.sessionKey ?? event.sessionId;
|
|
338
881
|
if (!key || sessions.has(key)) return;
|
|
@@ -343,11 +886,17 @@ const plugin = {
|
|
|
343
886
|
startedAt: Date.now(),
|
|
344
887
|
compactions: 0,
|
|
345
888
|
resets: 0,
|
|
889
|
+
runCount: 0,
|
|
890
|
+
totalInputTokens: 0,
|
|
891
|
+
totalOutputTokens: 0,
|
|
892
|
+
totalCacheReadTokens: 0,
|
|
893
|
+
totalCacheWriteTokens: 0,
|
|
894
|
+
totalToolCalls: 0,
|
|
895
|
+
totalEstimatedCostUsd: 0,
|
|
896
|
+
totalDurationMs: 0,
|
|
346
897
|
});
|
|
347
898
|
});
|
|
348
899
|
|
|
349
|
-
// ── before_agent_start ────────────────────────────────────────────────
|
|
350
|
-
// Fires at the start of each run. Sends run_start for real-time UI.
|
|
351
900
|
api.on("before_agent_start", (event: BeforeAgentStartEvent, ctx: AgentContext) => {
|
|
352
901
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
353
902
|
const agentId = ctx.agentId ?? "openclaw-agent";
|
|
@@ -361,9 +910,6 @@ const plugin = {
|
|
|
361
910
|
});
|
|
362
911
|
});
|
|
363
912
|
|
|
364
|
-
// ── llm_input ─────────────────────────────────────────────────────────
|
|
365
|
-
// Fires before each LLM call. Sends llm_start for real-time "thinking"
|
|
366
|
-
// indicator. Also accumulates LLM call count and image count.
|
|
367
913
|
api.on("llm_input", (event: LlmInputEvent, ctx: AgentContext) => {
|
|
368
914
|
const { runId } = event;
|
|
369
915
|
if (!runId) return;
|
|
@@ -387,9 +933,6 @@ const plugin = {
|
|
|
387
933
|
});
|
|
388
934
|
});
|
|
389
935
|
|
|
390
|
-
// ── llm_output ────────────────────────────────────────────────────────
|
|
391
|
-
// Fires after each LLM response. Accumulates tokens and sends llm_end
|
|
392
|
-
// so the dashboard can show token counts updating in real time.
|
|
393
936
|
api.on("llm_output", (event: LlmOutputEvent, ctx: AgentContext) => {
|
|
394
937
|
const { runId } = event;
|
|
395
938
|
if (!runId) return;
|
|
@@ -421,9 +964,6 @@ const plugin = {
|
|
|
421
964
|
});
|
|
422
965
|
});
|
|
423
966
|
|
|
424
|
-
// ── before_tool_call ──────────────────────────────────────────────────
|
|
425
|
-
// Fires before each tool execution. Sends tool_start immediately so
|
|
426
|
-
// the live visualizer shows tool activity in real time.
|
|
427
967
|
api.on("before_tool_call", (event: BeforeToolCallEvent, ctx: ToolContext) => {
|
|
428
968
|
const runId = event.runId ?? ctx.runId;
|
|
429
969
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
@@ -434,9 +974,6 @@ const plugin = {
|
|
|
434
974
|
});
|
|
435
975
|
});
|
|
436
976
|
|
|
437
|
-
// ── after_tool_call ───────────────────────────────────────────────────
|
|
438
|
-
// Fires after every tool execution. Counts calls/errors and sends
|
|
439
|
-
// tool_end with outcome and duration.
|
|
440
977
|
api.on("after_tool_call", (event: AfterToolCallEvent, ctx: ToolContext) => {
|
|
441
978
|
const runId = event.runId ?? ctx.runId;
|
|
442
979
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
@@ -452,13 +989,12 @@ const plugin = {
|
|
|
452
989
|
}
|
|
453
990
|
|
|
454
991
|
sendActivity("tool_end", agentId, sessionKey, runId, {
|
|
455
|
-
tool_name: event.toolName,
|
|
992
|
+
tool_name: _redactToolName(event.toolName),
|
|
456
993
|
duration_ms: event.durationMs,
|
|
457
|
-
error: event.error
|
|
994
|
+
error: _redactError(event.error, true),
|
|
458
995
|
});
|
|
459
996
|
});
|
|
460
997
|
|
|
461
|
-
// ── subagent_spawning ─────────────────────────────────────────────────
|
|
462
998
|
api.on("subagent_spawning", (event: SubagentSpawningEvent, ctx: SubagentContext) => {
|
|
463
999
|
const runId = ctx.runId;
|
|
464
1000
|
const sessionKey = ctx.requesterSessionKey;
|
|
@@ -480,7 +1016,6 @@ const plugin = {
|
|
|
480
1016
|
});
|
|
481
1017
|
});
|
|
482
1018
|
|
|
483
|
-
// ── subagent_ended ────────────────────────────────────────────────────
|
|
484
1019
|
api.on("subagent_ended", (event: SubagentEndedEvent, ctx: SubagentContext) => {
|
|
485
1020
|
const runId = event.runId ?? ctx.runId;
|
|
486
1021
|
const sessionKey = ctx.requesterSessionKey;
|
|
@@ -497,11 +1032,10 @@ const plugin = {
|
|
|
497
1032
|
sendActivity("subagent_end", agentId, sessionKey, runId, {
|
|
498
1033
|
child_session_key: event.targetSessionKey,
|
|
499
1034
|
outcome: event.outcome,
|
|
500
|
-
error: event.error
|
|
1035
|
+
error: _redactError(event.error, true),
|
|
501
1036
|
});
|
|
502
1037
|
});
|
|
503
1038
|
|
|
504
|
-
// ── before_compaction ─────────────────────────────────────────────────
|
|
505
1039
|
api.on("before_compaction", (_event: CompactionEvent, ctx: AgentContext) => {
|
|
506
1040
|
const key = ctx.sessionKey ?? ctx.sessionId;
|
|
507
1041
|
const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
|
|
@@ -516,7 +1050,6 @@ const plugin = {
|
|
|
516
1050
|
sendActivity("compaction", agentId, key, ctx.runId);
|
|
517
1051
|
});
|
|
518
1052
|
|
|
519
|
-
// ── before_reset ──────────────────────────────────────────────────────
|
|
520
1053
|
api.on("before_reset", (event: ResetEvent, ctx: AgentContext) => {
|
|
521
1054
|
const key = ctx.sessionKey ?? ctx.sessionId;
|
|
522
1055
|
const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
|
|
@@ -531,9 +1064,6 @@ const plugin = {
|
|
|
531
1064
|
sendActivity("reset", agentId, key, ctx.runId, { reason: event.reason });
|
|
532
1065
|
});
|
|
533
1066
|
|
|
534
|
-
// ── agent_end ─────────────────────────────────────────────────────────
|
|
535
|
-
// Fires when the agent finishes a run. Sends run_end for real-time UI,
|
|
536
|
-
// then the full persisted event to /v1/events with all accumulated data.
|
|
537
1067
|
api.on("agent_end", (event: AgentEndEvent, ctx: AgentContext) => {
|
|
538
1068
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
539
1069
|
if (!sessionKey) return;
|
|
@@ -550,22 +1080,47 @@ const plugin = {
|
|
|
550
1080
|
(run?.cacheReadTokens ?? 0) +
|
|
551
1081
|
(run?.cacheWriteTokens ?? 0);
|
|
552
1082
|
|
|
553
|
-
|
|
554
|
-
const
|
|
1083
|
+
const durationMs = event.durationMs ?? (run ? Date.now() - run.startedAt : undefined);
|
|
1084
|
+
const redactedError = _redactError(event.error);
|
|
1085
|
+
|
|
1086
|
+
const estimatedCostUsd = _estimateCost(
|
|
1087
|
+
run?.model,
|
|
1088
|
+
run?.inputTokens ?? 0,
|
|
1089
|
+
run?.outputTokens ?? 0,
|
|
1090
|
+
run?.cacheReadTokens ?? 0,
|
|
1091
|
+
run?.cacheWriteTokens ?? 0,
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
// Accumulate into session-level totals
|
|
1095
|
+
if (session) {
|
|
1096
|
+
session.runCount += 1;
|
|
1097
|
+
session.totalInputTokens += run?.inputTokens ?? 0;
|
|
1098
|
+
session.totalOutputTokens += run?.outputTokens ?? 0;
|
|
1099
|
+
session.totalCacheReadTokens += run?.cacheReadTokens ?? 0;
|
|
1100
|
+
session.totalCacheWriteTokens += run?.cacheWriteTokens ?? 0;
|
|
1101
|
+
session.totalToolCalls += run?.toolCalls ?? 0;
|
|
1102
|
+
session.totalEstimatedCostUsd += estimatedCostUsd ?? 0;
|
|
1103
|
+
session.totalDurationMs += durationMs ?? 0;
|
|
1104
|
+
}
|
|
555
1105
|
|
|
556
|
-
// Real-time: notify dashboard the run finished
|
|
557
1106
|
sendActivity("run_end", agentId, sessionKey, ctx.runId, {
|
|
558
1107
|
status: event.success ? "success" : "failed",
|
|
559
1108
|
duration_ms: durationMs,
|
|
560
1109
|
total_tokens: totalTokens || undefined,
|
|
561
1110
|
tool_calls: run?.toolCalls,
|
|
562
|
-
error: event.error
|
|
1111
|
+
error: _redactError(event.error, true),
|
|
563
1112
|
});
|
|
564
1113
|
|
|
565
|
-
// Persistent: full summary stored in DB
|
|
566
1114
|
send({
|
|
567
|
-
|
|
568
|
-
|
|
1115
|
+
event_id: randomUUID(),
|
|
1116
|
+
trace_id: session?.traceId ?? randomUUID(),
|
|
1117
|
+
session_id: sessionKey,
|
|
1118
|
+
run_id: ctx.runId,
|
|
1119
|
+
agent_id: agentId,
|
|
1120
|
+
platform: "openclaw",
|
|
1121
|
+
event_name: "agent_end",
|
|
1122
|
+
ts: Date.now(),
|
|
1123
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
569
1124
|
status: event.success ? "success" : "failed",
|
|
570
1125
|
duration_ms: durationMs,
|
|
571
1126
|
model: run?.model,
|
|
@@ -577,8 +1132,10 @@ const plugin = {
|
|
|
577
1132
|
total_tokens: totalTokens || undefined,
|
|
578
1133
|
tool_calls: run?.toolCalls ?? 0,
|
|
579
1134
|
tool_errors: run?.toolErrors ?? 0,
|
|
580
|
-
|
|
581
|
-
|
|
1135
|
+
tool_names: run ? _redactToolNames([...run.toolNames]) : [],
|
|
1136
|
+
step_count: event.messages?.length,
|
|
1137
|
+
...(estimatedCostUsd != null ? { estimated_cost_usd: estimatedCostUsd } : {}),
|
|
1138
|
+
...(redactedError ? { error: redactedError } : {}),
|
|
582
1139
|
metadata: {
|
|
583
1140
|
llm_calls: run?.llmCalls ?? 0,
|
|
584
1141
|
images_count: run?.imagesCount ?? 0,
|
|
@@ -586,16 +1143,52 @@ const plugin = {
|
|
|
586
1143
|
subagent_errors: run?.subagentErrors ?? 0,
|
|
587
1144
|
compactions: session?.compactions ?? 0,
|
|
588
1145
|
resets: session?.resets ?? 0,
|
|
589
|
-
tool_names: run ? [...run.toolNames] : [],
|
|
590
1146
|
},
|
|
591
1147
|
});
|
|
592
1148
|
|
|
593
1149
|
if (ctx.runId) runs.delete(ctx.runId);
|
|
594
1150
|
});
|
|
595
1151
|
|
|
596
|
-
// ── session_end ───────────────────────────────────────────────────────
|
|
597
1152
|
api.on("session_end", (event: SessionEndEvent, _ctx: SessionContext) => {
|
|
598
|
-
const key
|
|
1153
|
+
const key = event.sessionKey ?? event.sessionId;
|
|
1154
|
+
const session = sessions.get(key);
|
|
1155
|
+
|
|
1156
|
+
if (session && session.runCount > 0) {
|
|
1157
|
+
const sessionDurationMs = event.durationMs ?? (Date.now() - session.startedAt);
|
|
1158
|
+
const totalTokens =
|
|
1159
|
+
session.totalInputTokens + session.totalOutputTokens +
|
|
1160
|
+
session.totalCacheReadTokens + session.totalCacheWriteTokens;
|
|
1161
|
+
|
|
1162
|
+
send({
|
|
1163
|
+
event_id: randomUUID(),
|
|
1164
|
+
trace_id: session.traceId,
|
|
1165
|
+
session_id: key,
|
|
1166
|
+
agent_id: session.agentId,
|
|
1167
|
+
platform: "openclaw",
|
|
1168
|
+
event_name: "session_metrics",
|
|
1169
|
+
ts: Date.now(),
|
|
1170
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
1171
|
+
status: "success",
|
|
1172
|
+
duration_ms: sessionDurationMs,
|
|
1173
|
+
input_tokens: session.totalInputTokens,
|
|
1174
|
+
output_tokens: session.totalOutputTokens,
|
|
1175
|
+
cache_read_tokens: session.totalCacheReadTokens,
|
|
1176
|
+
cache_write_tokens: session.totalCacheWriteTokens,
|
|
1177
|
+
total_tokens: totalTokens || undefined,
|
|
1178
|
+
tool_calls: session.totalToolCalls,
|
|
1179
|
+
...(session.totalEstimatedCostUsd > 0
|
|
1180
|
+
? { estimated_cost_usd: session.totalEstimatedCostUsd }
|
|
1181
|
+
: {}),
|
|
1182
|
+
metadata: {
|
|
1183
|
+
run_count: session.runCount,
|
|
1184
|
+
compactions: session.compactions,
|
|
1185
|
+
resets: session.resets,
|
|
1186
|
+
message_count: event.messageCount,
|
|
1187
|
+
reason: event.reason,
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
599
1192
|
sessions.delete(key);
|
|
600
1193
|
});
|
|
601
1194
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "agentmetrics",
|
|
3
3
|
"name": "AgentMetrics",
|
|
4
|
-
"description": "360-degree observability for every OpenClaw agent
|
|
4
|
+
"description": "360-degree observability for every OpenClaw agent - tokens, tools, latency, cost, subagents, context health, and reliability.",
|
|
5
5
|
"configSchema": {
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
@@ -11,7 +11,55 @@
|
|
|
11
11
|
},
|
|
12
12
|
"endpoint": {
|
|
13
13
|
"type": "string",
|
|
14
|
-
"description": "Custom API endpoint (default:
|
|
14
|
+
"description": "Custom API endpoint (default: http://localhost:8099)"
|
|
15
|
+
},
|
|
16
|
+
"enabled": {
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"description": "Master toggle - set to false to disable all telemetry without uninstalling (default: true)",
|
|
19
|
+
"default": true
|
|
20
|
+
},
|
|
21
|
+
"flushIntervalSeconds": {
|
|
22
|
+
"type": "integer",
|
|
23
|
+
"description": "How often to flush the event batch to the API in seconds (default: 10)",
|
|
24
|
+
"default": 10
|
|
25
|
+
},
|
|
26
|
+
"maxBatchSize": {
|
|
27
|
+
"type": "integer",
|
|
28
|
+
"description": "Maximum number of events per batch request (default: 100)",
|
|
29
|
+
"default": 100
|
|
30
|
+
},
|
|
31
|
+
"maxQueueSize": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"description": "Maximum number of events to hold in the local queue before dropping (FIFO, default: 10000)",
|
|
34
|
+
"default": 10000
|
|
35
|
+
},
|
|
36
|
+
"retryMaxAttempts": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"description": "Maximum retry attempts per event batch before moving to dead-letter queue (default: 5)",
|
|
39
|
+
"default": 5
|
|
40
|
+
},
|
|
41
|
+
"redactionMode": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": ["strict", "moderate", "debug"],
|
|
44
|
+
"description": "Redaction policy: strict = all sensitive fields scrubbed (default); moderate = errors and secrets only; debug = opt-in full detail, auto-expires after 1h",
|
|
45
|
+
"default": "strict"
|
|
46
|
+
},
|
|
47
|
+
"toolNameExport": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": ["allowlist", "blocklist", "hash", "off"],
|
|
50
|
+
"description": "How tool names are exported: blocklist = export all except redactToolNames (default); allowlist = export only listed; hash = pseudonymise; off = never export",
|
|
51
|
+
"default": "blocklist"
|
|
52
|
+
},
|
|
53
|
+
"redactToolNames": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": { "type": "string" },
|
|
56
|
+
"description": "Tool names to always redact regardless of toolNameExport mode (default: [])",
|
|
57
|
+
"default": []
|
|
58
|
+
},
|
|
59
|
+
"compressPayloads": {
|
|
60
|
+
"type": "boolean",
|
|
61
|
+
"description": "Gzip-compress batch payloads before sending (requires server-side Content-Encoding: gzip support, default: false)",
|
|
62
|
+
"default": false
|
|
15
63
|
}
|
|
16
64
|
},
|
|
17
65
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "agentmetrics-openclaw",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "AgentMetrics observability plugin for OpenClaw agents",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"homepage": "https://agentmetrics
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/andalabx/agentmetrics
|
|
11
|
-
},
|
|
12
|
-
"keywords": [
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "agentmetrics-openclaw",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "AgentMetrics observability plugin for OpenClaw agents",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/andalabx/agentmetrics",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/andalabx/agentmetrics.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"agentmetrics",
|
|
15
|
+
"observability",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"monitoring"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"openclaw": ">=2026.3.2"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"openclaw": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"openclaw": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"openclaw.plugin.json",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
}
|
|
40
|
+
}
|