Overview
Spree answers analytics questions through a semantic layer: a registry of metrics (numbers) and dimensions (ways to group and filter them), and one query contract that compiles a combination of the two into SQL. Developers extend the vocabulary. Merchants, the dashboard, saved reports and future agent tools all compose sentences from it. Nobody writes a report class per question.The query contract
A query names what to measure and how to slice it. This is the whole surface:Query
Unknown members are rejected, never dropped. A malformed query answers 422,
not a partial result computed from the half the server understood.
GET /api/v3/admin/reporting/schema returns the vocabulary the calling
credential may use, with labels, per-dimension compatible metrics, filter
operators, enumerated values and time presets. Build pickers from it rather
than hardcoding member names — that is how a new dimension reaches the UI
without a dashboard change.Metrics
A metric is an aggregate over one base relation. Core registers four, in three families:
A query draws from one family only. The three answer different questions on
different clocks, so a payment total beside a units-received count is two
reports wearing one table — the query refuses the mix rather than inventing a
join between grains that have no honest relationship.
server/config/initializers/spree.rb
-
sqlis a portable aggregate fragment.%{orders},%{line_items},%{variants},%{products},%{addresses},%{product_categories},%{refunds},%{fees}and%{commission_lines}interpolate to real table names. -
formatis:money,:integer,:decimalor:percent. A:moneymetric forces a single-currency scope and arrives with a formatteddisplaystring; a:percentmetric arrives as the number a merchant reads (42.5), not the fraction. Amounts are never converted. A query containing money answers in one currency — the one it names, or the store’s default — and every base a money metric reads filters by it, or two currencies end up added together under one symbol. A store selling in several therefore has as many money answers as it has currencies, which is why the home screen offers a currency alongside its channel and date range. A query with no money in it is not scoped to a currency at all. Counts and quantities — orders, customers, units sold, payments taken — are not amounts, so narrowing them would answer “how many orders did we take” with only the share that happened to be priced in one currency. Ask forordersalone and you get every order; ask forordersbesidetotal_salesand the whole query narrows, because the money figure has to. A ratio counts as money when either side is: average order value does, sell-through does not. -
ratio: [:numerator, :denominator]defines a derived metric, computed after aggregation so it is correct for both rows and totals. Average order value isratio: %i[total_sales orders], neverAVG().
The sales chain
Core’s sales metrics follow the sequence merchants and accountants already use, so a Spree figure means what the same word means everywhere else:returns counts money refunded in the period the refund was issued, not
the period of the original order. A report over a past range can therefore
change after the fact, which is the conventional treatment and the only one an
open accounting period can produce.
cost_of_goods, gross_profit and gross_margin read the line item’s
cost_price. That column is nullable, so a variant with no cost contributes
zero and margin over an incompletely-costed catalogue reads high.
Dimensions
A dimension groups and filters. Its definition owns every behaviour keyed off it, which is what lets an extension-registered dimension work end to end:server/config/initializers/spree.rb
Authorization
Reporting never widens what a caller can see. Two axes are enforced together:- Staff (JWT) need
read_reportsplus:readon each referenced dimension’ssubject. Order data is always required. - API keys need
read_reportsplus each dimension’skey_scope.
subject without key_scope raises at registration, so a member
cannot ship with one axis unguarded. The schema endpoint filters by the same
rule, so a picker never offers a dimension whose query would be refused.
Counters
Not everything on the home screen is a report over a period. “Orders to fulfill”, “Open returns” and “Low stock” are the state of the store right now, with no time range and no currency. The registry holds these as counters beside metrics and dimensions, andGET /dashboard/counters evaluates them:
Response
- No copy crosses the wire. A counter is a key and a number; the client translates the key in its own language. An interface in one language can never end up labelling a number in another.
- Each counter carries its own
subjectandkey_scope, filtered by the same rule as the rest of the vocabulary. A role without stock access gets a shorter list, not a refused card. - The link is declared beside the count. The list a number opens shows exactly the rows that were counted, because the filter and the query are registered together — the counter and the list share one scope rather than each expressing the question their own way. A counter with no list that can honestly show what it counted carries no link.
low_stock_threshold preference (default five units; zero turns it off),
which merchants set under store settings → Inventory.
What a report counts
Two rules decide what the numbers mean: Canceled orders are excluded. A canceled order keeps itscompleted_at,
so both bases filter it out. Sales figures count what stayed sold. Refunds are
unaffected — they already net out of payment_total.
The Total row is the dimensionless figure. Grouping joins apply only to
the grouped query; totals run on the base rows with the same filters. A
product in three categories is counted once in the footer, and an order
without a shipping address still counts in “Sales by country”. A consequence
worth stating to merchants: rows of a fan-out breakdown need not sum to the
total, and that is correct.
Time buckets resolve in the store’s timezone, and a comparison period is the
range shifted back by its own length, paired bucket by bucket from the start.
Saved reports
A saved report (Spree::SavedReport, prefixed ID sq_…) is a stored query
plus a name, owned by the store and visible to every staff member who may read
reports. Its visualization is inferred from the query’s shape rather than
configured: a time dimension charts, any other dimension ranks, no dimension
shows totals.
Nine built-in reports are seeded per store. They are read-only on the model,
not merely in the UI — copy one to change it.
CSV export rides the existing export pipeline
as Spree::Exports::Report, so it inherits the background job, attachment and
email. The export re-authorizes its members against the requesting user, which
is why it requires a user and API keys cannot queue one.
Current limits
Worth knowing before you design an extension:- A new base is a registration, but a new table is not.
registry.baseadds a relation to query; the%{table}interpolation map is still fixed, so a base over your own table needs an entry added to the adapter. - Columns are identifiers, not expressions. A dimension’s
columnmust resolve totable.column; computed groupings such asSUBSTR(...)are refused. - The dashboard maps
lookupto a picker from a fixed list. A new lookup value still filters correctly, but through a plain ID input.
Related
- Extend reporting — a worked example
- Imports and exports — the CSV pipeline
- Permissions —
read_reportsand member scopes

