# Tracking Business Metrics in Rails Without a Full Analytics Stack ## Content # Tracking Business Metrics in Rails Without a Full Analytics Stack Most Rails apps eventually need charts that are not quite analytics, not quite observability, and not quite BI. Orders per day. Revenue per customer. Background jobs completed. Imported products. Feature usage. Tenant-level activity. You have a few options. You can `GROUP BY DATE(created_at)` your way through production tables until the queries get slow. You can push counters to Prometheus and pretend a business metric is an infrastructure metric. You can wire up a full analytics stack with event pipelines and a warehouse. Or you admit what you actually want: simple app-owned metrics with time rollups, queryable from Ruby. That is why `Trifle::Stats` exists. ## The Trifle idea `Trifle::Stats` is a Ruby gem for tracking custom application metrics from code and retrieving them as time-series values. You give it a key, a timestamp and a hash of values. It increments those values across every granularity you configured: hourly, daily, weekly, monthly, whatever you need. Retrieval gives you the timeline back as plain Ruby data. The part that makes it different from a plain counter API: `values` is not a single number. It is a hash, and it can be nested. One `track` call increments a whole tree of counters under a single key, and later you aggregate over any path in that tree. Plenty of tools can count. Not many let you count the total, the per-payment-method breakdown and the per-channel breakdown in one write. No events table. No pipeline. No separate service to deploy. It writes to storage you already run: Redis, Postgres, MySQL, MongoDB or SQLite. Configuration is one initializer: ```ruby # config/initializers/trifle.rb Trifle::Stats.configure do |config| redis = Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) config.driver = Trifle::Stats::Driver::Redis.new(redis) config.granularities = ['1h', '1d', '1w', '1mo'] config.time_zone = Rails.application.config.time_zone end ``` ## A minimal Rails example Say you want to know how your orders are doing. Not just how many, but broken down by payment method and sales channel. Track it where the order completes: ```ruby Trifle::Stats.track( key: 'orders::completed', at: Time.zone.now, values: { count: 1, revenue_cents: order.total_cents, payment: { order.payment_method => { count: 1, revenue_cents: order.total_cents } }, channel: { order.channel => { count: 1, revenue_cents: order.total_cents } } } ) ``` That is the whole integration. One key, one write per order. Every call increments the totals and the nested breakdowns in the hourly, daily, weekly and monthly buckets for that timestamp. After a day of orders, the daily bucket looks like this: ```ruby {"count" => 128, "revenue_cents" => 1034400, "payment" => { "card" => {"count" => 97, "revenue_cents" => 801200}, "paypal" => {"count" => 31, "revenue_cents" => 233200}}, "channel" => { "web" => {"count" => 84, "revenue_cents" => 691800}, "mobile" => {"count" => 44, "revenue_cents" => 342600}}} ``` With a plain counter tool this would be five or six separate metrics you have to keep in sync and query one by one. Here it is one document per bucket, incremented atomically. When you want the data back, ask for a timeframe and granularity: ```ruby Trifle::Stats.values( key: 'orders::completed', from: 30.days.ago, to: Time.zone.now, granularity: '1d' ) ``` You get back a hash with `at` (list of timestamps) and `values` (list of value hashes like the one above), ready for a chart or a quick console answer to "how did last month look?". ## Aggregating over nested values Retrieving buckets is half the story. The other half is asking questions across them, and this is where the nesting pays off. `Trifle::Stats.series` wraps the same data with aggregators that take a dot-separated path into the tree: ```ruby series = Trifle::Stats.series( key: 'orders::completed', from: 30.days.ago, to: Time.zone.now, granularity: '1d' ) # Total revenue over 30 days series.aggregate.sum(path: 'revenue_cents') # Card orders over 30 days series.aggregate.sum(path: 'payment.card.count') # Average daily mobile revenue series.aggregate.mean(path: 'channel.mobile.revenue_cents') # Best day by order count series.aggregate.max(path: 'count') ``` Any branch you tracked can be queried later without defining it as a separate metric upfront. You did not define "card orders per day" as a metric anywhere. It fell out of the data model. ## Why not just use X? Fair question. Developers ask it immediately, and the honest answer is that `Trifle::Stats` does not replace any of these. It fills the gap between them. | Tool | Good for | Where Trifle fits differently | |------|----------|-------------------------------| | SQL queries | Source-of-truth reporting | Trifle stores pre-aggregated rollups, so reads do not need to scan raw event or order tables | | Logs | Debugging individual events | Trifle gives you structured counters over time | | Prometheus | Infrastructure and service metrics | Trifle focuses on product and business metrics your app owns | | OpenTelemetry | Traces/metrics/logs pipelines | Trifle is simpler app-level stats, no collector to run | | BI tools | Dashboards over production data | Trifle lets developers model metrics in code, next to the logic that produces them | If you already run a warehouse and a BI stack that answers these questions, keep it. `Trifle::Stats` is for the cases where standing all that up is overkill, but `GROUP BY` on a 50M row table is underkill. And to be clear about the boundaries: `Trifle::Stats` is not for everything. If you need raw event replay, user-level behavioral analytics, distributed tracing or ad-hoc questions over dimensions you did not track upfront, reach for the tools built for that. It stores pre-aggregated rollups, which means you decide what to count when you write, not when you query. ## Model your data before you track it One of the hardest parts of tracking anything is deciding the shape of your data, and it is worth doing before the first `track` call. Nesting is powerful, but it has one rule: only nest what has a small, fixed cardinality. Payment methods and sales channels are perfect. There are five of them and there will be five of them next year. The whole breakdown lives comfortably in one document and one query feeds the whole dashboard. But if the subcategory can grow without bound, customers, tenants, products, do not stuff it into the payload. Every new tenant would add a branch and each document would keep growing forever. Put the entity into the key instead: ```ruby Trifle::Stats.track( key: "orders::completed::tenant::#{tenant.id}", at: Time.zone.now, values: { count: 1, revenue_cents: order.total_cents } ) ``` Track both the global key and the per-tenant key and you get the overview and the drill-down for the price of two increments. Small decisions like this early on save you from repainting your metrics later. Getting them wrong is annoying because historical data does not restructure itself. ## Where to go next The [getting started guide](https://docs.trifle.io/trifle-stats-rb/getting_started) covers driver configuration, granularities, storing values, retrieving values and working with series and aggregators. The docs also include [case studies](https://docs.trifle.io/trifle-stats-rb/case_studies), like how we track around 100M background jobs a day with it at DropBot. And when the console stops being enough, you do not have to build the rest yourself. [Trifle App](https://trifle.io/product/app) sits on top of the same data: it connects to the PostgreSQL or MongoDB where your stats already live, and gives you drag-and-drop dashboards, threshold and anomaly alerts delivered to Slack or email, and scheduled report digests. The gem stays the developer half; the app covers the part where someone asks "can I get this as a dashboard?" and you would rather not maintain one. Start with one metric. The one you keep computing by hand in the console is a good candidate.