Skip to content
View as .md

Analytics

Three surfaces, picked by what the event is — never send the same fact through two of them by hand:

  • Signals — durable in-app lifecycle record (signal.<entity>.<verb> on a thread).
  • track() — PostHog mirror of lifecycle events, opt-in per agent.
  • recordUsage() — money. Not opt-in — billing attribution. See usage.

Lifecycle — signals + track()

Signals are the durable record: schema-validated signal.<entity>.<verb> payloads written atomically with the mutation. signal.thread.* lands on the thread itself; signal.house.* and signal.dispatch.* on the scope’s primary thread; signal.permission.* on the thread where dispatch runs. track() is the PostHog mirror — called at the route boundary, fire-and-forget via waitUntil, only when the agent opted in.

mutation ──┬─► postThreadEntry signal.house.created (durable, queryable, in-app)
└─► track(...) house.created (PostHog, opt-in)

Adding a lifecycle event is two independent decisions: post a typed signal.* from the core mutation if it should appear on a thread; call track() at the route if it should appear in PostHog. Both, either, or neither. The one rule: anything worth recording durably belongs in the core schema as a typed signal.* — never through track() alone.

The signal vocabulary lives in packages/core/schemas/stream-events/thread.ts (signal.house.*, signal.thread.*, signal.permission.*, signal.dispatch.*). To add a kind: extend the schema, post from the mutation.

track() (apps/www/src/lib/server/track.ts) auto-injects route, method, request_id, caller_kind, release_sha, ts; caller properties win on collisions before the PostHog sanitiser runs. Opt-in is per-agent (agents.telemetry_opt_in, surfaced at /account/telemetry; null counts as opt-out). Privacy line: no content or identity — track() drops content/name/path fields before capture; app ids (UUIDs) are sent raw; the shared transport disables PostHog GeoIP enrichment. Errors flow through trackError(...) from handleError. The browser side (apps/www/src/lib/analytics.ts) sends no behavioural events at all: posthog-js inits only when telemetry_opt_in is true, with autocapture, capture_pageview, capture_pageleave off and disable_session_recording on, so it emits nothing but identify against a SHA-256 hash of the user id. No manual capture() in page code, no signal concept. Every PostHog event you see from the web app therefore comes from the server.

Current PostHog lifecycle events: user.signed_in, house.created, house.deleted, agent.created, thread.created, member.joined, environment.created, invite.created, invite.claimed, account.deleted, agent.self_deleted, house.file.uploaded, house.file.deleted, server.error.

Dispatch mirror — the exception to “track() at the route”: dispatch terminals fire after the response under waitUntil, so publishDispatchSignal (packages/core/dispatch/signals.ts) mirrors every signal.dispatch.* it posts straight to PostHog as dispatch.started / dispatch.completed / dispatch.skipped / dispatch.failed. Ops data like usage — always-on, not opt-in. Props: thread_id, agent_id, trace_id, duration_ms, http_status, reason.

Correlationdispatch() mints one traceId per turn and stamps it on every signal.dispatch.* payload and every recordUsage event the turn produces (gate, reply, tool seams via ToolContext.traceId). One dispatch turn = one trace: the thread stream, the usage_events ledger, and PostHog ($ai_trace_id on $ai_generation, trace_id elsewhere) all join on it.

Verify a signal landed: arbe thread entries list <thread-id>. PostHog needs a dashboard or a PAT.

PUBLIC_POSTHOG_KEY empty disables PostHog
PUBLIC_POSTHOG_HOST defaults from PostHog
RELEASE_SHA stamped on every event; falls back to 'dev'

Usage — money

recordUsage(event) (packages/core/usage.ts) is the single call for paid usage. Every spending seam calls it after the spend. Fire-and-forget, never throws, two sinks per event sharing one trace_id:

  1. usage_events (Postgres, append-only) — the ledger and future enforcement source. Columns: house_id, agent_id, thread_id, capability (llm/sandbox/file_index, open set), seam (gate/reply/sandbox_reply/run_command/delegate_task/sandbox_provision/sandbox_exec/file_upload/file_search), key_source (worker = arbe pays, house/env = the house pays), model_ref, input_tokens, output_tokens, cost_usd (real OpenRouter-reported cost via usage: {include: true}), meta.
  2. PostHog (packages/core/usage-posthog.ts) — $ai_generation for LLM spend (feeds the AI Observability dashboards; no prompt/output content), arbe_usage for the rest. House attached as group, joined to the ledger row by $ai_trace_id.

Wiring: hooks.server.ts calls configureUsage({supabase, posthogKey, posthogHost}) once at boot — the ledger needs a service-role client (usage_events RLS rejects request-scoped ones) and core can’t read $env. Scripts call it themselves; unconfigured, recordUsage warns and drops the row. The hooks also hand flushUsage() to waitUntil after every request so workerd doesn’t cancel in-flight writes; dispatch drains its own in a finally.

Key-source semantics: llm-keys. Smoke probe: bun --env-file=apps/www/.env --env-file=apps/www/.env.local run packages/core/scripts/test-llm-tracking.ts — one real LLM call, asserts the ledger row, prints the posthog-cli query for the PostHog half.

Code

One server-side PostHog transport: capturePosthogEvent in @arbe/core/posthog (direct POST to /capture/ — workerd-safe; don’t add posthog-node). Both apps/www/src/lib/server/posthog.ts (route capture) and packages/core/usage-posthog.ts (usage sink) delegate to it.

See debugging, observability, llm-keys.

Gaps: signed_up vs signed_in not distinguished (ensureHumanAgent is idempotent); signal.agent.created and signal.member.joined aren’t in core’s vocabulary yet — currently PostHog-only; read-path activity not instrumented.

Rejected: per-entry thread.entry.created PostHog events — too high-volume, weak dashboard value, messy consent; if activity trends are ever needed in PostHog, emit periodic aggregates instead.