feat(relay): per-community usage metrics#1723
Conversation
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>
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>
|
Reviewed at head Tenant safety (vs #1321) — clean
Useful / non-vacuous — yes
The real concern: cardinality × cost at thousands of communitiesPer 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:
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. Secondary waste: every pod runs identical DB rollupsThe DB-derived gauges are the same value on every pod (hence the Nits
Bottom lineSafe 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." |
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>
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)buzz_communities_totalCOUNT(*)on communitiesbuzz_community_userscommunity,type:human|agentdeactivated_at IS NULL; agent =agent_owner_pubkey IS NOT NULLbuzz_community_channelscommunity,type:stream|forum|dm|workflowdeleted_at IS NULLbuzz_community_messagescommunitykind=9,deleted_at IS NULLbuzz_community_relay_memberscommunity,rolebuzz_community_workflowscommunity,statusbuzz_community_git_reposcommunitybuzz_community_active_userscommunity,window:1d|7d|30d,type:human|agent|unknownCOUNT(DISTINCT pubkey)on events;unknown= pubkeys with nousersrow (profileless posters)buzz_community_active_channelscommunity,window:1d|7dFleet-wide totals (always emitted regardless of
BUZZ_USAGE_METRICS_PER_COMMUNITY)buzz_total_userstype:human|agentbuzz_total_channelstypebuzz_total_messagesbuzz_total_relay_membersrolebuzz_total_workflowsstatusbuzz_total_git_reposbuzz_total_active_userswindow,typebuzz_total_active_channelswindowbuzz_total_ws_connectionsbuzz_total_users_online_podbuzz_total_subscriptionsIn-memory snapshot gauges (same poller tick, from live state)
buzz_community_ws_connectionscommunitybuzz_community_users_online_podcommunitybuzz_community_subscriptionscommunitySnapshot pattern used instead of inc/dec to eliminate gauge drift from asymmetric call paths.
Write-path counter tags
communitytag 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 throughputNew counters
buzz_users_created_total{community}— incremented only on first insert (ensure_userreturnstrue)buzz_channels_created_total{community, type}— incremented only on actual creation (ingest.rswas_createdpath, side_effects.rs new-channel paths)Bug fix
Kind
44200(agent turn metrics) now maps to"44200"inbounded_kind_labelinstead of collapsing to"other".Cardinality knob
BUZZ_USAGE_METRICS_PER_COMMUNITYcontrols how many per-community gauge series emit:all(default)offtop:<k>Fleet-wide
buzz_total_*metrics always emit regardless of mode — Datadog cost lever without losing aggregate visibility.Implementation notes
ensure_user()return type changed fromResult<()>toResult<bool>(true = new insert, false =ON CONFLICT DO NOTHING). All callers updated.GROUP BY community_id— one query per metric family, not one query per community.metrics::gauge!(...).set(...)call. If any query fails the tick aborts cleanly — no mixed fresh/stale snapshot.[0, interval_secs)to prevent thundering herd on rolling deploy.MissedTickBehavior::Skipprevents catch-up burst.buzz_events_received_totalandbuzz_events_stored_totalkeep{kind}only (fleet-wide).bounded_kind_labelpasses through 10k values in20000..=29999(client-controlled); crossing withcommunitywould produce millions of series. Usebuzz_community_events_received_total{community}for per-community throughput.buzz_community_users_online_podis distinct within a pod (not fleet-distinct). Sum across pods for fleet total, with caveat that multi-pod pubkeys count N times.active_user_countsSQL 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.filter_maps emitwarn!log entries instead of being silently dropped.max:in Datadog (all pods report same DB value); in-memory gauges usesum:(per-pod partials). Documented in code comments.community(host string), nothost— avoids collision with Datadog's reserved infra host tag.