Skip to main content

Overview

Reporting is extended by adding vocabulary, not by writing report classes. Register a metric or a dimension in an initializer and it becomes available everywhere at once: the Reports builder, saved reports, CSV export and the schema endpoint. Read how reporting works first — this guide assumes the query contract and the metric/dimension split.

Add a metric

A metric is an aggregate over one registered base — :orders, :line_items, :payments or :stock_movements:
config/initializers/spree.rb
That is the whole change. The metric appears in GET /reporting/schema, in the builder’s metric list, and can be saved into a report. Give it a label so merchants do not read the raw name:
config/locales/en.yml

Derived metrics

A ratio is computed after aggregation, so it stays correct for both rows and totals — which a plain AVG() would not:

Add a dimension

A dimension groups and filters. The simplest kind is a column on the base table:

A dimension that identifies records

When the keys are IDs rather than plain values, declare four things together: how to reach the table, how to resolve a filter value, how to render the key, and who may see it.
What each part buys you:
  • joins is applied only to the grouped query, so the Total row stays the store’s real figure even when the join fans out.
  • resolve lets a filter accept the prefixed IDs clients already hold. Scope it through the store, so an ID from another store is a 404.
  • hydrate is batched once per dimension per response, not per row.
  • subject and key_scope are mandatory together. Staff need :read on the subject; API keys need the scope. Omitting key_scope raises at registration rather than shipping a member that only guards one axis.

Status-like dimensions

Publish the values so the builder can offer checkboxes instead of a text box:
Use a lambda so model constants load lazily. Label the values under spree.reporting.values.<dimension>.<value>; they are translated server-side, so a plugin needs no dashboard locale edit.

Verify it

Query the contract directly:
Or over HTTP:
A spec is worth writing for the parts that are easy to get wrong — the authorization declaration and the hydration payload:
spec/lib/my_extension/reporting_spec.rb

Add a counter

A counter is a point-in-time number for the home screen’s Operations card: no time range, no currency, no base. It takes the store and the channel the merchant is looking at and returns an integer.
The endpoint sends the key and the number, never any text — the dashboard owns every string it shows. Add admin.pages.home.operations.counters.orders_on_hold.label, and optionally .description, to your dashboard locale files; a counter with no entry falls back to its humanized key, so it reads sensibly before you translate it. Pass nav: with a sidebar entry’s key to badge that entry with this count. Declare link only when the list filter shows exactly the rows counted — the dashboard resolves resource to one of its lists (orders, returns, exchanges, claims, products, inventory) and appends the channel filter itself for order lists.

Overriding a built-in member

Registration refuses a duplicate name unless you say so explicitly:
Redefining what a built-in number means changes every saved report using it, including the seeded ones. Prefer a new name unless you intend exactly that.

What you cannot do yet

These are limits of the compiler, not of registration:
  • The %{table} map is fixed. Registering a base over your own table needs an entry added to the adapter’s interpolation map — the one place a new table still has to be named in core.
  • A dimension’s column must be a plain identifier. Computed groupings such as SUBSTR(email, ...) are refused; add a real column, or a database view, if you need one.
  • Custom lookup values fall back to an ID input in the builder, since the dashboard maps known lookups to pickers.
Registering a base is otherwise ordinary: declare its family, its table, a store-scoped relation, its time column and which bases its dimensions reach. The relation’s currency argument is nil when the query contains no money metric, and a base must then not filter by it — a count restricted to one currency is a wrong answer, not a narrower one. Guard the filter rather than passing nil into where, which would match nothing:
Give it its own family unless its rows genuinely answer the same question, on the same clock, as an existing one.