Skip to content

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 via observability.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.