From 74f16f35a3e169a13730d66921bf2fd6f41be2ff Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 11 Mar 2026 07:28:08 +0000 Subject: [PATCH 1/2] Add OpenClaw gateway hook for live telemetry ingestion TypeScript hook that runs inside the OpenClaw gateway container and sends traces, logs, health checks, and metrics to AgentWatch via the HTTP ingestion API. Fire-and-forget design ensures zero impact on message processing latency. Co-Authored-By: Claude Opus 4.6 --- examples/openclaw-hook/HOOK.md | 74 ++++++++ examples/openclaw-hook/handler.ts | 277 ++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 examples/openclaw-hook/HOOK.md create mode 100644 examples/openclaw-hook/handler.ts diff --git a/examples/openclaw-hook/HOOK.md b/examples/openclaw-hook/HOOK.md new file mode 100644 index 0000000..5993d42 --- /dev/null +++ b/examples/openclaw-hook/HOOK.md @@ -0,0 +1,74 @@ +--- +name: agentwatch +description: "Send telemetry to AgentWatch observability dashboard" +homepage: https://github.com/maxdraki/AgentWatch +metadata: + { + "openclaw": + { + "emoji": "🔭", + "events": + [ + "gateway:startup", + "message:received", + "message:sent", + "command:new", + "command:reset", + ], + "export": "default", + }, + } +--- + +# AgentWatch Telemetry Hook + +Sends OpenClaw gateway events to an [AgentWatch](https://github.com/maxdraki/AgentWatch) server for observability. + +Tracks: +- **Traces** — each message received/sent pair becomes a trace with spans +- **Logs** — commands, session lifecycle, errors +- **Health** — gateway startup confirmation +- **Metrics** — message counts by channel + +## Installation + +Copy this directory into your OpenClaw workspace hooks: + +```bash +cp -r examples/openclaw-hook ~/.openclaw/workspace/hooks/agentwatch +``` + +Then enable in `openclaw.json`: + +```json +{ + "hooks": { + "internal": { + "entries": { + "agentwatch": { "enabled": true } + } + } + } +} +``` + +Restart the gateway to load the hook. + +## Configuration + +Set the following environment variables (via `hooks.internal.entries.agentwatch.env` in `openclaw.json` or container env): + +| Variable | Default | Description | +|----------|---------|-------------| +| `AGENTWATCH_URL` | `http://172.17.0.1:8470` | AgentWatch server URL (use Docker gateway IP from container) | +| `AGENTWATCH_TOKEN` | *(none)* | Optional auth token | +| `AGENTWATCH_AGENT_NAME` | `openclaw-gateway` | Agent name in dashboard | + +## Running AgentWatch + +```bash +pip install agentwatch[server] +agentwatch serve --host 0.0.0.0 --port 8470 +``` + +Then open `http://localhost:8470` to see your OpenClaw telemetry. diff --git a/examples/openclaw-hook/handler.ts b/examples/openclaw-hook/handler.ts new file mode 100644 index 0000000..dc5e0fb --- /dev/null +++ b/examples/openclaw-hook/handler.ts @@ -0,0 +1,277 @@ +/** + * AgentWatch telemetry hook for OpenClaw. + * + * Sends traces, logs, health checks, and metrics to an AgentWatch server + * via its HTTP ingestion API. All sends are fire-and-forget to avoid + * blocking message processing. + * + * Installation: + * Copy this directory to ~/.openclaw/workspace/hooks/agentwatch/ + * Enable in openclaw.json: hooks.internal.entries.agentwatch.enabled = true + * Restart the gateway. + * + * Environment variables: + * AGENTWATCH_URL - Server URL (default: http://172.17.0.1:8470) + * AGENTWATCH_TOKEN - Optional auth token + * AGENTWATCH_AGENT_NAME - Agent name (default: openclaw-gateway) + */ + +const AGENTWATCH_URL = + process.env.AGENTWATCH_URL || "http://172.17.0.1:8470"; +const AGENTWATCH_TOKEN = process.env.AGENTWATCH_TOKEN || ""; +const AGENT_NAME = process.env.AGENTWATCH_AGENT_NAME || "openclaw-gateway"; + +const MAX_CONTENT = 200; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function uuid(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} + +function now(): string { + return new Date().toISOString(); +} + +function truncate(s: string, max: number): string { + if (!s) return ""; + const clean = s.replace(/\n/g, " ").trim(); + return clean.length <= max ? clean : clean.slice(0, max) + "..."; +} + +// ── HTTP client (fire-and-forget) ────────────────────────────────────── + +async function post( + path: string, + body: Record +): Promise { + const url = `${AGENTWATCH_URL}/api/v1/ingest/${path}`; + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "openclaw-agentwatch-hook/1.0", + }; + if (AGENTWATCH_TOKEN) { + headers["Authorization"] = `Bearer ${AGENTWATCH_TOKEN}`; + } + + try { + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + if (!resp.ok) { + console.error( + `[agentwatch] POST ${path} failed: ${resp.status} ${resp.statusText}` + ); + } + } catch (err) { + console.error( + `[agentwatch] POST ${path} error:`, + err instanceof Error ? err.message : String(err) + ); + } +} + +function sendTrace( + name: string, + status: "completed" | "failed", + durationMs: number, + metadata: Record = {}, + spans: Record[] = [] +): void { + const traceId = uuid(); + const startedAt = new Date(Date.now() - durationMs).toISOString(); + const endedAt = now(); + + void post("traces", { + id: traceId, + agent_name: AGENT_NAME, + name, + status, + started_at: startedAt, + ended_at: endedAt, + duration_ms: durationMs, + metadata, + spans: + spans.length > 0 + ? spans + : [ + { + id: uuid(), + trace_id: traceId, + name, + status, + started_at: startedAt, + ended_at: endedAt, + duration_ms: durationMs, + metadata, + events: [], + }, + ], + }); +} + +function sendLog( + level: string, + message: string, + metadata: Record = {} +): void { + void post("logs", { + agent_name: AGENT_NAME, + level, + message, + timestamp: now(), + metadata, + }); +} + +function sendHealth( + name: string, + status: string, + message: string, + metadata: Record = {} +): void { + void post("health", { + name, + agent_name: AGENT_NAME, + status, + message, + timestamp: now(), + metadata, + }); +} + +function sendMetric( + name: string, + value: number, + tags: Record = {} +): void { + void post("metrics", { + agent_name: AGENT_NAME, + name, + value, + kind: "counter", + tags, + timestamp: now(), + }); +} + +// ── Skip patterns (heartbeats, noise) ────────────────────────────────── + +const SKIP = [ + /^HEARTBEAT_OK$/i, + /^NO_REPLY$/i, + /^Read HEARTBEAT\.md/i, + /^\s*$/, +]; + +function shouldSkip(content: string): boolean { + return SKIP.some((p) => p.test((content || "").trim())); +} + +// ── Main handler ─────────────────────────────────────────────────────── + +const handler = async (event: any) => { + try { + const { type, action, context, sessionKey } = event; + + // ── Gateway startup ────────────────────────────────────────────── + if (type === "gateway" && action === "startup") { + sendHealth("gateway", "ok", "Gateway started", { + sessionKey, + timestamp: now(), + }); + sendLog("info", "OpenClaw gateway started", { event: "startup" }); + return; + } + + // ── Message received ───────────────────────────────────────────── + if (type === "message" && action === "received") { + const content = context?.content || context?.body || ""; + if (shouldSkip(content)) return; + + const from = + context?.metadata?.senderName || context?.from || "unknown"; + const channel = context?.channelId || "unknown"; + const isGroup = !!context?.isGroup; + + sendTrace("msg:received:" + channel, "completed", 1, { + direction: "inbound", + channel, + from, + isGroup, + content: truncate(content, MAX_CONTENT), + conversationId: context?.conversationId, + }); + + sendMetric("messages_received", 1, { channel }); + return; + } + + // ── Message sent ───────────────────────────────────────────────── + if (type === "message" && action === "sent") { + const content = context?.content || context?.body || ""; + if (shouldSkip(content)) return; + + const to = context?.to || "unknown"; + const channel = context?.channelId || "unknown"; + const success = context?.success !== false; + + sendTrace( + "msg:sent:" + channel, + success ? "completed" : "failed", + 1, + { + direction: "outbound", + channel, + to, + success, + error: context?.error, + isGroup: !!context?.isGroup, + content: truncate(content, MAX_CONTENT), + conversationId: context?.conversationId, + } + ); + + sendMetric("messages_sent", 1, { + channel, + success: String(success), + }); + + if (!success && context?.error) { + sendLog("error", "Message delivery failed: " + context.error, { + channel, + to, + }); + } + return; + } + + // ── Commands ───────────────────────────────────────────────────── + if (type === "command") { + sendLog("info", "Command: /" + action, { + event: "command:" + action, + sessionKey, + source: context?.commandSource, + senderId: context?.senderId, + }); + + sendTrace("command:" + action, "completed", 1, { + command: action, + sessionKey, + source: context?.commandSource, + }); + return; + } + } catch (err) { + // Never throw — don't break message processing + console.error( + "[agentwatch]", + err instanceof Error ? err.message : String(err) + ); + } +}; + +export default handler; From fde13462bb7deff8f8906294dd6f00f0ff20dbd2 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 11 Mar 2026 07:46:56 +0000 Subject: [PATCH 2/2] Track real round-trip duration by correlating received/sent events Instead of hardcoding 1ms duration, the hook now tracks message:received timestamps by conversationId and computes actual round-trip time when message:sent fires. Produces a waterfall trace with conversation + delivery spans. Also adds response_time_ms metric and Docker network documentation. Co-Authored-By: Claude Opus 4.6 --- examples/openclaw-hook/HOOK.md | 18 +++++- examples/openclaw-hook/handler.ts | 99 +++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/examples/openclaw-hook/HOOK.md b/examples/openclaw-hook/HOOK.md index 5993d42..c6aec1a 100644 --- a/examples/openclaw-hook/HOOK.md +++ b/examples/openclaw-hook/HOOK.md @@ -54,13 +54,19 @@ Then enable in `openclaw.json`: Restart the gateway to load the hook. +## How it works + +The hook correlates `message:received` and `message:sent` events by conversation ID to compute **real round-trip duration** — the time from when a message arrives to when the reply is delivered. Each conversation turn becomes a trace with nested spans (conversation + delivery), giving you a waterfall view in the dashboard. + +Events that can't be correlated (e.g. outbound-only announcements) still produce traces with zero duration. + ## Configuration Set the following environment variables (via `hooks.internal.entries.agentwatch.env` in `openclaw.json` or container env): | Variable | Default | Description | |----------|---------|-------------| -| `AGENTWATCH_URL` | `http://172.17.0.1:8470` | AgentWatch server URL (use Docker gateway IP from container) | +| `AGENTWATCH_URL` | `http://172.17.0.1:8470` | AgentWatch server URL (see note below) | | `AGENTWATCH_TOKEN` | *(none)* | Optional auth token | | `AGENTWATCH_AGENT_NAME` | `openclaw-gateway` | Agent name in dashboard | @@ -72,3 +78,13 @@ agentwatch serve --host 0.0.0.0 --port 8470 ``` Then open `http://localhost:8470` to see your OpenClaw telemetry. + +## Network note + +The default `AGENTWATCH_URL` uses `172.17.0.1` — the Docker bridge gateway IP that lets the container reach the host. This works when OpenClaw runs in Docker and AgentWatch runs on the host. + +| Setup | URL to use | +|-------|-----------| +| OpenClaw in Docker, AgentWatch on host | `http://172.17.0.1:8470` (default) | +| Both on the same host (no Docker) | `http://localhost:8470` | +| AgentWatch on a different machine | `http://:8470` | diff --git a/examples/openclaw-hook/handler.ts b/examples/openclaw-hook/handler.ts index dc5e0fb..ff8de2b 100644 --- a/examples/openclaw-hook/handler.ts +++ b/examples/openclaw-hook/handler.ts @@ -23,6 +23,11 @@ const AGENT_NAME = process.env.AGENTWATCH_AGENT_NAME || "openclaw-gateway"; const MAX_CONTENT = 200; +// Track open conversations: conversationId → receive timestamp (for duration) +const pendingConversations = new Map(); +// Auto-expire stale entries after 10 minutes +const PENDING_TTL_MS = 10 * 60 * 1000; + // ── Helpers ──────────────────────────────────────────────────────────── function uuid(): string { @@ -196,14 +201,24 @@ const handler = async (event: any) => { context?.metadata?.senderName || context?.from || "unknown"; const channel = context?.channelId || "unknown"; const isGroup = !!context?.isGroup; + const convId = context?.conversationId as string | undefined; + + // Track receive time so we can compute round-trip on the sent event + if (convId) { + pendingConversations.set(convId, Date.now()); + // Expire stale entries + for (const [k, ts] of pendingConversations) { + if (Date.now() - ts > PENDING_TTL_MS) pendingConversations.delete(k); + } + } - sendTrace("msg:received:" + channel, "completed", 1, { + sendLog("info", `Message from ${from} (${channel})`, { direction: "inbound", channel, from, isGroup, content: truncate(content, MAX_CONTENT), - conversationId: context?.conversationId, + conversationId: convId, }); sendMetric("messages_received", 1, { channel }); @@ -218,28 +233,86 @@ const handler = async (event: any) => { const to = context?.to || "unknown"; const channel = context?.channelId || "unknown"; const success = context?.success !== false; + const convId = context?.conversationId as string | undefined; + + // Compute real round-trip duration if we have the receive timestamp + let durationMs = 0; + let startedAt: string; + const endedAt = now(); + const receiveTs = convId ? pendingConversations.get(convId) : undefined; + + if (receiveTs) { + durationMs = Date.now() - receiveTs; + startedAt = new Date(receiveTs).toISOString(); + pendingConversations.delete(convId!); + } else { + startedAt = endedAt; + } - sendTrace( - "msg:sent:" + channel, - success ? "completed" : "failed", - 1, - { - direction: "outbound", - channel, + // Build a trace with receive + sent spans for the full round-trip + const traceId = uuid(); + const spans: Record[] = []; + + if (receiveTs) { + // Span covering the full conversation turn + spans.push({ + id: uuid(), + trace_id: traceId, + name: "conversation:" + channel, + status: success ? "completed" : "failed", + started_at: startedAt, + ended_at: endedAt, + duration_ms: durationMs, + metadata: { channel, conversationId: convId }, + events: [], + }); + } + + // Delivery span + spans.push({ + id: uuid(), + trace_id: traceId, + parent_id: spans.length > 0 ? (spans[0] as any).id : undefined, + name: "deliver:" + channel, + status: success ? "completed" : "failed", + started_at: endedAt, + ended_at: endedAt, + duration_ms: 0, + metadata: { to, success, error: context?.error, - isGroup: !!context?.isGroup, content: truncate(content, MAX_CONTENT), - conversationId: context?.conversationId, - } - ); + }, + events: [], + }); + + void post("traces", { + id: traceId, + agent_name: AGENT_NAME, + name: "conversation:" + channel, + status: success ? "completed" : "failed", + started_at: startedAt, + ended_at: endedAt, + duration_ms: durationMs, + metadata: { + channel, + to, + isGroup: !!context?.isGroup, + conversationId: convId, + }, + spans, + }); sendMetric("messages_sent", 1, { channel, success: String(success), }); + if (durationMs > 0) { + sendMetric("response_time_ms", durationMs, { channel }); + } + if (!success && context?.error) { sendLog("error", "Message delivery failed: " + context.error, { channel,