Analytics¶
Tomoda's product-analytics signal lives in two complementary places, both already deployed:
- Prometheus counters + gauges — aggregates, rates, alerting. Defined in
backend/internal/observability/business.go. - Loki structured logs — forensic timeline (
{app_event="event_joined", user_id="..."}queryable in Grafana). Emitted viaobservability.RecordAppEvent.
Together they replace the retired user_activity_logs write-only table. Sentry continues to own errors, crashes, performance traces, and session-quality signals (Replay, Insights) — distinct concern, distinct destination.
When to use what¶
| Need | Tool | Why |
|---|---|---|
| "How many event-joins per minute?" | Counter tomoda_events_joins_total + Grafana rate panel |
Aggregate question — Prometheus is built for this |
| "Which users joined what events last week?" | Loki query {app_event="event_joined"} |
Forensic per-user timeline — Loki labels handle it |
| "DAU / new users / users by country" | Gauges refreshed by the user_stats cron |
Cheap periodic Postgres aggregate; powers the analytics dashboard |
| "Did a feature flag rollout regress crash-free %?" | Sentry Release Health | Per-release crash bucketing — Sentry's job |
| "Funnels, retention cohorts, A/B" | Add Posthog later when there's a real question | We don't have it today; Loki+Grafana covers ~70% |
Emitting an app event¶
Pair a Prometheus counter (aggregate) with a Loki structured log (forensic). Most call sites do both at the same call:
import (
"github.com/tomoda-labs/tomoda/internal/logger"
"github.com/tomoda-labs/tomoda/internal/observability"
)
observability.RecordFriendAction("sent")
observability.RecordAppEvent(ctx, "friend_request_sent", userID,
logger.String("recipient_id", recipientID.String()),
)
Conventions:
- The Loki log message is always "app_event"; the actual event name lives in the app_event structured field so a single Grafana panel can target it.
- Keep counter labels low-cardinality (action, method, status). Never add user_id, email, ip as a Prometheus label — those go in Loki fields instead.
- A new event without a stable aggregate shape can skip the counter and emit only RecordAppEvent (forensic-only).
Catalog¶
Counters (business.go)¶
| Metric | Labels | Source |
|---|---|---|
tomoda_auth_registrations_total |
method |
auth_service.go |
tomoda_auth_logins_total |
method, status |
auth_service.go |
tomoda_auth_otp_sent_total |
purpose |
otp_service.go |
tomoda_chat_messages_sent_total |
room_type |
chat_service.go |
tomoda_events_created_total |
— | event_service.go::CreateEvent |
tomoda_events_joins_total |
status |
event_service.go::JoinEvent |
tomoda_friends_actions_total |
action (sent / accepted / declined / blocked) |
friend_service.go |
tomoda_travel_log_writes_total |
source (moment / stamp / tagged_stamp / checkin / event / ...), country (ISO 3166-1 alpha-2 of the visited location) |
travel_log_service.go::Record |
tomoda_payments_total |
status |
payment_service.go |
Gauges — population (business.go, refreshed every 1 minute by cron:user_stats_refresh)¶
| Gauge | Labels | Source |
|---|---|---|
tomoda_users_total |
— | SELECT COUNT(*) FROM users WHERE deleted_at IS NULL |
tomoda_users_active_24h |
— | SELECT COUNT(*) FROM user_profiles WHERE last_active_at > NOW() - 24h |
tomoda_users_new_24h |
— | SELECT COUNT(*) FROM users WHERE created_at > NOW() - 24h |
tomoda_users_by_country |
country (ISO 3166-1 alpha-2) |
SELECT country, COUNT(*) GROUP BY country |
tomoda_users_active_by_country |
country |
DAU by country: users joined with user_profiles.last_active_at > NOW() - 24h |
tomoda_friends_friendships_accepted |
— | SELECT COUNT(*) FROM friendships WHERE status='accepted' — current friend graph size |
tomoda_friends_friendships_pending |
— | SELECT COUNT(*) FROM friendships WHERE status='pending' — open requests |
tomoda_friends_friendships_avg_per_user |
— | accepted * 2 / users_total — graph density |
tomoda_activity_hotspot_1h |
lat, lng (0.5° cell centre) |
Distinct users with Redis user:location:* updated within last hour, bucketed to a 0.5° grid — powers the 1h hotspot Geomap |
tomoda_activity_hotspot_24h |
lat, lng |
Same as 1h but trailing 24h window |
App-event log lines (Loki — {app_event="..."} selector)¶
app_event |
Where it's emitted | Fields beyond user_id |
|---|---|---|
event_created |
event_service.go::CreateEvent |
event_id, category, visibility |
event_joined |
event_service.go::JoinEvent |
event_id, status, days_before_event |
friend_request_sent |
friend_service.go::SendRequest |
recipient_id |
friend_request_accepted |
friend_service.go::AcceptRequest |
friend_id, time_to_accept_seconds |
location_checkin |
friend_service.go::UpdateLocation |
source |
location_outdated_reported |
location_handler.go::ReportOutdated |
location_id |
travel_log_write |
travel_log_service.go::Record |
source, country, city |
Dashboard¶
The Grafana dashboard tomoda-analytics lives in the devops repo (k8s/envs/platform/monitoring/dashboards/tomoda-analytics.json) and pulls these metrics + Loki streams into a single board: user counts, login funnel, event lifecycle, friend graph, travel-log activity, world map of users by country, and a live app_event log panel.
Migration note¶
services.ActivityLogService and the user_activity_logs table were retired in favour of this two-layer model. The table was write-only (no reader ever consumed it); the events that mattered are now captured here as counters + structured logs, the others (app_opened heartbeats) were redundant with tomoda_auth_logins_total and Sentry session tracking.