diff --git a/examples/openclaw-hook/HOOK.md b/examples/openclaw-hook/HOOK.md new file mode 100644 index 0000000..c6aec1a --- /dev/null +++ b/examples/openclaw-hook/HOOK.md @@ -0,0 +1,90 @@ +--- +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. + +## 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 (see note below) | +| `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. + +## 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 new file mode 100644 index 0000000..ff8de2b --- /dev/null +++ b/examples/openclaw-hook/handler.ts @@ -0,0 +1,350 @@ +/** + * 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; + +// 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 { + 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; + 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); + } + } + + sendLog("info", `Message from ${from} (${channel})`, { + direction: "inbound", + channel, + from, + isGroup, + content: truncate(content, MAX_CONTENT), + conversationId: convId, + }); + + 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; + 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; + } + + // 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, + content: truncate(content, MAX_CONTENT), + }, + 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, + 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;