Analytics
Default setup
Section titled “Default setup”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.
Query analytics
Section titled “Query analytics”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.
Add custom analytics models
Section titled “Add custom analytics models”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.
Scope rules
Section titled “Scope rules”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.
REST endpoints
Section titled “REST endpoints”Query analytics via the REST API with the analytics:read permission:
# List available models, measures, and dimensionscurl "http://localhost:4000/api/analytics/meta" \ -H "x-api-key: dev-staff-key"
# Query orders by statuscurl -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 }'Retail reports
Section titled “Retail reports”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.
# List available reportscurl "http://localhost:4000/api/analytics/reports" \ -H "x-api-key: dev-staff-key"
# A single day's sales journalcurl "http://localhost:4000/api/analytics/reports/daily-journal?date=2026-07-01" \ -H "x-api-key: dev-staff-key"
# Tax collected over a rangecurl "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.
Related
Section titled “Related”- Analytics Reference — all models, measures, dimensions, filter operators, and scope rules
- Store Settings — the timezone reports bucket by
- Build a Loyalty Plugin — example of contributing a plugin with hooks and routes
- Plugin Architecture —
analyticsModelsmanifest slot