Repository navigation
feat(analytics): anonymous storefront telemetry for the shopper funnel - #47
Merged
PhilippTheServer merged 1 commit intoAug 26, 2026
Conversation
The order funnel begins at order creation, so it can say how many orders were
paid but not how many people looked and left. Browsing, product views and
abandoned carts leave no trace in the order tables, because nothing happened
there.
Adds a public ingest endpoint, a storefront_events table, and an admin endpoint
returning the full funnel: sessions, product views, carts, checkouts and paid
orders.
POST /v1/analytics/events public, rate limited, opt-in
GET /v1/admin/analytics/storefront the shopper funnel
Privacy is structural rather than a policy someone remembers. The table has no
column that could identify a person, and a test fails if one ever appears. The
request schema forbids extra fields, so a client sending email or ip_address
gets a 422 rather than having it quietly dropped — silently discarding it would
let a frontend believe it was collecting something it was not. Query strings are
stripped before storage, because that is where personal data arrives by
accident: an email in a share link, a token in a redirect.
Nothing identifies anyone and nothing is stored in the browser beyond a per-tab
session id, so this needs no consent banner in the EU. That is the point of the
shape, not a happy accident — a banner costs 40-60% of sessions to opt-outs,
which would make the funnel it feeds mostly fiction.
Collection is off unless STOREFRONT_ANALYTICS_ENABLED is set, and the ingest
endpoint returns 404 while it is off, so a deployment that has not opted in does
not advertise a capability it is not offering. The admin endpoint still answers,
reporting enabled: false with zeroes, because "nobody visited" and "we are not
counting" otherwise look identical.
The endpoint is public, so it is rate limited, batch capped, closed-vocabulary
and length-bounded throughout. The worst an abusive client can do is add noise
to a report.
Browser timestamps are accepted within 24 hours of server time and discarded
outside it: 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 an
administrator has already reported on.
The paid step is read from orders rather than from the events, so a client
claiming a checkout it never paid for inflates one step and cannot touch the
next. order_id is not a foreign key: the event records what a browser reported
and must survive the order being deleted, rather than vanishing with it and
silently improving the conversion rate.
Closes #46
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4
This was referenced Aug 26, 2026
PhilippTheServer
added a commit
to OpenTaberna/wiki
that referenced
this pull request
Aug 26, 2026
The API gained a public ingest endpoint and an admin shopper funnel (OpenTaberna/fastapi#47), and refreshing the snapshot made the drift check fail with two undocumented paths. Documents both, and the parts a reader cannot recover from the schema: that the pre-order steps are a floor rather than a count while the paid step is exact, why the ingest endpoint is public, what it refuses to store, and why none of it needs a consent banner. Also records why the endpoint returns 404 rather than 403 when collection is off, and adds STOREFRONT_ANALYTICS_ENABLED to the configuration reference. Closes #9 Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46 · S2 of the telemetry programme
The order funnel starts at order creation, so it answers "how many orders were paid" but not
"how many people looked and left". This adds the part before the order.
Privacy is structural, not a policy
The table has no column that could hold PII, and a test fails if one appears:
The request schema uses
extra="forbid", so a client sendingemailgets a422ratherthan having it quietly dropped — silently discarding it would let a frontend believe it was
collecting something it was not, and nobody would find out.
Query strings are stripped before storage. That is where personal data arrives by accident,
and removing it at the boundary means it cannot be stored even if sent:
Because nothing identifies anyone and nothing is kept in the browser beyond a per-tab
session id, this needs no consent banner in the EU. That is the point of the shape — a
banner costs 40–60% of sessions to opt-outs, which would make the funnel it feeds mostly
fiction.
Off unless the operator opts in
STOREFRONT_ANALYTICS_ENABLEDdefaults to false; ingest returns404while it is.404rather than
403so a deployment that has not opted in does not advertise the capability.The admin endpoint still answers, reporting
enabled: falsewith zeroes — "nobody visited"and "we are not counting" otherwise look identical.
The last step is not taken on trust
paidis read fromorders, not from the events. A browser reportingcheckout_startedmeans a button was pressed; whether money arrived is knowable only from the orders table. A
client fabricating an
order_idinflates one step and cannot touch the next — there is atest for exactly that.
order_idis deliberately not a foreign key: the event records what a browser reportedand must survive the order being deleted, rather than vanishing with it and silently
improving the conversion rate.
Verification
Verified end to end against the live stack:
One bug caught in review
I first declared
occurred_at/created_atwithouttimezone=True, which producedTIMESTAMP WITHOUT TIME ZONEcolumns while every other table usestimestamptz. Comparingan aware Python datetime against them failed with a
DBAPIErroron the first real query.Fixed to match the
TimestampMixinconvention.Because
create_alldoes not alter existing tables, a database that already created themistyped table needs
DROP TABLE storefront_events;once — it holds no durable data atthis point. Fresh databases are unaffected.
The storefront client and the admin display land separately.