Skip to content

Analytics

Analytics works out of the box with zero configuration. The kernel instantiates a DrizzleAnalyticsAdapter that compiles analytics queries into SQL and runs them directly against your PostgreSQL database.

On startup, the kernel registers four built-in models:

| Model | What it covers | |-------|----------------| | Orders | Order count, revenue, status breakdown | | OrderLineItems | Per-item revenue, quantity, product breakdown | | Customers | Customer count, new vs returning | | Inventory | Stock levels, reservation status |

If the marketplace plugin is installed, three additional models are registered automatically: VendorOrders, VendorBalance, VendorReviews.

For the complete list of measures, dimensions, segments, and filter operators, see the Analytics Reference.

Use the analytics.query service method from a hook, custom route, or script:

import { buildAnalyticsScope } from "@porulle/core";
const scope = buildAnalyticsScope(actor);
const result = await kernel.analytics.query(
{
model: "Orders",
measures: ["count", "revenue"],
dimensions: ["status"],
filters: [{ dimension: "createdAt", operator: "gte", value: "2026-01-01" }],
limit: 100,
},
scope,
);

All queries run within an AnalyticsScope that restricts data visibility based on the caller’s role. Always pass a scope — never query without one. Scopes are created exclusively through buildAnalyticsScope.

Plugins contribute analytics models via the analyticsModels manifest slot in defineCommercePlugin:

import type { AnalyticsModel } from "@porulle/core";
import { defineCommercePlugin } from "@porulle/core";
const subscriptionsModel: AnalyticsModel = {
name: "Subscriptions",
title: "Subscriptions",
sql: "SELECT * FROM subscriptions",
measures: {
count: { type: "count", title: "Subscription Count" },
mrr: { type: "sum", sql: "monthly_amount", title: "Monthly Recurring Revenue" },
},
dimensions: {
status: { type: "string", sql: "status", title: "Status" },
startedAt: { type: "time", sql: "started_at", title: "Started At" },
customerId: { type: "string", sql: "customer_id", title: "Customer ID" },
},
};
export const subscriptionsPlugin = defineCommercePlugin({
id: "subscriptions",
version: "1.0.0",
analyticsModels: () => [subscriptionsModel],
});

Models contributed this way are registered at kernel startup. They appear in GET /api/analytics/meta and can be queried through GET /api/analytics/query like any built-in model.

Analytics queries enforce role-based scoping automatically. A customer actor can only see their own data. A vendor actor sees only their vendor’s orders. An admin actor sees all data for the organization.

The buildAnalyticsScope function reads the actor’s role and organization ID and builds the appropriate filter set. Passing the wrong scope (or no scope) is a security defect — the query will return either wrong data or fail validation.

Query analytics via the REST API with the analytics:read permission:

Terminal window
# List available models, measures, and dimensions
curl "http://localhost:4000/api/analytics/meta" \
-H "x-api-key: dev-staff-key"
# Query orders by status
curl -X POST "http://localhost:4000/api/analytics/query" \
-H "content-type: application/json" \
-H "x-api-key: dev-staff-key" \
-d '{
"model": "Orders",
"measures": ["count", "revenue"],
"dimensions": ["status"],
"limit": 50
}'

Alongside the semantic query engine above, Porulle ships a pack of canned, parameterized retail reports. The query engine composes arbitrary measures and dimensions across models; these reports are fixed, purpose-built views — a daily journal, a tax summary, inventory aging — that you fetch by name and narrow with date parameters. Both require the analytics:read permission scope (granted to owner, admin, and manager).

There are no new tables — each report runs parameterized SQL over the existing orders, line-item, inventory, and entity data.

| Report | What it returns | |--------|-----------------| | daily-journal | Per-day sales journal: order rows plus a summary with prior-day deltas | | tax-summary | Tax collected over a range, bucketed per local day, with prior-period deltas | | inventory-aging | On-hand stock bucketed by days since last restock (0-30 / 31-60 / 61-90 / 90+) | | sell-through | Units sold in the range vs current on-hand, per entity | | reorder-needed | Inventory rows at or below their reorder threshold | | staff-sales | Orders and revenue grouped by orders.metadata.staffId |

List the available reports, then fetch one by name. Single-day reports take an optional date; range reports take optional from and to (inclusive). All dates use YYYY-MM-DD. When a range is omitted it defaults to the first of the current month through today.

Terminal window
# List available reports
curl "http://localhost:4000/api/analytics/reports" \
-H "x-api-key: dev-staff-key"
# A single day's sales journal
curl "http://localhost:4000/api/analytics/reports/daily-journal?date=2026-07-01" \
-H "x-api-key: dev-staff-key"
# Tax collected over a range
curl "http://localhost:4000/api/analytics/reports/tax-summary?from=2026-06-01&to=2026-06-30" \
-H "x-api-key: dev-staff-key"

GET /api/analytics/reports returns { data: [{ name, description }, ...] }; GET /api/analytics/reports/{name} returns { data: <report result> }.

Calendar math uses the store’s timezone, read from the general.timezone settings group (default UTC), so every day boundary and range default is computed in local time. Financial reports exclude cancelled and voided orders.