Analytics & admin panel¶
Status: Decided 2026-09-26; ownership, panel and moderation defaults set 2026-09-27. The backend shape (modules, telemetry, deploy, rollout) follows backend-platform.md, which wins where the two differ.
Decision¶
Three kinds of numbers, each from the place that owns the truth:
| Kind | Examples | Source of truth | Tool |
|---|---|---|---|
| System health | Request duration, DB latency, poller lag, upstream errors | The api and worker processes |
OTel traces, metrics and logs to Grafana Cloud; Sentry for panics and KindInternal |
| Business | Open proposals, deposits, platform volume, total value held, social graph size | Postgres (ledgers, proposals, followers) | Admin panel dashboards over SQL |
| Product behavior | DAU, retention, funnels, drop-off, rage taps, most-used flows | User actions in the app | PostHog Cloud, US region (iOS SDK + backend events) |
Money numbers never come from PostHog. Client analytics lose events (offline, ad-block, app killed); the ledger does not. PostHog gets money events from the backend only, for joining behavior to outcomes.
The admin panel is an internal tool for dashboards and a small set of moderation and safety actions, built on Retool first. Every action goes through the backend with a required reason, and is written to an audit log.
Ownership (default 2026-09-27): a new analytics module owns the PostHog export consumer and the business dashboards' queries. The admin module owns admins, admin_actions, dead_letters and the /v1/admin/* routes. Both are listed in backend-platform.md.
System health¶
The current backend exports Prometheus metrics (see legacy/ops-observability.md): monaco_http_request_duration_seconds per route, money event counters and volume, upstream latency, poller lag, pending backlogs, alerts to Slack/Discord and Sentry.
The rewrite replaces that pipeline. platform/observability sends traces, metrics and logs over OTLP straight to Grafana Cloud (Tempo, Mimir, Loki). Grafana alert rules replace the Slack/Discord webhook. Sentry stays for panics and KindInternal. See Deploy and observability. Logs follow Logs as evidence, so an incident is reconstructed from Loki plus the events table, joined on trace_id.
The RFC already alerts on poller_errors_total{poller,code}, the dead-letter count, relayer SOL balance, js.AccountInfo usage and p95 per route. The rewrite also emits these as OTel instruments:
| Metric | Labels | Why |
|---|---|---|
monaco_db_query_duration_seconds |
query (named statement), outcome |
DB latency, slow-query spotting. One pgx tracer in platform/db rather than per call; the same tracer counts queries per request for the Testing gate. |
monaco_db_pool_in_use, monaco_db_pool_wait_seconds |
Pool exhaustion shows up here before it shows up as latency. | |
monaco_open_proposals |
Gauge on the backlog scrape. | |
monaco_events_unpublished, monaco_bus_consumer_pending, monaco_bus_consumer_ack_pending, monaco_dead_letters |
consumer |
Relay lag and per-consumer backlog on the event bus. Full list in event-bus.md. Synadia account usage comes from the worker's js.AccountInfo gauges (NATS hosting and budget). |
monaco_paused_cabals |
reason |
External-deposit pauses and ops pauses, read from funding's pause records. |
Business metrics¶
Read-only SQL over a Postgres read replica (or the primary with a statement timeout until a replica exists). Shown in the admin panel.
- Money: deposits (count, USDC, split crypto vs card), fund-to-cabal count and USDC, platform volume (confirmed swap USDC, buys and sells), cash outs, withdrawals, total value held (sum of pot values plus platform balances), per day/week.
- Governance: open proposals, proposals created/passed/failed/expired/blocked per day, median time to pass, vote participation rate per cabal.
- Social graph: users, cabals, members per cabal, followers/following distribution, follows by source (
phone,x,cabal,feed,suggested), contact-match acceptance rate, comments and replies per day, reply depth, users in eachauth_stateandaccount_status. - Safety: paused cabals, external deposits and bounces, banned users and cabals, admin actions per day.
Metrics that need history (e.g. "members per cabal last month") come from the events table, not from current-state tables. monacoctl replay --to <event_id> rebuilds any projection as of a past event (Replay and seeded states). Dashboards are CQRS-lite reads through sqlc and never go through a command (Patterns). They live in the analytics module and read across modules only through each module's read-only query port (Dependency rules).
Product analytics (PostHog)¶
Identity. posthog.identify(<monaco user id>) after POST /v1/auth/session. Person properties: auth_state, login_provider, cabal count, created at. No email, phone, X handle or wallet address in PostHog.
Client events (iOS SDK):
- Screen views on every screen (
$screen). - One event per step of every flow, named
<flow>_<step>, with a sharedflow_idper attempt so funnels do not mix attempts. rage_tap: emitted by the app when the same control is tapped 4+ times within 1 second, with the control's identifier and screen. Alsodead_tapwhen a tap on a non-interactive element repeats. Built as a small tap tracker in the app rather than relying on autocapture.- Session replay is off (default 2026-09-27). These are money screens.
Server events (backend, PostHog server API, sent by the analytics module's consumer on the event bus, with the event id as PostHog's uuid so redeliveries dedupe). These are the authoritative "it happened" for funnels that end in money or social actions. Each comes from one subject in the events registry:
| PostHog event | Bus subject (Flows) |
|---|---|
deposit_credited |
deposit.credited (flow 5) |
cabal_funded |
cabal.funded (flow 7) |
proposal_passed |
proposal.passed (flow 10) |
trade_executed |
trade.confirmed (flow 11) |
cash_out_completed |
cashout.completed (flow 14) |
withdrawal_sent |
withdrawal.confirmed (flow 15) |
follow_created |
follow.created (flow 20) |
comment_created |
comment.created (flow 21) |
The RFC's flow table lists analytics as a consumer on every one of these flows (default 2026-09-27 added it to flows 7, 10, 11, 14, 20 and 21), so monacoctl flows check holds each export to a test.
The PostHog call is an outbound HTTP call, so it sits behind a port with an anti-corruption adapter, circuit breaker and retry (Patterns). A PostHog outage is a retryable KindUnavailable code: bus.Dispatch naks with backoff and never blocks another consumer.
Flows to instrument. Each gets a funnel in PostHog:
| Flow | Steps |
|---|---|
| Onboarding | login_started → login_completed → phone_shown → phone_verified / skipped → x_shown → x_linked / skipped → contacts_permission → follow_suggestions_shown → first_follow |
| Crypto deposit | deposit_opened → crypto_selected → address_copied → (server) deposit_credited |
| Card deposit | deposit_opened → card_selected → page_opened → privy_auth → fund_confirmed / cancelled → (server) deposit_credited |
| Join cabal | cabal_viewed → join_tapped → request_sent / joined → fund_sheet_opened → (server) cabal_funded |
| Propose | propose_opened → asset_selected → amount_entered → thesis_entered → submitted |
| Vote | proposal_viewed → vote_cast |
| Trade outcome | (server) proposal_passed → trade_executed / blocked |
| Cash out | cash_out_opened → amount_entered → confirmed → (server) cash_out_completed |
| Withdraw | withdraw_opened → address_entered → confirmed → (server) withdrawal_sent |
| Feed | feed_opened → item_opened → comment_opened → comment_posted |
| Social | profile_viewed → follow_tapped; suggestion_shown → suggestion_followed / dismissed |
| Chat | chat_opened → message_sent |
The list of flows lives here; adding a screen means adding its steps here and in the app's event enum, so names never drift.
Standard reports: DAU/WAU/MAU, stickiness (DAU/MAU), D1/D7/D30 retention by signup week and by login_provider, funnel drop-off per flow, most-used flows, rage-tap hotspots by screen.
Admin panel¶
Access¶
- Separate web app at
admin.monacolabs.xyz, not reachable from the consumer app. - Login with Privy (Google), then the backend checks the user against an
adminstable (user_id,role:viewer,moderator,operator). No admin rights from a client flag or an env list baked into the frontend. - Admin API under
/v1/admin/*, same backend, separate middleware: admin role check, per-admin rate limit, all requests logged. Routes live inapi/openapi.yamllike every other route. A failed role check is aKindForbiddencode (403) from theerrstable (Errors). Theadminmodule ownsadmins,admin_actionsand the dead-letter queue (Repository layout). - Each admin route lives in the HTTP adapter of the module that owns the data it changes (an ops pause in
funding, a proposal void ingovernance), mounted under/v1/admin/*behind the shared admin middleware thatadminexports.admindoes not proxy other modules' commands.
Actions¶
| Action | Role | Effect | Limits |
|---|---|---|---|
| Pause / resume trading (cabal) | operator | Writes a pause record with reason = ops through the funding module, which owns every pause (default 2026-09-27). Same record the external-deposit bounce uses (deposits-withdrawals.md); trading and treasury read it through funding's query port at check time: no executions, no funding, no cash outs. There is no cabals.trading_paused_at column. Global switch for all cabals too. |
Resume only clears an ops pause, never an unresolved external-deposit pause. One operator is enough, so a pause is fast. |
| Void a proposal | moderator | Status voided with reason. Removed from voting; feed item shows "Voided by Monaco". |
Allowed while open or passed with no live transaction. Once a swap is submitted, it cannot be voided; it finishes or fails. |
| Ban a user | moderator | account_status → banned (auth.md). Middleware blocks funding, voting, proposing and commenting. Their open proposals voided. |
They can still withdraw and cash out (decided 2026-09-27). The money is theirs. |
| Ban a cabal | operator, plus a second operator's approval | Cabal status = banned. Hidden from lists and feed; trading paused permanently. Then wind-down (default 2026-09-27): sell every holding and return USDC to each member pro rata through the normal cash-out path (flow 14). |
Two-person approval (default 2026-09-27): the ban takes effect only when a second operator approves. Members' money always comes back; the wind-down moves it through the product path, not an admin transfer. |
| Delete a comment | moderator | Soft delete (deleted_at, deleted_by = admin). Renders as "Removed by Monaco". |
|
| Delete a proposal | moderator | Soft delete: hidden from cabal and feed. | Executed proposals are linked to ledger rows and cannot be deleted, only hidden. |
Every action:
- Requires a free-text reason.
- Is a Command with an
IdempotencyKey, handled by the module that owns the state (for examplegovernance'sVoidProposal, flow 13), with the same guarded updates the product uses. The request context carries actoradmin(Context rules). The admin API adds no second path for changing state. - Appends
admin.action(flow 26), plus the domain event such asproposal.voided, inside the sameuow.Doas the state change. - The
adminmodule's consumer onadmin.actionwrites theadmin_actionsrow (admin_id,action,target_type,target_id,reason,before,after,created_at) (default 2026-09-27). Theeventsrow is the atomic record;admin_actionsis a projection of it, one relay hop behind. This keeps the module walls: the owning module never writes anadmintable (Dependency rules). - The usual consumers react to the same domain events. Only events in What notifies push: an ops pause pushes cabal members through
cabal.paused; a void pushes nobody for MVP.
No admin action can move money. Moving funds stays in the ops runbooks (ops-sweep-usdc.md, whose script was deleted with the legacy backend).
The admin module lands in Rollout step 6. The telemetry pipeline lands with the scaffold in step 1.
Views¶
- Dashboards: the business metrics above.
- Lookups: user (profile,
auth_statehistory, cabals, balances, recent actions), cabal (members, pot, holdings, proposals, pause state, external deposits), proposal (votes, comments, linked transaction), transaction (signature, explorer link, status history). - Queues: external deposits pending bounce, stuck transactions, unpublished
eventsrows older than 30 s, and dead letters (flow 27).bus.Dispatchwrites a termed message and its errorcodeto theDEADLETTERstream, and theadminmodule records each one in itsdead_letterstable with a resolve state (default 2026-09-27), so the queue outlives the stream's 30-dayMaxAge(NATS hosting and budget). The redrive button runs the same path asmonacoctl deadletter retry, back throughbus.Dispatch, soevent_deliveriesstill dedupes, and marks the row resolved on success. - User-facing reporting of comments, proposals and users, and its moderation queue, is deferred past launch (default 2026-09-27).
Build¶
Retool first (default 2026-09-27), on top of /v1/admin/*. The actions are already backend endpoints, so Retool is only a UI; nothing to host on Render. Data passes through Retool, so the admin API returns no more PII than a lookup needs. A custom app in apps/admin is the later option if Retool limits bite.
Alternatives considered¶
| Alternative | Why not |
|---|---|
| Money metrics from PostHog | Client events drop. Ledger is the truth. |
| Admin actions as direct SQL | No audit trail, bypasses guarded updates and emits no events, so no consumer ever reacts. |
| Custom React admin app now | More work before launch for screens Retool gives for free. Revisit when Retool limits bite. |
| Admin routes inside the consumer app gated by a flag | Bigger attack surface on the public app, and a client flag is not an authorization check. |
| Hard deletes of user content | Breaks threads, feed history and ledger links. users, chat messages and follows soft delete through deleted_at (decided 2026-09-27), as do comments and proposals above. Ledger rows are never deleted. |
| Self-hosted PostHog | The RFC hosts nothing itself. PostHog Cloud, US region (default 2026-09-27). |
| PostHog autocapture only | Event names drift with UI changes and funnels break silently. Named step events per flow are stable. |
Open questions¶
None.
Log¶
- 2026-09-29: Admin routes live in the owning module's HTTP adapter behind shared admin middleware from
admin(default; see #535). - 2026-09-27: Decided:
users, chat messages andfollowssoft delete throughdeleted_at; other tables keep their current delete behavior; ledger rows are never deleted. Narrowed the hard-delete alternative to match. - 2026-09-27: Closed the last open question (default 2026-09-27): PostHog Cloud runs in the US region.
- 2026-09-27: Applied decisions. Decided: banned users can withdraw and cash out. Defaults: new
analyticsmodule owns the PostHog export and dashboards; ops pause is afundingpause record withreason = ops, notrading_paused_at;admin_actionswritten by anadminconsumer ofadmin.action;dead_letterstable with resolve state next to theDEADLETTERstream;analyticsadded to flows 7, 10, 11, 14, 20, 21 in the RFC; Retool first; banned-cabal wind-down through cash out; two-person approval for cabal bans only; PostHog Cloud; session replay off; user reporting deferred. Open question left: PostHog region. - 2026-09-27: Reconciled with backend-platform.md: system health moves from Prometheus and Slack/Discord to OTel over OTLP into Grafana Cloud with Grafana alerts, Sentry kept for panics and
KindInternal; PostHog server events mapped to bus subjects, withanalyticsmissing from sixflows.tsvconsumer cells; admin actions are Commands with actoradmininsideuow.Do; dead letters come from theDEADLETTERstream with redrive throughbus.Dispatch; rollout step 6. Added open questions on analytics ownership, whereadmin_actionsis written, and panel hosting. - 2026-09-26: Outbox rows replaced by
eventsrows delivered over the NATS event bus (event-bus.md). - 2026-09-26: Initial decision. Prometheus for system health, Postgres for business metrics, PostHog for behavior; admin panel with audited, reason-required actions through the normal backend services.