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 CHANGED
@@ -1,11 +1,9 @@
1
1
  # agentmetrics-openclaw
2
2
 
3
- [![npm](https://img.shields.io/npm/v/agentmetrics-openclaw?color=009E80&label=npm&logo=npm&logoColor=white)](https://www.npmjs.com/package/agentmetrics-openclaw)
4
- [![License: MIT](https://img.shields.io/badge/license-MIT-009E80)](../LICENSE)
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)
5
5
 
6
- Zero-code observability for [OpenClaw](https://openclaw.dev) agents. Install the plugin, set your API key, and every agent session is tracked automatically tokens, cost, tools, subagents, context health, and reliability.
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
- - An AgentMetrics API key [get one at agentmetrics.dev](https://agentmetrics.dev/signup)
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. Set your API key**
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 AGENTMETRICS_API_KEY=am_live_...' >> ~/.bashrc && source ~/.bashrc
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:AGENTMETRICS_API_KEY = "am_live_..."
46
+ $Env:AGENTMETRICS_BASE_URL = "http://your-server:8099"
38
47
  ```
39
48
 
40
- **2. Trust the plugin** (silences the security scan advisory)
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
- **3. Restart the gateway**
57
+ **4. Restart the gateway**
47
58
 
48
59
  ```bash
49
60
  openclaw gateway restart
50
61
  ```
51
62
 
52
- **4. Verify**
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 `AGENTMETRICS_API_KEY` and sends it as a Bearer token to the AgentMetrics API. Add `agentmetrics` to `plugins.allow` to suppress it permanently.
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. Check the key is set: `echo $AGENTMETRICS_API_KEY`
87
- 2. Verify the plugin loads: `openclaw plugins list`
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; // run-level start time for accurate duration
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
- /** Send a completed run summary to /v1/events (persisted to DB). */
188
- async function send(payload: Record<string, unknown>): Promise<void> {
189
- if (!API_KEY) return;
283
+ function _walAppend(payload: Record<string, unknown>): void {
284
+ if (!WAL_PATH) return;
190
285
  try {
191
- await fetch(`${BASE_URL}/v1/events`, {
192
- method: "POST",
193
- headers: {
194
- "Content-Type": "application/json",
195
- "Authorization": `Bearer ${API_KEY}`,
196
- },
197
- 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; }
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
- // Never crash the agent on observability failure
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 no retry, no crash on failure.
461
+ * to dashboard via SSE). Fire-and-forget - no retry, no crash on failure.
207
462
  */
208
463
  function sendActivity(
209
- type: string,
210
- agentId: string,
211
- sessionKey: string | undefined,
212
- runId: string | undefined,
213
- data?: Record<string, unknown>,
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
- // ─── 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
+
247
563
 
248
564
  const plugin = {
249
565
  id: "agentmetrics",
250
566
  name: "AgentMetrics",
251
- 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.",
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: 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)",
262
617
  },
263
618
  },
264
619
  additionalProperties: false,
265
620
  } as const,
266
621
 
267
622
  register(api: PluginApi) {
268
- // Config: pluginConfig > env vars
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
- "https://api.agentmetrics.dev"
636
+ "http://localhost:8099"
274
637
  ).replace(/\/$/, "");
275
638
 
276
- // 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
+
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
- " Get an API key at https://agentmetrics.dev and set AGENTMETRICS_API_KEY.\n"
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 sending data to ${BASE_URL}\n` +
292
- ` View your dashboard https://agentmetrics.dev\n`
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 AgentMetrics plugin status",
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
- console.log("AgentMetrics active");
309
- console.log(` API key : ${keyPreview}`);
310
- console.log(` Endpoint : ${BASE_URL}`);
311
- console.log(` Sessions : ${sessions.size} tracked`);
312
- 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}`);
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?.slice(0, 200),
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?.slice(0, 200),
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
- // Use run-level duration (accurate) fall back to session duration only if unavailable
554
- const durationMs = event.durationMs ?? (run ? Date.now() - run.startedAt : undefined);
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?.slice(0, 200),
1111
+ error: _redactError(event.error, true),
563
1112
  });
564
1113
 
565
- // Persistent: full summary stored in DB
566
1114
  send({
567
- trace_id: session?.traceId ?? randomUUID(),
568
- 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()}`,
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
- step_count: event.messages?.length,
581
- ...(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 } : {}),
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 = 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
+
599
1192
  sessions.delete(key);
600
1193
  });
601
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.3",
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/andalabx/agentmetrics-sdk.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
+ }