Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions API/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ persistence:
| Inventory | `/v1/admin/inventory` | Stock levels and reservations |
| Admin | `/v1/admin/orders` | Back-office fulfillment |
| Analytics | `/v1/admin/analytics` | Commercial reporting over the order history |
| Storefront analytics | `/v1/analytics` | Anonymous shopper telemetry ingest (public) |
| Returns | `/v1/admin/returns` | Admin decisions on return requests |
| Webhooks | `/v1/webhooks` | Inbound payment provider callbacks |
| Health | `/health` | Liveness and readiness |
Expand Down Expand Up @@ -159,6 +160,27 @@ UTC belongs to the next day in Berlin, and bucketing on UTC would file a day's t
against the wrong day — an error that looks like a data problem for weeks. See
[Configuration](/Configuration).

#### The shopper funnel

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `GET` | `/v1/admin/analytics/storefront` | Sessions → product views → carts → checkouts → paid | admin |

What happens *before* an order exists — which the order funnel above cannot see, because
nothing has happened in the order tables yet. Fed by the public ingest endpoint below.

Sessions are counted distinctly: ten product views from one shopper is one person
considering a purchase, not ten.

**The pre-order steps are a floor, not a count.** Blocked scripts, a tab closed before the
batch flushed and disabled JavaScript all lose events. The `paid` step is read from the
orders table and is exact. A funnel that undercounts its first step but not its last
overstates the drop, so the two are labelled differently rather than presented as one
continuous measurement. Real conversion is never worse than reported.

Returns `enabled: false` with zeroes when the deployment is not collecting — "nobody
visited" and "we are not counting" would otherwise look identical.

#### What these numbers cannot tell you

Stated plainly, because a reader will otherwise infer something stronger:
Expand Down Expand Up @@ -193,6 +215,36 @@ reserves against.
| `PATCH` | `/v1/admin/inventory/{inventory_id}` | Update stock | admin |
| `DELETE` | `/v1/admin/inventory/{inventory_id}` | Delete inventory record | admin |

### Storefront ingest — `/v1/analytics`

| Method | Path | Purpose | Auth |
|---|---|---|---|
| `POST` | `/v1/analytics/events` | Record anonymous shopper events | — |

**Public by design**, because a shopper who has not signed in is exactly who this measures.
Off unless `STOREFRONT_ANALYTICS_ENABLED` is set, returning `404` while off so a deployment
that has not opted in does not advertise the capability. Returns `202`: the browser must
neither wait on the result nor retry.

Nothing stored identifies a person. There is no column for an IP address, a user agent, an
email or a customer id, and query strings are stripped before storage — that is where
personal data arrives by accident, in a share link or a redirect. The request schema
forbids unknown fields, so a client sending `email` gets a `422` rather than having it
quietly dropped.

Because nothing identifies anyone and the browser stores only a per-tab session id, this
requires no consent banner in the EU. That is the reason for the shape rather than a happy
accident: a banner costs 40–60% of sessions to opt-outs, which would make the funnel it
feeds mostly fiction.

Being public, it is guarded accordingly — rate limited, batch capped at 50 events, a closed
event vocabulary, and every field length-bounded. The worst an abusive client achieves is
noise in a report.

Browser timestamps outside ±24 hours of server time are discarded and counted in
`rejected`. Clocks are wrong often enough that rejecting all skew would lose real data, and
trusting all of it would let anyone write into a period already reported on.

### Webhooks and health

| Method | Path | Purpose | Auth |
Expand Down
6 changes: 6 additions & 0 deletions Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,18 @@ hold stock nobody can buy.
| Setting | Default | Description |
|---|---|---|
| `SHOP_TIMEZONE` | `Europe/Berlin` | IANA timezone the shop trades in |
| `STOREFRONT_ANALYTICS_ENABLED` | `false` | Accept anonymous shopper events from the storefront |

Analytics buckets days in this zone rather than UTC, so "today" matches the operator's day.
Leave it wrong and evening orders land on the following day for any shop east of Greenwich —
the figures stay internally consistent, which is what makes it hard to notice. An unknown
zone is rejected at request time with a `422` naming the value, rather than a `500`.

`STOREFRONT_ANALYTICS_ENABLED` is off by default because cloning OpenTaberna must not
silently start collecting anything, even something that identifies nobody. While off, the
ingest endpoint returns `404`. The storefront has a matching switch in
`storefront.config.ts`; both must be on.

## Object storage — MinIO / S3

| Setting | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion home.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ system. It is one possible shop UI, not *the* shop UI — that is the point of t
| Fulfillment | Carrier labels, packing slips, the job queue and outbox |
| Shipments | Tracking numbers and label files |
| Returns | Customer return requests and admin decisions |
| Analytics | Revenue, product performance and the order funnel |
| Analytics | Revenue, product performance, and the order and shopper funnels |
| Health | Liveness and readiness probes |

The full endpoint list is on the [API Architecture](/API/Architecture) page, and a live,
Expand Down
10 changes: 10 additions & 0 deletions openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"summary": "Product performance (admin)"
}
},
"/v1/admin/analytics/storefront": {
"get": {
"summary": "Shopper funnel (admin)"
}
},
"/v1/admin/analytics/summary": {
"get": {
"summary": "Commercial summary (admin)"
Expand Down Expand Up @@ -106,6 +111,11 @@
"summary": "Update return request (admin)"
}
},
"/v1/analytics/events": {
"post": {
"summary": "Record anonymous storefront events"
}
},
"/v1/customers/me": {
"get": {
"summary": "Get my profile"
Expand Down
Loading