Skip to content

Powered by Grav CMS + Helios Theme

Analytics

The Analytics screen compares store performance over time. Choose a date range and comparison period, apply filters, and select a breakdown. Each figure includes its definition.

This page and Reports and the rollup are the two halves of reporting. Reports and the rollup is where the numbers come from: the nightly day rows, the watermark, and how to rebuild them. This page is comparison periods, filters, breakdowns, where an order came from, cost of goods and margin, and saved views. The two meet in the "read from the rollup or scanned" line at the top of every report.

The screen in one paragraph

Pick a date range, pick something to compare it against, optionally add filters, and optionally pick a dimension to break it down by. Every figure comes back three ways: its value, the same figure for the comparison period, and the change between them. Every figure also carries the sentence that defines it, printed on the card.

Every metric, defined

The admin prints these sentences off the metric itself, the get_report_vocabulary MCP tool publishes the same text, and this table comes from the same registry.

Metric Unit Needs cost of goods Definition
revenue money no The total of every order in the range that reached a status flagged complete and whose payment was not fully refunded, counted on the day the order completed. A partially refunded order keeps its full total here and its credit appears under Refunds.
orders count no How many orders the Revenue figure is made of: orders in the range that reached a status flagged complete and whose payment was not fully refunded, counted on the day the order completed.
aov money no Revenue divided by Orders, rounded down to the nearest minor unit. Null when there were no orders, because an average of nothing is not zero.
items count no How many units were sold across the orders behind Revenue, counting the current lines of an edited order once each.
refunds money no The total of every successful refund transaction raised in the range, on the day it was raised, whatever the date of the order it credits. Positive, and never netted off Revenue here.
refund_rate percent no Refunds as a share of Revenue, in basis points, where 250 is 2.5%. Null when there was no revenue in the range to measure against.
tax money no The total of every tax adjustment on the orders behind Revenue, including tax that was already inside the price. It is what the store charged, not what it owes.
shipping money no The total of every shipping adjustment on the orders behind Revenue: what customers were charged to have their orders delivered or collected, not what the carrier charged the store.
discounts money no The total of every discount adjustment on the orders behind Revenue, from sales, coupons and price lists alike. Negative, because a discount is a negative adjustment on an order.
cogs money yes What the units sold cost the store, at the unit cost frozen onto each line when it was added to the basket. A line whose product had no recorded cost contributes nothing, so this figure is a floor rather than a total on a catalogue that is only partly costed.
margin money yes Revenue minus Cost of goods. It carries the same caveat: a catalogue that is only partly costed reports a margin that is too high, because the uncosted lines cost nothing.
margin_rate percent yes Margin as a share of Revenue, in basis points, where 4200 is 42%. Null when there was no revenue in the range to measure against.

Three things to keep in mind when reading them.

Refunds are not netted off revenue. A month with £10,000 of sales and £400 of refunds reports £10,000 and £400, not £9,600.

A percent metric is basis points. 250 is 2.5%. Money is minor units everywhere in KahunaCart.

Null is not zero. Average order value on a day with no orders is null, and the screen prints a dash.

Per currency and in base currency

The currency picker remembers each admin's choice. A code such as ?currency=EUR shows euro orders only, and leaving it out shows the base currency. ?currency=all adds every rated order in the base currency, using each order's own frozen fx_to_base rate, exact decimal addition and one rounding at the end. Orders with no rate are left out and counted beside the result. Refunds without a rate are counted separately. See Reports and totals.

Cost is always the base currency's. A native report in another currency has no cost or margin, so cogs, margin and margin_rate are null and the screen prints a dash. They are absent there, not wrong. The converted view takes the base-currency cost off the converted revenue, for the rated orders alone. A total money filter uses the selected currency's minor units. In the converted view it compares the exact converted order amount with a threshold in the base currency. A saved view remembers range.currency, and an older view with no currency opens in the base currency.

In a breakdown by currency, only the base row has cost or margin. Currency rows start in currency-code order, their money columns can't be sorted against each other, and they are never combined into an "other" row.

Comparison periods

Mode What it compares against
previous_period The same number of days immediately before the range. 1–31 March compares against 30 January – 28 February.
previous_year The same dates a year earlier. 1–31 March 2025 compares against 1–31 March 2024.
none Nothing. The cards show the range's figures and no change.

reports.default_comparison sets which one a report opens on.

Pick previous_year for a seasonal store. A florist comparing February against January is comparing Valentine's Day against nothing.

The timezone rule, which is the part that bites

A comparison is calendar arithmetic, and it happens in the store's timezone. Not UTC, and not the browser's.

  • Storage is UTC epoch throughout.
  • reports.timezone says where the store's days begin. Empty falls back to Grav's system.timezone, and then to UTC.
  • Every range boundary, and every comparison range derived from one, is worked out in that zone and converted back to epoch to query.

Three consequences follow.

A day is not always 86,400 seconds. In a zone that observes daylight saving, a spring-forward week is one hour shorter than the week it is compared against, and an autumn week is one hour longer. This is correct: the week really was shorter.

A leap day is a real day. 29 February 2024 compared against the previous year is 28 February 2023, because there is no 29 February 2023.

Changing reports.timezone moves orders between days. An order placed at 00:30 UTC belongs to the previous day in New York. Change the setting and yesterday's revenue changes. Do not change it casually on a store that has been reporting for a year.

Filters

A filter set is a match mode and a list of clauses.

Match Meaning
all Every clause has to hold. The default.
any At least one clause has to hold.

Each clause is a field, an operator and a value.

Field Type Operators Can break down by Grain
status text eq, ne, in, not_in scan order
payment_status text eq, ne, in, not_in scan order
fulfillment_status text eq, ne, in, not_in scan order
provider text eq, ne, in, not_in rollup order
currency text eq, ne, in, not_in rollup order
total money eq, ne, gt, gte, lt, lte no order
email text eq, ne, in, not_in, contains, not_contains, starts_with, ends_with no order
attribution_source_type select eq, ne, in, not_in scan order
attribution_source text eq, ne, in, not_in, contains, not_contains, starts_with, ends_with scan order
attribution_medium text same as attribution_source scan order
attribution_campaign text same as attribution_source scan order
attribution_device text eq, ne, in, not_in scan order
customer_group text eq, ne, in, not_in scan order
product product eq, ne, in, not_in scan line
category category eq, ne, in, not_in scan line
country text eq, ne, in, not_in not yet order

A clause naming a field that is not on that list is refused with a 422 that names the field. It is never ignored.

Note

country is declared and not yet available. An order keeps its address as a frozen JSON snapshot rather than as columns, so there is nothing to group on until a migration extracts one. The admin lists the field as unavailable and says why.

Line-grain fields

product and category are properties of an order line, not of an order. Filtering by one restricts the report to orders that contain a matching line, and the money is still the whole order's. Filtering by "orders containing a hat" gives you the revenue of those orders, not the revenue of the hats.

Breaking down by product or category is the other way round. See the next section.

Breakdowns

Pick a field with a value in the "Can break down by" column above and the report gains a table with one row per value.

  • rollup means the breakdown can be read out of the nightly rollup when nothing is filtered and the range is entirely rolled up. Currency and payment provider are dimensions of kahunacart_order_stats, so they are free.
  • scan means it is worked out from the orders table.

The report says which happened, per breakdown, and never quietly falls back.

Every breakdown row carries the metrics its carries list names, worked out from that row's own measures. Computed metrics such as aov and margin_rate arrive on the row rather than as something a client has to divide out for itself.

A line-grain breakdown, by product or by category, carries only the metrics a line knows: revenue, orders, items, average order value, cost of goods, margin and margin rate. It does not carry tax, shipping, discounts or refunds, because those sit on the order rather than on the line. Those keys are left off the row entirely rather than printed as zero, and the table says so above it.

Breakdown rows are capped. A store with forty thousand SKUs asking for a breakdown by product gets the top of that list, not forty thousand rows in a browser.

Where an order came from

reports.attribution.enabled records, against each order, where the visitor that placed it arrived from. It ships on.

Bucket Means
organic Arrived from a search engine.
referral Arrived from another site.
utm Arrived on a link carrying utm_ parameters.
typein Arrived with no referrer at all: typed, bookmarked, or from an app that sends none.
admin The order was created in the admin rather than by a shopper.
agent A shopping agent built the basket: a UCP platform such as Google AI Mode, or an MCP client. See Agentic commerce.
unknown Recorded before this feature existed, or captured with nothing to go on.

Alongside the bucket, an order keeps the source, medium and campaign taken from utm_source, utm_medium and utm_campaign, the referring URL, a device bucket, and the timestamp of capture.

An agent order is stamped when the agent's cart is created, not from a browser request. Its source is the platform's profile host (gemini.google.com, say), or mcp for an MCP client that sent no UCP profile. Its medium is the protocol: ucp_rest, ucp_mcp or mcp. Its campaign is the platform's campaign name when it sends one, there is no referrer, and the device is unknown. First touch wins here too, so a basket an agent started and the buyer finished in the browser through the continue_url stays an agent sale. The bucket filters and breaks down like the others, with nothing to set up, and it is only recorded while reports.attribution.enabled is on. A basket in this bucket is never sent a cart-recovery email.

What it does not do:

  • Nothing is stored about a visitor who does not order. The details sit in the session and are written to the order when a basket is created.
  • No third party is contacted. There is no beacon, no pixel and no external request. Every field comes from the request the visitor's own browser made.
  • The user agent is never stored. It is read once to decide desktop, mobile, tablet or bot, and only the bucket is kept.
  • First touch wins. The source is captured when the basket is created and is not overwritten by a later visit.

Turning reports.attribution.enabled off stops the capture. Orders already captured keep what they have.

Cost of goods and margin

A product carries a cogs_minor, which is what a unit costs the store. When a line is added to a basket that cost is copied onto the line, exactly as the price is. Editing the product's cost afterwards changes nothing already sold.

Cost is the base currency's. The cost frozen onto an order line is in the base currency whatever currency the order is in, so native margin is worked out over base-currency orders only. The converted view includes foreign orders that have a frozen rate and leaves unrated orders out. See Per currency and in base currency.

A partly costed catalogue reports a margin that is too high. A line whose product had no cost contributes zero to cost of goods and its whole value to margin. The metric definitions say so on the card. Read a margin figure on a half-costed catalogue as "at most this".

Cost of goods is also a column in the nightly rollup, added by migration 0061. That migration does not backfill it. A store that upgraded has a rollup with no cost figures in it, so margin is worked out by scanning the orders table until the rollup is rebuilt. The report says which happened, and the reports_rollup_stale dashboard alert says so once on the dashboard. To fix it:

BASH
bin/plugin kahunacart reports:rebuild

With no --from or --to that recomputes every closed day the store has. A store upgrading to cost of goods runs it once.

Saved views, and the URL

Everything the Analytics screen is showing lives in the address:

TXT
#/analytics?from=2026-01-01&to=2026-03-31&comparison=previous_year&segment=provider&filters={"match":"all","filters":[{"field":"currency","operator":"eq","value":"GBP"}]}

Copy the URL, send it to somebody, and they see the report you were looking at.

A saved view is the same state under a name. Views are per user: two people looking at the same store keep their own. A store keeps at most 50 saved views per report.

Rollup or scan

Every comparison report says at the top which of the two it did.

Line Means
Read from the nightly rollup Every day in the range is at or before the watermark, and nothing is filtered.
Scanned from orders Something is filtered, or part of the range is not rolled up yet.

Converted reports always scan the orders and their frozen rates. Native filtered reports always scan too. The rollup holds day rows with no order columns to filter on.

An unfiltered range that straddles the watermark is split: the rolled-up part is read, the rest is scanned, and the two are added. See Reports and the rollup for the watermark and how it moves.

Important

The rollup is authoritative only for days it actually holds rows for. If the watermark runs ahead of the newest rolled-up day, reports fall back to scanning the uncovered days rather than reporting zero for them.

Column choices and property labels

Two small things live next to reporting because they are the same idea: what a screen shows, and what it calls things.

The column picker sits above the orders, products and customers lists. Each screen has exactly one column that cannot be turned off: the order number, the product title, the customer's email.

A saved choice is stored as a diff, not as a list. It records the columns you turned off, against the columns that existed when you made the choice. A column added by a later release appears for everybody, including people who saved a choice years earlier. PUT /kahunacart/view-configs/{entity} with no columns puts the code defaults back.

Property labels rename a field everywhere the admin names it. Type "Order Number" once and it appears on the list, on the picker and on the export.

Overrides are stored per language, keyed by the language key rather than by a column code, and the shipped key is still underneath. A merchant who renames a field in English and nothing else gets their word in English and the shipped translation everywhere else. A blank box removes the override. GET /kahunacart/view-configs/{entity} lists every key you can rename; anything else is refused with an error that names it.

What each screen needs

Thing Permission
Reading a report, the vocabulary, saved views and the column picker kahunacart.reports
Renaming a field with property labels kahunacart.settings

The column picker is behind the reports permission rather than the entity's own. A bookkeeper who may see the order list can choose what their own order list shows.

API reference

Route What it does
GET /kahunacart/reports/comparison The metrics for a range against a comparison period, optionally filtered and broken down
GET /kahunacart/reports/vocabulary Every field, operator, metric and match mode, with the definitions, so a client can draw the filter row without hardcoding any of it
GET /kahunacart/report-views This user's saved views
POST /kahunacart/report-views Save one
PATCH /kahunacart/report-views/{id} Change one
DELETE /kahunacart/report-views/{id} Delete one
GET /kahunacart/view-configs/{entity} Which columns a list screen offers, and which this user sees
PUT /kahunacart/view-configs/{entity} Change that choice
PUT /kahunacart/property-labels Rename a field across the admin

MCP tools

Tool Does
get_report_comparison The metrics for a range against a comparison period.
get_report_currency / set_report_currency Read or remember this admin's currency choice.
get_report_vocabulary Every field, operator, metric and match mode, with the definitions.
list_report_views This user's saved views for a report.
create_report_view Save one.
update_report_view Change one.
delete_report_view Delete one.
get_view_config Which columns a list screen offers and which this user sees.
set_view_config Change that choice.
set_property_labels Rename a field across the admin.

Configuration

Key Default Effect
reports.timezone '' Where the store's days begin. Empty uses Grav's system.timezone, then UTC
reports.default_comparison previous_period What a report compares against when nobody has chosen
reports.default_days 30 How many days a report covers when it opens
reports.attribution.enabled true Whether to record where an order came from

See Configuration for the environment-variable form of each and for everything else the plugin ships.