Skip to content

feat(relay): per-community usage metrics#1723

Open
wpfleger96 wants to merge 5 commits into
mainfrom
duncan/relay-usage-metrics
Open

feat(relay): per-community usage metrics#1723
wpfleger96 wants to merge 5 commits into
mainfrom
duncan/relay-usage-metrics

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

Emit per-community usage gauges and counter tags to Datadog so teams can answer "how many users / messages / active users does community X have right now" without manual SQL.

Metrics added

DB-polled gauges (new background poller, BUZZ_USAGE_METRICS_INTERVAL_SECS, default 300 s, min 5 s)

Metric Tags Query
buzz_communities_total COUNT(*) on communities
buzz_community_users community, type:human|agent users where deactivated_at IS NULL; agent = agent_owner_pubkey IS NOT NULL
buzz_community_channels community, type:stream|forum|dm|workflow channels where deleted_at IS NULL
buzz_community_messages community events kind=9, deleted_at IS NULL
buzz_community_relay_members community, role relay_members by role
buzz_community_workflows community, status workflows
buzz_community_git_repos community git_repo_names
buzz_community_active_users community, window:1d|7d|30d, type:human|agent|unknown DAU/WAU/MAU via COUNT(DISTINCT pubkey) on events; unknown = pubkeys with no users row (profileless posters)
buzz_community_active_channels community, window:1d|7d distinct channels with ≥1 kind-9 in window

Fleet-wide totals (always emitted regardless of BUZZ_USAGE_METRICS_PER_COMMUNITY)

Metric Tags
buzz_total_users type:human|agent
buzz_total_channels type
buzz_total_messages
buzz_total_relay_members role
buzz_total_workflows status
buzz_total_git_repos
buzz_total_active_users window, type
buzz_total_active_channels window
buzz_total_ws_connections
buzz_total_users_online_pod
buzz_total_subscriptions

In-memory snapshot gauges (same poller tick, from live state)

Metric Tags Notes
buzz_community_ws_connections community
buzz_community_users_online_pod community pod-local distinct count; sum across pods for fleet total
buzz_community_subscriptions community

Snapshot pattern used instead of inc/dec to eliminate gauge drift from asymmetric call paths.

Write-path counter tags

community tag added to:

  • buzz_ws_connections_total (connection.rs)
  • buzz_media_uploads_total (api/media.rs)
  • buzz_workflow_runs_total (handlers/event.rs)

New per-community event volume counter (kind-only counters remain fleet-wide to avoid kind × community cardinality explosion):

  • buzz_community_events_received_total{community} — per-community event throughput

New counters

  • buzz_users_created_total{community} — incremented only on first insert (ensure_user returns true)
  • buzz_channels_created_total{community, type} — incremented only on actual creation (ingest.rs was_created path, side_effects.rs new-channel paths)

Bug fix

Kind 44200 (agent turn metrics) now maps to "44200" in bounded_kind_label instead of collapsing to "other".

Cardinality knob

BUZZ_USAGE_METRICS_PER_COMMUNITY controls how many per-community gauge series emit:

Value Behavior
all (default) per-community series for every community
off fleet totals only, no per-community series
top:<k> per-community series for the k communities with the most messages; fleet totals always on

Fleet-wide buzz_total_* metrics always emit regardless of mode — Datadog cost lever without losing aggregate visibility.

Implementation notes

  • ensure_user() return type changed from Result<()> to Result<bool> (true = new insert, false = ON CONFLICT DO NOTHING). All callers updated.
  • All rollup queries use GROUP BY community_id — one query per metric family, not one query per community.
  • Collect-before-publish: all DB queries run before any metrics::gauge!(...).set(...) call. If any query fails the tick aborts cleanly — no mixed fresh/stale snapshot.
  • First-tick jitter: PID-seeded hash gives each pod a start delay in [0, interval_secs) to prevent thundering herd on rolling deploy. MissedTickBehavior::Skip prevents catch-up burst.
  • buzz_events_received_total and buzz_events_stored_total keep {kind} only (fleet-wide). bounded_kind_label passes through 10k values in 20000..=29999 (client-controlled); crossing with community would produce millions of series. Use buzz_community_events_received_total{community} for per-community throughput.
  • buzz_community_users_online_pod is distinct within a pod (not fleet-distinct). Sum across pods for fleet total, with caveat that multi-pod pubkeys count N times.
  • active_user_counts SQL uses a three-way LEFT JOIN classification: human (row exists, owner NULL), agent (row exists, owner NOT NULL), unknown (no row — profileless posters). Old query incorrectly counted no-row pubkeys as human.
  • Unmatched enum values in zero-fill filter_maps emit warn! log entries instead of being silently dropped.
  • Multi-pod aggregation: DB-derived gauges use max: in Datadog (all pods report same DB value); in-memory gauges use sum: (per-pod partials). Documented in code comments.
  • Tag is community (host string), not host — avoids collision with Datadog's reserved infra host tag.

Emit per-community usage gauges and counter tags to Datadog:
- New usage poller task (BUZZ_USAGE_METRICS_INTERVAL_SECS, default 60,
  min 5): DB-polled gauges for users, channels, messages, relay members,
  workflows, git repos, DAU/WAU/MAU, and active channels, all grouped by
  community; plus in-memory snapshot gauges for ws_connections,
  users_online, and subscriptions per community
- Write-path community tag: events_received/stored, ws_connections,
  media_uploads, workflow_runs now carry a 'community' label
- New counters: buzz_users_created_total and buzz_channels_created_total
  tagged by community, incremented only on actual first insert/creation
- Fix kind 44200 missing from bounded_kind_label (was collapsing to 'other')

ensure_user() now returns Result<bool> (true = new insert, false = conflict
DO NOTHING) so callers can count new users without a separate query.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 requested a review from a team as a code owner July 10, 2026 18:36
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 10, 2026 15:04
F1: Zero-fill buzz_community_channels, buzz_community_relay_members, and
buzz_community_workflows across bounded label domains so counts going to
zero emit 0 instead of retaining stale last-nonzero values. buzz_community_
users, messages, git_repos, active_users, active_channels, ws_connections,
users_online, and subscriptions were already zero-filled. Remove the now-
unused host() closure.

Add two regression tests:
- subscription.rs: per_community_subscriptions_drops_to_zero_when_all_
  subs_removed (in-memory gauge)
- usage.rs: test_channel_counts_drops_to_zero_after_last_channel_deleted
  (DB gauge)

F2: Default interval 60s -> 300s (env override unchanged, min 5s). Add
comment naming rollup-table escape hatch at event-rollup queries.

F3: Increment buzz_channels_created_total{type="dm"} at open_dm call
sites in command_executor.rs (two DM paths) and moderation_notices.rs.

F4: Run cargo fmt; split long assertion in subscription.rs per rustfmt.

F5: Fix test_user_counts_scoped_per_community: original insert_user(is_
agent=true) inserted the agent's owner as a separate human, making the
assertion wrong (3 humans, not 2). Rewrite to share owner pubkey so agent
and human count correctly. All 7 ignored usage tests pass against local PG.

F6: FROM git_repo_names (not git_repos which does not exist). Add test
test_git_repo_counts_scoped_per_community covering the corrected relation.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…regates

The module header claimed all queries use indexed columns with no full-table
scans. That is false for message_counts, active_user_counts, and
active_channel_counts, which are exact event-table aggregates (accepted
tradeoff from Pass-1 review F2). Split the doc to accurately describe the
two query classes and call out the rollup-table escape hatch.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@tlongwell-block

Copy link
Copy Markdown
Collaborator

Reviewed at head b423c8a (CI green, MERGEABLE) against the #1321 tenant model, at Tyler's request. Verdict up front: tenant-safe and correct; the one real risk is metric-series cardinality/cost at the scale this is meant for (thousands of communities).

Tenant safety (vs #1321) — clean

  • Every community label value comes from tenant.host() / conn.tenant.host() — the server-resolved TenantContext minted only by fail-closed host resolution. Client input can never mint a label value, so label cardinality is bounded by the communities table, not by attackers.
  • The new usage.rs queries are deliberately unscoped (fleet-wide GROUP BY community_id), which would be a fence violation on a request path — but they're confined to the relay-internal poller and never reachable from any client path. Results are keyed back to hosts via the communities table itself. This is the right shape for operator rollups.
  • AssertSqlSafe + format! on the interval looks scary but the parameter is &'static str, so the type system pins it to compile-time literals. Fine.
  • Read-only throughout; ensure_user() -> bool via rows_affected() on ON CONFLICT DO NOTHING is the correct exact-once signal.

Useful / non-vacuous — yes

  • Snapshot gauges instead of inc/dec kills gauge drift (the classic failure mode of this metric family).
  • Zero-fill from host_map means absence-of-row reads as a true 0, not a stale last value — and there are regression tests pinning exactly that semantics.
  • RELAY_ROLES/CHANNEL_TYPES/WORKFLOW_STATUSES match the actual schema enums (relay_members CHECK is owner/admin/member; channel_type and workflow_status enums match). Verified against migrations/0001.
  • Multi-pod max: vs sum: semantics are documented in code. Good.

The real concern: cardinality × cost at thousands of communities

Per community the gauges emit ~25 series (users 2 + channels 4 + messages 1 + members 3 + workflows 3 + repos 1 + active_users 6 + active_channels 2 + ws/online/subs 3). Datadog counts unique tag combos including the pod host tag, so:

5,000 communities × 3 pods × 25 ≈ 375K custom metric series, plus the write-path counters at kind × community (sparse, but real). Datadog list pricing is ~$5/100 series/mo → five figures/month. Even 1,000 communities × 3 pods ≈ 75K series.

Zero-fill makes this worse in one specific way: it guarantees the full product of series always exists — no sparsity savings for dormant communities. That's the correct choice for gauge semantics, but it means cost scales with community count, not activity.

Recommendation (can be follow-up, but decide before running on a big relay): an env knob to control the per-community family — e.g. BUZZ_USAGE_METRICS_PER_COMMUNITY=all|top:<k>|off — with fleet-wide totals always on. Or explicitly accept the cost in writing.

Secondary waste: every pod runs identical DB rollups

The DB-derived gauges are the same value on every pod (hence the max: note), so at P pods you pay P× the query cost for zero information. The event-table aggregates are the expensive ones (all-time kind-9 count scans every partition's index entries; 30d COUNT(DISTINCT) + join every tick). The 300s default and the documented rollup-table escape hatch are reasonable mitigations — but consider leader-gating the DB poll (in-memory snapshots must stay per-pod).

Nits

  1. PR body says default 60s; code is unwrap_or(300). Update the body.
  2. tokio::time::interval ticks immediately → every pod fires ~10 aggregate queries at boot, simultaneously during a rolling deploy. A small random jitter on first tick would smooth it.
  3. Unknown enum values are silently dropped by the filter_maps — if someone adds a channel type or workflow status, its counts vanish without a trace. A warn! on unmatched values would catch the drift.
  4. No idle_timeout on the Prometheus recorder, so a community removed from the table keeps its last-value series until pod restart. Minor (communities aren't deletable today).
  5. handle_create_group fallback paths increment buzz_channels_created_total on create_channel success — worth one eyes-on that a retried side-effect can't double-count vs the ingest.rs was_created increment (the fallback should be unreachable when ingest pre-created, but the comment itself says "shouldn't happen").

Bottom line

Safe to merge with respect to #1321 — no fence crossings, no client-mintable labels, read-only. Metrics are well-designed and non-vacuous. Before this runs on a relay with thousands of communities, add (or consciously decline) a cardinality control; that's the only thing standing between "useful" and "expensive."

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 10, 2026 16:35
C2: add unknown bucket to active_user_counts
  - Old SQL: LEFT JOIN + FILTER (WHERE u.agent_owner_pubkey IS NULL) counted
    pubkeys with no users row as human (NULL passes IS NULL check).
  - Fix: three-way FILTER on u.pubkey IS NOT NULL/IS NULL — profileless
    posters and agents with missing rows now land in a new 'unknown' gauge
    type rather than inflating the human count.
  - CommunityActiveUsers gains an 'unknown: i64' field; callers updated to
    emit buzz_community_active_users{type=unknown}.
  - New ignored PG test: test_active_user_counts_unknown_bucket_for_profileless_poster.

C3: rename buzz_community_users_online → buzz_community_users_online_pod
  - The gauge is pod-local (distinct within a pod, not fleet-distinct).
    The _pod suffix makes the aggregation semantics explicit at the metric name
    level so dashboard authors don't sum and expect global-distinct counts.
  - Added comment explaining fleet-wide query pattern (sum across pods).

C4: collect-before-publish in run_usage_metrics_tick
  - All DB .await? calls are now grouped at the top of the function.
    If any query fails the function returns early with no metrics emitted,
    preventing a mixed fresh/stale snapshot. In-memory snapshots
    (infallible) are taken once before the publish phase.

C5: document double-count analysis in handle_create_group
  - No code change needed: the Err(_) fallback path only fires when the
    prior ingest DB lookup returns an error, not when ingest succeeded.
    Added a comment explaining this so the invariant is auditable.

N1: fix PR body — default interval was documented as 60s, is actually 300s.

N2: jitter first tick + MissedTickBehavior::Skip
  - PID-seeded Fibonacci hash gives each pod a start delay in
    [0, interval_secs), preventing a rolling-deploy thundering herd.
  - MissedTickBehavior::Skip prevents catch-up burst if a tick runs long.

N3: warn! on unmatched enum values in filter_map
  - channel_type, relay_member role, and workflow status filter_maps now
    emit a tracing warn! for unrecognised values instead of silently
    dropping them, making schema drift immediately observable in logs.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nity knob)

C1 — cardinality fix for buzz_events_received_total and buzz_events_stored_total:
  - bounded_kind_label passes through all 10k values in 20000..=29999
    (client-controlled ephemeral range). Crossing kind × community with
    per-community tags produces up to millions of series — this PR had
    added 'community' to both counters.
  - Fix: revert both counters to {kind}-only (fleet-wide, pre-PR shape).
  - Add buzz_community_events_received_total{community} — a new counter
    with community-only label for per-community throughput graphs.
    Never cross kind × community.

K1 — BUZZ_USAGE_METRICS_PER_COMMUNITY cardinality knob:
  - New PerCommunityMode enum parsed from env at startup.
  - Three modes: 'all' (default), 'off' (fleet totals only), 'top:<k>'
    (per-community series for the k communities with most messages —
    reuses message_rows already computed each tick, no extra query).
  - Fleet-wide totals (buzz_total_*) always emit regardless of mode,
    preserving aggregate visibility at zero extra cardinality cost:
    buzz_total_users, buzz_total_channels, buzz_total_messages,
    buzz_total_relay_members, buzz_total_workflows, buzz_total_git_repos,
    buzz_total_active_users, buzz_total_active_channels,
    buzz_total_ws_connections, buzz_total_users_online_pod,
    buzz_total_subscriptions.
  - Malformed 'top:<k>' values warn! and fall back to 'all'.

PR description updated to reflect final state (C1-K1 + earlier C2-N3).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants