agentmetrics-openclaw 0.2.1 → 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 CHANGED
@@ -1,76 +1,118 @@
1
1
  # agentmetrics-openclaw
2
2
 
3
- Full observability for every OpenClaw agent. Tokens, tools, latency, cost, and reliability — automatically.
3
+ [![npm](https://img.shields.io/npm/v/agentmetrics-openclaw?color=6366f1&label=npm&logo=npm&logoColor=white)](https://www.npmjs.com/package/agentmetrics-openclaw)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-6366f1)](../LICENSE)
4
5
 
5
- ## What it captures
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.
6
7
 
7
- Every agent run produces one event in AgentMetrics with:
8
+ ---
8
9
 
9
- | Field | Source |
10
- |---|---|
11
- | Status (success / failed) | `agent_end.success` |
12
- | Duration | `agent_end.durationMs` |
13
- | Model & provider | `llm_output` |
14
- | Input tokens | `llm_output.usage.input` |
15
- | Output tokens | `llm_output.usage.output` |
16
- | Cache read tokens | `llm_output.usage.cacheRead` |
17
- | Cache write tokens | `llm_output.usage.cacheWrite` |
18
- | Tool call count | `after_tool_call` (counted) |
19
- | Tool error count | `after_tool_call.error` (counted) |
20
- | Step count | `agent_end.messages.length` |
21
- | Error message | `agent_end.error` |
22
-
23
- All token buckets are accumulated across the full run — multi-turn, multi-LLM-call runs are handled correctly.
10
+ ## Requirements
24
11
 
25
- ## Setup
12
+ - OpenClaw 2026.3.2 or later
13
+ - Node.js 22 or later
14
+ - A running AgentMetrics server (see the [main README](../../../README.md) for setup)
15
+
16
+ ---
26
17
 
27
- ### 1. Install
18
+ ## Install
28
19
 
29
- ```sh
20
+ ```bash
30
21
  openclaw plugins install agentmetrics-openclaw
31
22
  ```
32
23
 
33
- ### 2. Set your API key
24
+ ---
25
+
26
+ ## Setup
27
+
28
+ **1. Start AgentMetrics** (if not already running)
34
29
 
35
- ```sh
36
- export AGENTMETRICS_API_KEY=am_your_key_here
30
+ ```bash
31
+ # Docker
32
+ docker compose up
33
+
34
+ # Or Python CLI
35
+ pip install agentmetrics
36
+ agentmetrics dashboard
37
37
  ```
38
38
 
39
- Add this to your shell profile (`.bashrc`, `.zshrc`, etc.) to persist it.
39
+ **2. Set the server URL** (if not running on localhost)
40
40
 
41
- ### 3. Restart the gateway
41
+ ```bash
42
+ # macOS / Linux, permanent
43
+ echo 'export AGENTMETRICS_BASE_URL=http://your-server:8099' >> ~/.bashrc && source ~/.bashrc
42
44
 
43
- ```sh
44
- openclaw gateway restart
45
+ # Windows (PowerShell)
46
+ $Env:AGENTMETRICS_BASE_URL = "http://your-server:8099"
45
47
  ```
46
48
 
47
- That's it. Sessions appear in your [AgentMetrics dashboard](https://agentmetrics.dev) within seconds.
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)
48
52
 
49
- ## Configuration (optional)
53
+ ```bash
54
+ openclaw config set plugins.allow '["agentmetrics"]'
55
+ ```
50
56
 
51
- You can also set credentials in `openclaw.yml` instead of environment variables:
57
+ **4. Restart the gateway**
52
58
 
53
- ```yaml
54
- plugins:
55
- agentmetrics:
56
- apiKey: am_your_key_here
57
- endpoint: https://api.agentmetrics.dev # optional
59
+ ```bash
60
+ openclaw gateway restart
58
61
  ```
59
62
 
60
- ## Auto-enable
63
+ **5. Verify**
64
+
65
+ ```bash
66
+ openclaw plugins list
67
+ # agentmetrics loaded
68
+ ```
61
69
 
62
- If `AGENTMETRICS_API_KEY` is present in your environment, the plugin enables itself automatically — no manual `openclaw plugins enable agentmetrics` needed.
70
+ **6. Set your agent name** (recommended)
63
71
 
64
- ## CLI
72
+ In your agent's `openclaw.json`:
65
73
 
66
- ```sh
67
- openclaw agentmetrics status
74
+ ```json
75
+ {
76
+ "name": "my-agent"
77
+ }
68
78
  ```
69
79
 
70
- Shows the active API key, endpoint, and current session/run counts.
80
+ The `name` field becomes the agent ID in your dashboard. Give each agent a distinct name.
81
+
82
+ ---
83
+
84
+ ## What gets tracked
85
+
86
+ Every agent session reports automatically:
87
+
88
+ | Signal | Detail |
89
+ |---|---|
90
+ | **Cost** | Computed from token counts and model pricing |
91
+ | **Latency** | Wall-clock duration per run |
92
+ | **Tokens** | Input, output, cache read, cache write |
93
+ | **Tools** | Call count, errors, per-tool duration |
94
+ | **Subagents** | Spawned count, error count |
95
+ | **Context health** | Compaction count, reset count |
96
+ | **Reliability** | Success/failure, full error message |
97
+
98
+ ---
99
+
100
+ ## Troubleshooting
101
+
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.
104
+
105
+ **"manifest id does not match package name" warning**
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`.
107
+
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`
111
+ 3. Restart the gateway after any env var change
112
+ 4. Confirm your `openclaw.json` has a `name` field
113
+
114
+ ---
71
115
 
72
- ## Links
116
+ ## License
73
117
 
74
- - Dashboard: [agentmetrics.dev](https://agentmetrics.dev)
75
- - Docs: [agentmetrics.dev/docs](https://agentmetrics.dev/docs)
76
- - GitHub: [github.com/andausman/agentmetrics](https://github.com/andausman/agentmetrics)
118
+ [MIT](../LICENSE)
package/index.ts CHANGED
@@ -1,10 +1,111 @@
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
- // Resolved once in register() — pluginConfig overrides env vars
4
54
  let API_KEY: string | undefined;
5
55
  let BASE_URL: string;
6
56
 
7
- // ─── Types (sourced from openclaw/src/plugins/hook-types.ts) ──────────────────
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
+
97
+ interface PluginApi {
98
+ config: Record<string, unknown>;
99
+ pluginConfig?: Record<string, unknown>;
100
+ registerAutoEnableProbe?: (probe: () => boolean) => void;
101
+ registerCli?: (registrar: {
102
+ name: string;
103
+ description: string;
104
+ commands: Array<{ name: string; description: string; handler: () => void | Promise<void> }>;
105
+ }) => void;
106
+ on: (hookName: string, handler: (...args: unknown[]) => void) => void;
107
+ }
108
+
8
109
 
9
110
  type AgentContext = {
10
111
  runId?: string;
@@ -139,7 +240,6 @@ type GatewayStartEvent = { port: number };
139
240
  type GatewayStopEvent = { reason?: string };
140
241
  type GatewayContext = { port?: number };
141
242
 
142
- // ─── Per-session and per-run state ────────────────────────────────────────────
143
243
 
144
244
  interface SessionMeta {
145
245
  traceId: string;
@@ -147,6 +247,15 @@ interface SessionMeta {
147
247
  startedAt: number;
148
248
  compactions: number;
149
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;
150
259
  }
151
260
 
152
261
  interface RunMeta {
@@ -164,42 +273,201 @@ interface RunMeta {
164
273
  model?: string;
165
274
  provider?: string;
166
275
  sessionKey?: string;
276
+ startedAt: number;
167
277
  }
168
278
 
169
279
  const sessions = new Map<string, SessionMeta>();
170
280
  const runs = new Map<string, RunMeta>();
171
281
 
172
- // ─── HTTP helpers ─────────────────────────────────────────────────────────────
173
282
 
174
- /** Send a completed run summary to /v1/events (persisted to DB). */
175
- async function send(payload: Record<string, unknown>): Promise<void> {
176
- if (!API_KEY) return;
283
+ function _walAppend(payload: Record<string, unknown>): void {
284
+ if (!WAL_PATH) return;
177
285
  try {
178
- await fetch(`${BASE_URL}/v1/events`, {
179
- method: "POST",
180
- headers: {
181
- "Content-Type": "application/json",
182
- "Authorization": `Bearer ${API_KEY}`,
183
- },
184
- body: JSON.stringify(payload),
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; }
185
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
+ }
186
415
  } catch {
187
- // Never crash the agent on observability failure
416
+ _cbOnFailure();
417
+ _requeue(batch);
188
418
  }
189
419
  }
190
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
+ }
443
+ }
444
+ }
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
+
191
459
  /**
192
460
  * Send a real-time intermediate event to /v1/activity (in-memory, streamed
193
- * to dashboard via SSE). Fire-and-forget no retry, no crash on failure.
461
+ * to dashboard via SSE). Fire-and-forget - no retry, no crash on failure.
194
462
  */
195
463
  function sendActivity(
196
- type: string,
197
- agentId: string,
198
- sessionKey: string | undefined,
199
- runId: string | undefined,
200
- data?: Record<string, unknown>,
464
+ type: string,
465
+ agentId: string,
466
+ sessionKey: string | undefined,
467
+ runId: string | undefined,
468
+ data?: Record<string, unknown>,
201
469
  ): void {
202
- if (!API_KEY) return;
470
+ if (!API_KEY || !ENABLED) return;
203
471
  const payload = JSON.stringify({
204
472
  type,
205
473
  agent_id: agentId,
@@ -226,15 +494,77 @@ function emptyRun(sessionKey?: string): RunMeta {
226
494
  toolCalls: 0, toolErrors: 0, toolNames: new Set(),
227
495
  subagentsSpawned: 0, subagentErrors: 0,
228
496
  sessionKey,
497
+ startedAt: Date.now(),
229
498
  };
230
499
  }
231
500
 
232
- // ─── Plugin ───────────────────────────────────────────────────────────────────
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
+
233
563
 
234
564
  const plugin = {
235
565
  id: "agentmetrics",
236
566
  name: "AgentMetrics",
237
- description: "360-degree observability for every OpenClaw agent real-time streaming, tokens, tools, latency, cost, subagents, and reliability.",
567
+ description: "360-degree observability for every OpenClaw agent - real-time streaming, tokens, tools, latency, cost, subagents, and reliability.",
238
568
  configSchema: {
239
569
  type: "object",
240
570
  properties: {
@@ -244,29 +574,125 @@ const plugin = {
244
574
  },
245
575
  endpoint: {
246
576
  type: "string",
247
- description: "Custom API endpoint (default: https://api.agentmetrics.dev)",
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)",
248
617
  },
249
618
  },
250
619
  additionalProperties: false,
251
620
  } as const,
252
621
 
253
- register(api: any) {
254
- // Config: pluginConfig > env vars
255
- API_KEY = api.config?.apiKey ?? process.env.AGENTMETRICS_API_KEY;
622
+ register(api: PluginApi) {
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
+
632
+ API_KEY = (api.pluginConfig?.apiKey as string | undefined) ?? process.env.AGENTMETRICS_API_KEY;
256
633
  BASE_URL = (
257
- api.config?.endpoint ??
634
+ (api.pluginConfig?.endpoint as string | undefined) ??
258
635
  process.env.AGENTMETRICS_URL ??
259
- "https://api.agentmetrics.dev"
636
+ "http://localhost:8099"
260
637
  ).replace(/\/$/, "");
261
638
 
262
- // Auto-enable when a key is available (env var or plugin config)
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
+
263
654
  if (typeof api.registerAutoEnableProbe === "function") {
264
- api.registerAutoEnableProbe(() => !!API_KEY);
655
+ api.registerAutoEnableProbe(() => !!API_KEY && ENABLED);
265
656
  }
266
657
 
267
- if (!API_KEY) return;
658
+ if (!ENABLED) {
659
+ console.log("\n AgentMetrics: disabled via config (metrics.enabled: false)\n");
660
+ return;
661
+ }
662
+
663
+ if (!API_KEY) {
664
+ console.log(
665
+ "\n AgentMetrics: no API key found.\n" +
666
+ " Your agent runs are not being tracked.\n" +
667
+ " Start AgentMetrics (see README) and set AGENTMETRICS_API_KEY.\n" +
668
+ " AGENTMETRICS_URL defaults to http://localhost:8099.\n",
669
+ );
670
+ return;
671
+ }
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
+
690
+ console.log(
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`,
694
+ );
268
695
 
269
- // CLI: openclaw agentmetrics status
270
696
  if (typeof api.registerCli === "function") {
271
697
  api.registerCli({
272
698
  name: "agentmetrics",
@@ -274,49 +700,182 @@ const plugin = {
274
700
  commands: [
275
701
  {
276
702
  name: "status",
277
- description: "Show current AgentMetrics plugin status",
703
+ description: "Show current plugin status, config, delivery counters, and circuit breaker state",
278
704
  handler() {
279
705
  const keyPreview = API_KEY
280
706
  ? `${API_KEY.slice(0, 8)}...${API_KEY.slice(-4)}`
281
707
  : "(not set)";
282
- console.log("AgentMetrics active");
283
- console.log(` API key : ${keyPreview}`);
284
- console.log(` Endpoint : ${BASE_URL}`);
285
- console.log(` Sessions : ${sessions.size} tracked`);
286
- console.log(` Runs : ${runs.size} in flight`);
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}`);
287
861
  },
288
862
  },
289
863
  ],
290
864
  });
291
865
  }
292
866
 
293
- // ── gateway_start ─────────────────────────────────────────────────────
294
867
  api.on("gateway_start", (event: GatewayStartEvent, _ctx: GatewayContext) => {
295
868
  sendActivity("gateway_start", "openclaw-gateway", undefined, undefined, {
296
869
  port: event.port,
297
870
  });
298
- send({
299
- trace_id: randomUUID(),
300
- agent_id: "openclaw-gateway",
301
- status: "success",
302
- metadata: { event_type: "gateway_start", port: event.port },
303
- });
304
871
  });
305
872
 
306
- // ── gateway_stop ──────────────────────────────────────────────────────
307
873
  api.on("gateway_stop", (event: GatewayStopEvent, _ctx: GatewayContext) => {
308
874
  sendActivity("gateway_stop", "openclaw-gateway", undefined, undefined, {
309
875
  reason: event.reason,
310
876
  });
311
- send({
312
- trace_id: randomUUID(),
313
- agent_id: "openclaw-gateway",
314
- status: "success",
315
- metadata: { event_type: "gateway_stop", reason: event.reason },
316
- });
317
877
  });
318
878
 
319
- // ── session_start ─────────────────────────────────────────────────────
320
879
  api.on("session_start", (event: SessionStartEvent, ctx: SessionContext) => {
321
880
  const key = event.sessionKey ?? event.sessionId;
322
881
  if (!key || sessions.has(key)) return;
@@ -327,11 +886,17 @@ const plugin = {
327
886
  startedAt: Date.now(),
328
887
  compactions: 0,
329
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,
330
897
  });
331
898
  });
332
899
 
333
- // ── before_agent_start ────────────────────────────────────────────────
334
- // Fires at the start of each run. Sends run_start for real-time UI.
335
900
  api.on("before_agent_start", (event: BeforeAgentStartEvent, ctx: AgentContext) => {
336
901
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
337
902
  const agentId = ctx.agentId ?? "openclaw-agent";
@@ -345,9 +910,6 @@ const plugin = {
345
910
  });
346
911
  });
347
912
 
348
- // ── llm_input ─────────────────────────────────────────────────────────
349
- // Fires before each LLM call. Sends llm_start for real-time "thinking"
350
- // indicator. Also accumulates LLM call count and image count.
351
913
  api.on("llm_input", (event: LlmInputEvent, ctx: AgentContext) => {
352
914
  const { runId } = event;
353
915
  if (!runId) return;
@@ -371,9 +933,6 @@ const plugin = {
371
933
  });
372
934
  });
373
935
 
374
- // ── llm_output ────────────────────────────────────────────────────────
375
- // Fires after each LLM response. Accumulates tokens and sends llm_end
376
- // so the dashboard can show token counts updating in real time.
377
936
  api.on("llm_output", (event: LlmOutputEvent, ctx: AgentContext) => {
378
937
  const { runId } = event;
379
938
  if (!runId) return;
@@ -400,15 +959,11 @@ const plugin = {
400
959
  output_tokens: event.usage?.output,
401
960
  cache_read: event.usage?.cacheRead,
402
961
  cache_write: event.usage?.cacheWrite,
403
- // Running totals for the dashboard
404
962
  total_input: run.inputTokens,
405
963
  total_output: run.outputTokens,
406
964
  });
407
965
  });
408
966
 
409
- // ── before_tool_call ──────────────────────────────────────────────────
410
- // Fires before each tool execution. Sends tool_start immediately so
411
- // the live visualizer shows tool activity in real time.
412
967
  api.on("before_tool_call", (event: BeforeToolCallEvent, ctx: ToolContext) => {
413
968
  const runId = event.runId ?? ctx.runId;
414
969
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
@@ -419,9 +974,6 @@ const plugin = {
419
974
  });
420
975
  });
421
976
 
422
- // ── after_tool_call ───────────────────────────────────────────────────
423
- // Fires after every tool execution. Counts calls/errors and sends
424
- // tool_end with outcome and duration.
425
977
  api.on("after_tool_call", (event: AfterToolCallEvent, ctx: ToolContext) => {
426
978
  const runId = event.runId ?? ctx.runId;
427
979
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
@@ -437,13 +989,12 @@ const plugin = {
437
989
  }
438
990
 
439
991
  sendActivity("tool_end", agentId, sessionKey, runId, {
440
- tool_name: event.toolName,
992
+ tool_name: _redactToolName(event.toolName),
441
993
  duration_ms: event.durationMs,
442
- error: event.error?.slice(0, 200),
994
+ error: _redactError(event.error, true),
443
995
  });
444
996
  });
445
997
 
446
- // ── subagent_spawning ─────────────────────────────────────────────────
447
998
  api.on("subagent_spawning", (event: SubagentSpawningEvent, ctx: SubagentContext) => {
448
999
  const runId = ctx.runId;
449
1000
  const sessionKey = ctx.requesterSessionKey;
@@ -465,7 +1016,6 @@ const plugin = {
465
1016
  });
466
1017
  });
467
1018
 
468
- // ── subagent_ended ────────────────────────────────────────────────────
469
1019
  api.on("subagent_ended", (event: SubagentEndedEvent, ctx: SubagentContext) => {
470
1020
  const runId = event.runId ?? ctx.runId;
471
1021
  const sessionKey = ctx.requesterSessionKey;
@@ -482,11 +1032,10 @@ const plugin = {
482
1032
  sendActivity("subagent_end", agentId, sessionKey, runId, {
483
1033
  child_session_key: event.targetSessionKey,
484
1034
  outcome: event.outcome,
485
- error: event.error?.slice(0, 200),
1035
+ error: _redactError(event.error, true),
486
1036
  });
487
1037
  });
488
1038
 
489
- // ── before_compaction ─────────────────────────────────────────────────
490
1039
  api.on("before_compaction", (_event: CompactionEvent, ctx: AgentContext) => {
491
1040
  const key = ctx.sessionKey ?? ctx.sessionId;
492
1041
  const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
@@ -501,7 +1050,6 @@ const plugin = {
501
1050
  sendActivity("compaction", agentId, key, ctx.runId);
502
1051
  });
503
1052
 
504
- // ── before_reset ──────────────────────────────────────────────────────
505
1053
  api.on("before_reset", (event: ResetEvent, ctx: AgentContext) => {
506
1054
  const key = ctx.sessionKey ?? ctx.sessionId;
507
1055
  const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
@@ -516,9 +1064,6 @@ const plugin = {
516
1064
  sendActivity("reset", agentId, key, ctx.runId, { reason: event.reason });
517
1065
  });
518
1066
 
519
- // ── agent_end ─────────────────────────────────────────────────────────
520
- // Fires when the agent finishes a run. Sends run_end for real-time UI,
521
- // then the full persisted event to /v1/events with all accumulated data.
522
1067
  api.on("agent_end", (event: AgentEndEvent, ctx: AgentContext) => {
523
1068
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
524
1069
  if (!sessionKey) return;
@@ -535,21 +1080,49 @@ const plugin = {
535
1080
  (run?.cacheReadTokens ?? 0) +
536
1081
  (run?.cacheWriteTokens ?? 0);
537
1082
 
538
- // Real-time: notify dashboard the run finished
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
+ }
1105
+
539
1106
  sendActivity("run_end", agentId, sessionKey, ctx.runId, {
540
1107
  status: event.success ? "success" : "failed",
541
- duration_ms: event.durationMs ?? (session ? Date.now() - session.startedAt : undefined),
1108
+ duration_ms: durationMs,
542
1109
  total_tokens: totalTokens || undefined,
543
1110
  tool_calls: run?.toolCalls,
544
- error: event.error?.slice(0, 200),
1111
+ error: _redactError(event.error, true),
545
1112
  });
546
1113
 
547
- // Persistent: full summary stored in DB
548
1114
  send({
549
- trace_id: session?.traceId ?? randomUUID(),
550
- agent_id: agentId,
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()}`,
551
1124
  status: event.success ? "success" : "failed",
552
- duration_ms: event.durationMs ?? (session ? Date.now() - session.startedAt : undefined),
1125
+ duration_ms: durationMs,
553
1126
  model: run?.model,
554
1127
  model_provider: run?.provider,
555
1128
  input_tokens: run?.inputTokens ?? 0,
@@ -559,8 +1132,10 @@ const plugin = {
559
1132
  total_tokens: totalTokens || undefined,
560
1133
  tool_calls: run?.toolCalls ?? 0,
561
1134
  tool_errors: run?.toolErrors ?? 0,
562
- step_count: event.messages?.length,
563
- ...(event.error ? { error: event.error.slice(0, 500) } : {}),
1135
+ tool_names: run ? _redactToolNames([...run.toolNames]) : [],
1136
+ step_count: event.messages?.length,
1137
+ ...(estimatedCostUsd != null ? { estimated_cost_usd: estimatedCostUsd } : {}),
1138
+ ...(redactedError ? { error: redactedError } : {}),
564
1139
  metadata: {
565
1140
  llm_calls: run?.llmCalls ?? 0,
566
1141
  images_count: run?.imagesCount ?? 0,
@@ -568,16 +1143,52 @@ const plugin = {
568
1143
  subagent_errors: run?.subagentErrors ?? 0,
569
1144
  compactions: session?.compactions ?? 0,
570
1145
  resets: session?.resets ?? 0,
571
- tool_names: run ? [...run.toolNames] : [],
572
1146
  },
573
1147
  });
574
1148
 
575
1149
  if (ctx.runId) runs.delete(ctx.runId);
576
1150
  });
577
1151
 
578
- // ── session_end ───────────────────────────────────────────────────────
579
1152
  api.on("session_end", (event: SessionEndEvent, _ctx: SessionContext) => {
580
- const key = event.sessionKey ?? event.sessionId;
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
+
581
1192
  sessions.delete(key);
582
1193
  });
583
1194
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "agentmetrics",
3
3
  "name": "AgentMetrics",
4
- "description": "360-degree observability for every OpenClaw agent tokens, tools, latency, cost, subagents, context health, and reliability.",
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: https://api.agentmetrics.dev)"
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.1",
4
- "type": "module",
5
- "description": "AgentMetrics observability plugin for OpenClaw agents",
6
- "license": "MIT",
7
- "homepage": "https://agentmetrics.dev",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/andausman/agentmetrics.git"
11
- },
12
- "keywords": ["openclaw", "agentmetrics", "observability", "ai-agents", "monitoring"],
13
- "peerDependencies": {
14
- "openclaw": ">=2026.3.2"
15
- },
16
- "openclaw": {
17
- "extensions": ["./index.ts"]
18
- },
19
- "files": [
20
- "index.ts",
21
- "openclaw.plugin.json",
22
- "README.md"
23
- ],
24
- "engines": {
25
- "node": ">=22"
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
+ }