stapel-analytics 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. stapel_analytics-0.1.0/CONFIG.MD +108 -0
  2. stapel_analytics-0.1.0/LICENSE +21 -0
  3. stapel_analytics-0.1.0/MODULE.md +498 -0
  4. stapel_analytics-0.1.0/PKG-INFO +168 -0
  5. stapel_analytics-0.1.0/README.md +139 -0
  6. stapel_analytics-0.1.0/__init__.py +66 -0
  7. stapel_analytics-0.1.0/_codegen_settings.py +89 -0
  8. stapel_analytics-0.1.0/actions.py +214 -0
  9. stapel_analytics-0.1.0/adapters.py +235 -0
  10. stapel_analytics-0.1.0/admin.py +28 -0
  11. stapel_analytics-0.1.0/apps.py +35 -0
  12. stapel_analytics-0.1.0/checks.py +380 -0
  13. stapel_analytics-0.1.0/conf.py +184 -0
  14. stapel_analytics-0.1.0/conftest.py +130 -0
  15. stapel_analytics-0.1.0/dto.py +117 -0
  16. stapel_analytics-0.1.0/erasure.py +162 -0
  17. stapel_analytics-0.1.0/errors.py +133 -0
  18. stapel_analytics-0.1.0/events.py +32 -0
  19. stapel_analytics-0.1.0/functions.py +117 -0
  20. stapel_analytics-0.1.0/funnels.py +352 -0
  21. stapel_analytics-0.1.0/gdpr.py +77 -0
  22. stapel_analytics-0.1.0/ingest.py +343 -0
  23. stapel_analytics-0.1.0/manage.py +27 -0
  24. stapel_analytics-0.1.0/migrations/0001_initial.py +35 -0
  25. stapel_analytics-0.1.0/migrations/__init__.py +0 -0
  26. stapel_analytics-0.1.0/models.py +91 -0
  27. stapel_analytics-0.1.0/presenters.py +220 -0
  28. stapel_analytics-0.1.0/privacy.py +147 -0
  29. stapel_analytics-0.1.0/py.typed +0 -0
  30. stapel_analytics-0.1.0/pyproject.toml +85 -0
  31. stapel_analytics-0.1.0/registry.py +282 -0
  32. stapel_analytics-0.1.0/schemas/consumes/gdpr.erasure.requested.json +18 -0
  33. stapel_analytics-0.1.0/schemas/consumes/gdpr.owner.probe.json +11 -0
  34. stapel_analytics-0.1.0/schemas/consumes/user.deleted.json +12 -0
  35. stapel_analytics-0.1.0/schemas/emits/analytics.events.recorded.json +31 -0
  36. stapel_analytics-0.1.0/schemas/emits/gdpr.owner.alive.json +24 -0
  37. stapel_analytics-0.1.0/schemas/emits/gdpr.section.erased.json +36 -0
  38. stapel_analytics-0.1.0/schemas/functions/analytics.event_registry.json +8 -0
  39. stapel_analytics-0.1.0/schemas/functions/analytics.funnel_report.json +14 -0
  40. stapel_analytics-0.1.0/schemas/functions/analytics.track.json +17 -0
  41. stapel_analytics-0.1.0/serializers.py +135 -0
  42. stapel_analytics-0.1.0/services.py +305 -0
  43. stapel_analytics-0.1.0/setup.cfg +4 -0
  44. stapel_analytics-0.1.0/stapel_analytics.egg-info/PKG-INFO +168 -0
  45. stapel_analytics-0.1.0/stapel_analytics.egg-info/SOURCES.txt +118 -0
  46. stapel_analytics-0.1.0/stapel_analytics.egg-info/dependency_links.txt +1 -0
  47. stapel_analytics-0.1.0/stapel_analytics.egg-info/requires.txt +4 -0
  48. stapel_analytics-0.1.0/stapel_analytics.egg-info/top_level.txt +1 -0
  49. stapel_analytics-0.1.0/store.py +169 -0
  50. stapel_analytics-0.1.0/tasks.py +70 -0
  51. stapel_analytics-0.1.0/tests/test_adapters.py +288 -0
  52. stapel_analytics-0.1.0/tests/test_api.py +434 -0
  53. stapel_analytics-0.1.0/tests/test_checks.py +293 -0
  54. stapel_analytics-0.1.0/tests/test_comm_bridge.py +294 -0
  55. stapel_analytics-0.1.0/tests/test_contract.py +258 -0
  56. stapel_analytics-0.1.0/tests/test_functions.py +163 -0
  57. stapel_analytics-0.1.0/tests/test_funnels.py +343 -0
  58. stapel_analytics-0.1.0/tests/test_gdpr.py +284 -0
  59. stapel_analytics-0.1.0/tests/test_ingest.py +368 -0
  60. stapel_analytics-0.1.0/tests/test_library_commands.py +216 -0
  61. stapel_analytics-0.1.0/tests/test_presentation.py +189 -0
  62. stapel_analytics-0.1.0/tests/test_privacy.py +192 -0
  63. stapel_analytics-0.1.0/tests/test_public_api.py +82 -0
  64. stapel_analytics-0.1.0/tests/test_registry.py +228 -0
  65. stapel_analytics-0.1.0/tests/test_services.py +180 -0
  66. stapel_analytics-0.1.0/tests/test_store.py +193 -0
  67. stapel_analytics-0.1.0/tests/test_transport.py +270 -0
  68. stapel_analytics-0.1.0/tests/test_wire_compat.py +294 -0
  69. stapel_analytics-0.1.0/translations/errors.es.json +24 -0
  70. stapel_analytics-0.1.0/translations/errors.ru.json +24 -0
  71. stapel_analytics-0.1.0/transport.py +130 -0
  72. stapel_analytics-0.1.0/urls.py +44 -0
  73. stapel_analytics-0.1.0/urls_v1.py +58 -0
  74. stapel_analytics-0.1.0/views.py +420 -0
@@ -0,0 +1,108 @@
1
+ # CONFIG.MD — stapel-analytics settings
2
+
3
+ Every key below lives in the `STAPEL_ANALYTICS` dict. Resolution order per
4
+ key: `settings.STAPEL_ANALYTICS` → flat Django setting of the same name →
5
+ environment variable → default. The two seam keys (`SUBJECT_RESOLVER`,
6
+ `PII_GUARD`) are **never** read from the environment — a name that decides
7
+ which code the process loads is not something a stray `export` may choose.
8
+
9
+ Nothing here needs to be set for the module to boot. What a host actually has
10
+ to decide is in §"The five decisions" at the end.
11
+
12
+ ## Event registry
13
+
14
+ | Key | Default | What it does |
15
+ |---|---|---|
16
+ | `EVENTS` | `{}` | Merge-registry of event definitions, layered over the built-ins (`flow.*`, `identify`). Accepts a map (`{name: definition}`) or a list (what `events.json` contains). A definition of `None` **removes** an entry, including a built-in. |
17
+ | `EVENTS_FILE` | `None` | Path to the project's `analytics/events.json` — the static projection of the frontend's `defineEvent` declarations. Merged UNDER `EVENTS`. Parsed once, lazily; a missing or malformed file is `analytics.E002`, never a silent empty registry. |
18
+ | `REGISTRY_MODE` | `"warn"` | `"warn"` stores an unregistered event and marks it; `"reject"` refuses it at ingest; `"off"` disables the check entirely (`analytics.W003`). Only `kind: "track"` is ever validated. |
19
+
20
+ ## Ingest
21
+
22
+ | Key | Default | What it does |
23
+ |---|---|---|
24
+ | `MAX_BATCH_SIZE` | `500` | Events one batch may carry. A larger body is refused whole (400). |
25
+ | `MAX_BODY_BYTES` | `1048576` | Request body cap (413), checked from `Content-Length` before parsing. |
26
+ | `MAX_PROPS_BYTES` | `16384` | Serialized cap on one event's props. |
27
+ | `MAX_NAME_LENGTH` | `200` | Longest event / page / source name accepted. |
28
+ | `MAX_ID_LENGTH` | `128` | Anonymous / session / hash ids are truncated to this, not refused — an opaque id that is too long is a client bug, not a reason to lose the event. |
29
+ | `MAX_CLOCK_SKEW_SECONDS` | `300` | A client clock further ahead than this has its timestamp replaced by server time. Browsers have wrong clocks; refusing those users would bias every funnel toward people with working NTP. |
30
+ | `MAX_EVENT_AGE_SECONDS` | `604800` | Older events are refused as a stale offline-buffer replay — admitting them rewrites a closed reporting period. `0` disables the bound. |
31
+ | `WRITE_KEYS` | `{}` | `{write_key: source_name}`. What a batch's `write_key` is checked against and what names its `source`. |
32
+ | `REQUIRE_WRITE_KEY` | `False` | **Decision.** Refuse a batch whose key is unrecognized (401). Ships off: the shipped facade sends a key only when a host configured one, and a module that 401s a correct frontend out of the box is a module nobody mounts. `analytics.W010` catches "required, none configured". |
33
+ | `DEFAULT_SOURCE` | `"web"` | Source attributed to a batch no key names. |
34
+ | `INGEST_AUTHENTICATION` | `[]` | Authenticator dotted paths for the ingest endpoint ONLY. Empty on purpose: the ingest identity is the `user_hash` in the payload, and an empty list is what makes `navigator.sendBeacon` work (a beacon carries no CSRF token, and DRF's `SessionAuthentication` enforces CSRF from inside authentication). |
35
+ | `LEGACY_INGEST_ALIAS` | `True` | Also mount the un-versioned `/analytics/api/events` that `@stapel/analytics` 0.1 hardcodes. A compatibility surface with an end date — set `False` once your frontend targets `api/v1`. |
36
+
37
+ ## Privacy
38
+
39
+ | Key | Default | What it does |
40
+ |---|---|---|
41
+ | `PII_MODE` | `"reject"` | **Decision.** What a PII-shaped prop VALUE does: `"reject"` refuses the event and names the prop; `"strip"` redacts and stores; `"warn"` logs and stores; `"off"` disables the guard (`analytics.W002`). Keys are never judged — only values. |
42
+ | `USER_HASH_SALT` | `""` | **Decision.** Mixed into the user id before hashing. Empty by default because `@stapel/analytics` hashes `sha256(userId)` unsalted: with a salt, a server-side funnel step and the browser steps of the same person get different subject keys and never join. `analytics.W008` states the trade on every boot. |
43
+ | `PII_GUARD` | `stapel_analytics.privacy.looks_like_pii` | The heuristic itself (dotted path, never from the environment). Same contract as the frontend guard, so tightening one is a choice rather than a coincidence. |
44
+
45
+ ## Storage
46
+
47
+ | Key | Default | What it does |
48
+ |---|---|---|
49
+ | `STREAM` | `"analytics"` | The `stapel_core.eventstore` stream every row is appended to. Backend, buffering, partitioning and per-stream routing are the event store's own settings (`STAPEL_EVENTSTORE`) — this module never touches a backend. |
50
+ | `RETENTION_DAYS` | `400` | **Decision.** Raw retention applied by `manage.py purge_analytics` / `stapel_analytics.tasks.purge_analytics_events`. `None` = keep forever, which for personal data is a decision somebody made — `analytics.W005` accepts either this or `STAPEL_EVENTSTORE["RETENTION"]`. |
51
+ | `PURGE_SCHEDULE` | `{"hour": 4, "minute": 30}` | Crontab kwargs for the beat entry. |
52
+ | `QUERY_PAGE_SIZE` | `1000` | Rows per store page on a report or erasure pass. |
53
+ | `MAX_REPORT_EVENTS` | `200000` | Ceiling on the rows one report scans. Past it the report comes back `truncated: true` rather than becoming an unbounded table scan somebody triggers from a dashboard. |
54
+
55
+ ## Funnels
56
+
57
+ | Key | Default | What it does |
58
+ |---|---|---|
59
+ | `FUNNELS` | `{}` | Funnels DECLARED by the project spec: `{slug: {"title", "steps", "window_seconds"}}`. Merged UNDER the `Funnel` table — a declared funnel is read-only over the API (409), because its home is the spec and an edit the next deploy reverts is worse than a refusal. |
60
+ | `DEFAULT_FUNNEL_WINDOW_SECONDS` | `604800` | Window a funnel that names none inherits. `0` on a funnel means unbounded. |
61
+ | `MAX_FUNNEL_STEPS` | `12` | Steps one funnel may carry. |
62
+ | `MAX_FUNNELS_PER_OWNER` | `50` | Authored funnels per user. Unowned funnels (created in code) are not capped — they are operators' objects. |
63
+ | `SUBJECT_RESOLVER` | `stapel_analytics.ingest.default_subject` | What "the same person" means for a funnel (dotted path, never from the environment). Default: user hash → anonymous id → session id. |
64
+
65
+ ## Server-side fan-out
66
+
67
+ | Key | Default | What it does |
68
+ |---|---|---|
69
+ | `ADAPTERS` | `{}` | Merge-registry over the built-ins (`webhook`, `log`), both of which ship DISABLED. An entry merges OVER its built-in and `config` merges one level deep, so a host names a URL without restating a handler. `None` removes an adapter. |
70
+ | `FANOUT_ENABLED` | `True` | Whether recording emits `analytics.events.recorded` at all. Off stops the mirror; the event store still records everything. |
71
+ | `FANOUT_BATCH_SIZE` | `200` | Events one fan-out Action carries; larger batches are chunked. |
72
+
73
+ ## comm bridge
74
+
75
+ | Key | Default | What it does |
76
+ |---|---|---|
77
+ | `COMM_BRIDGE` | `{}` | `{action_name: event_name}` or `{action_name: {"event", "props": [...], "mapper": "dotted.path"}}`. A host Action becomes a step of the same funnels as the clicks that led to it. Empty by default: a bridge that guessed which Actions are business milestones would invent a funnel nobody declared. A broken mapper raises `ImproperlyConfigured` at `ready()` — a bridge that quietly does not fire looks exactly like a bad conversion rate. |
78
+ | `SERVER_SOURCE` | `"server"` | Source recorded on bridged and `track()`-ed events. |
79
+
80
+ ## API
81
+
82
+ | Key | Default | What it does |
83
+ |---|---|---|
84
+ | `MAX_PAGE_SIZE` | `100` | Cap on the funnel listing. `?limit=` may ask for less, never for more. |
85
+
86
+ ---
87
+
88
+ ## The five decisions
89
+
90
+ Everything above has a working default. These five are the ones a host
91
+ genuinely has to make, and each of them says so at boot if left unmade:
92
+
93
+ 1. **Where does the vocabulary come from?** `EVENTS_FILE` pointed at your
94
+ project's `analytics/events.json`. Without it every event is stored
95
+ `unregistered` (`analytics.W001`) and no funnel can validate its steps.
96
+ 2. **How long do you keep behavioural data about people?** `RETENTION_DAYS`,
97
+ plus scheduling the purge. `None` is a legitimate answer — made on
98
+ purpose, not by omission (`analytics.W005`).
99
+ 3. **Is this module a declared data owner?** `STAPEL_GDPR["DATA_OWNERS"]`
100
+ must list `analytics`, or erasure requests never reach it and the
101
+ behavioural rows survive an account's deletion (`analytics.W009`).
102
+ 4. **Does ingest need a write key?** `REQUIRE_WRITE_KEY` + `WRITE_KEYS`. Off
103
+ is the right default for a first-party SPA on the same origin; on is the
104
+ right answer the moment you have more than one source, or a mobile app
105
+ whose bundle is public.
106
+ 5. **Salted or joinable user hashes?** `USER_HASH_SALT`. Empty keeps the
107
+ client/server funnel join working; setting it makes the store unlinkable
108
+ to an id guesser and breaks every mixed funnel (`analytics.W008`).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,498 @@
1
+ # MODULE.md — stapel-analytics
2
+
3
+ Integration reference for **stapel-analytics**: what it stores, what it
4
+ exposes, what it asks of a host, and which of its switches are decisions
5
+ rather than tuning. `README.md` is the introduction; this is the contract.
6
+
7
+ Design of record: `docs/pending/analytics-standard-v2.md` in the stapel
8
+ workspace (the backend half; the frontend half is `docs/done/
9
+ analytics-standard-v1.md`, shipped as `@stapel/analytics`), plus the
10
+ `stapel-analytics` row of `docs/reference/module-roadmap.md`.
11
+
12
+ ---
13
+
14
+ ## 1. What it is
15
+
16
+ The **backend half of the analytics standard**. The frontend facade shipped
17
+ first — `@stapel/analytics` already does `track` / `identify` / `page`, a
18
+ consent gate, an offline queue and client-side provider fan-out. What was
19
+ missing was everything on the other side of the wire:
20
+
21
+ ```
22
+ browser (@stapel/analytics) server modules
23
+ │ track / page / identify │ analytics.track
24
+ ▼ ▼
25
+ POST /analytics/api/v1/events ──► registry check + PII guard
26
+
27
+ host comm Actions ──► COMM_BRIDGE ────────┤
28
+ (payment.completed, …) │
29
+
30
+ stapel_core.eventstore ("analytics" stream)
31
+
32
+ ┌────────────────────────┼────────────────────┐
33
+ ▼ ▼ ▼
34
+ funnels / reports analytics.events.recorded erasure
35
+ (conversion by step) ──► adapter fan-out (GDPR Art. 17)
36
+ ```
37
+
38
+ An **L2 data-plane module** with exactly **one table**:
39
+
40
+ | Model | Table | Role |
41
+ |---|---|---|
42
+ | `Funnel` | `analytics_funnel` | the DEFINITION: ordered event names + a window |
43
+
44
+ The events themselves have **no model here**. They go to
45
+ `stapel_core.eventstore` — the fleet's append-only stream primitive, which
46
+ is already the design's "partitionable table, retention is a setting". A second unbounded time-series table per deployment, with its
47
+ own partitioning and its own scale-out story, would be three problems core
48
+ solved once.
49
+
50
+ App label `analytics`. UUID primary key on `Funnel`, `@access.standard`: a
51
+ funnel names business milestones, never personal data. The EVENT rows are
52
+ personal data, and they are administered (and erased) through the event
53
+ store — see §7.
54
+
55
+ ---
56
+
57
+ ## 2. Mounting
58
+
59
+ ```python
60
+ INSTALLED_APPS = [
61
+ ...,
62
+ "stapel_core.django.eventstore", # REQUIRED: the event rows live here
63
+ "stapel_analytics",
64
+ ]
65
+
66
+ # urls.py — the module bakes in the api/v1 segment (api-versioning.md §2)
67
+ path("analytics/", include("stapel_analytics.urls")) # -> /analytics/api/v1/...
68
+ ```
69
+
70
+ Forgetting `stapel_core.django.eventstore` is the one mounting mistake that
71
+ looks like a working install — the endpoint answers, the registry lists, the
72
+ funnels save, and every batch raises on a missing table. `analytics.E001`
73
+ refuses to let that boot quietly.
74
+
75
+ ---
76
+
77
+ ## 3. The event registry — the vocabulary this deployment admits
78
+
79
+ Merge-registry, same semantics as everywhere in the fleet:
80
+
81
+ ```
82
+ built-ins <- EVENTS_FILE (analytics/events.json) <- EVENTS <- register_event()
83
+ ```
84
+
85
+ Last layer wins; a definition of `None` REMOVES an entry, including a
86
+ built-in one. Names may be **patterns**: a trailing `*` matches a prefix, and
87
+ an exact name beats a pattern (longest prefix wins among patterns).
88
+
89
+ A definition is the same literal shape `@stapel/analytics`' `defineEvent`
90
+ projects into `analytics/events.json`:
91
+
92
+ ```json
93
+ {"name": "listing.published",
94
+ "description": "A seller published a listing",
95
+ "props": {"listing_id": {"type": "string", "description": "…"}},
96
+ "flow": "sell"}
97
+ ```
98
+
99
+ That is the point: the frontend declares events next to the code that fires
100
+ them, `gen:events` projects them into `events.json`, and the same file is the
101
+ backend's registry. **One vocabulary, two runtimes, no hand-maintained copy.**
102
+ Both shapes load — a list (what `events.json` contains) and a map (what a
103
+ settings dict is), with or without a `{"events": [...]}` envelope.
104
+
105
+ Built-ins: `flow.*` (the frontend flow auto-instrumentation of
106
+ analytics-standard §1.2) and `identify` (the facade's own event kind).
107
+
108
+ **Validation is a WARNING by default.** An unregistered `track` is stored and
109
+ marked `unregistered`, never dropped: losing the event and the evidence of
110
+ the mistake at the same time is the one outcome an ingest must not have.
111
+ `REGISTRY_MODE = "reject"` refuses instead; `"off"` disables the check (and
112
+ says so at boot). Only `kind: "track"` is validated — a `page` name is a path
113
+ chosen at render time and an `identify` name is the literal string
114
+ `"identify"`.
115
+
116
+ Three ways to ask:
117
+
118
+ ```python
119
+ from stapel_analytics import event_registry
120
+ event_registry()["listing.published"]["props"] # in-process
121
+
122
+ call("analytics.event_registry", {}) # over comm
123
+ GET /analytics/api/v1/event-registry # over HTTP
124
+ python manage.py analytics_event_registry # from a shell
125
+ ```
126
+
127
+ ---
128
+
129
+ ## 4. HTTP surface
130
+
131
+ | Route | Method | Access | Notes |
132
+ |---|---|---|---|
133
+ | `/events` | POST | **anonymous** | the collector — `@stapel/analytics` posts here |
134
+ | `/event-registry` | GET | mandate | the vocabulary, the mode, live adapters, bridged actions |
135
+ | `/funnels` | GET, POST | mandate | the caller's funnels + the declared ones (`?mine=`, `?limit=`); POST authors one |
136
+ | `/funnels/<slug>` | GET, PATCH, DELETE | owner/staff | PATCH re-validates the WHOLE rule |
137
+ | `/funnels/<slug>/report` | GET | owner/staff | `?start=&end=&compare=` |
138
+ | `/reports/events` | GET | staff | counts by `name` / `source` / `kind` |
139
+ | `/error-keys/` | GET | staff/service | the listing the stapel-translate collector reads |
140
+
141
+ "mandate" is `HasWorkspaceMandateIfScoped` — the library-shaped gate: where a
142
+ deployment can answer the mandate question it enforces the third principal
143
+ state (a registered account belonging to no workspace is a guest, not a
144
+ user), and where nothing can answer it, nobody holds one and it admits. The
145
+ strict class would 503 everyone in a single-tenant host, and analytics must
146
+ be installable there. Ownership is the scope layered on top.
147
+
148
+ A stranger's funnel answers **404, not 403**: a slug is guessable, and
149
+ "exists but not yours" is an oracle for which funnels another tenant runs —
150
+ which is a description of their product roadmap. An *unowned* funnel (created
151
+ in code, or by an erased account) answers 403: it is an operator's object.
152
+
153
+ ### Why ingest is open, and what guards it instead
154
+
155
+ `AllowAny`, and by default an **empty authenticator list**
156
+ (`INGEST_AUTHENTICATION`). Three reasons, all of them structural:
157
+
158
+ 1. the identity of an analytics event is the `user_hash` INSIDE the payload,
159
+ already hashed by the browser — a session adds nothing;
160
+ 2. the last batch of a session arrives via `navigator.sendBeacon`, which
161
+ carries no CSRF token, and DRF's `SessionAuthentication` enforces CSRF
162
+ from inside authentication;
163
+ 3. the authorization that matters is the SOURCE (`WRITE_KEYS`), not the
164
+ visitor.
165
+
166
+ What guards it: `MAX_BODY_BYTES`, `MAX_BATCH_SIZE`, `MAX_PROPS_BYTES`,
167
+ `MAX_NAME_LENGTH`, `MAX_ID_LENGTH`, the age/skew bounds, the PII guard and —
168
+ when a host turns it on — `REQUIRE_WRITE_KEY`. A host that wants
169
+ token-authenticated ingest names its classes in `INGEST_AUTHENTICATION`.
170
+
171
+ ### The wire format
172
+
173
+ ```json
174
+ POST /analytics/api/v1/events
175
+ {
176
+ "write_key": "wk_live_…", // optional; names the source
177
+ "anon_id": "…", "session_id": "…", // optional batch-level defaults
178
+ "events": [
179
+ {"id": "1755000000000-1", // the facade's own id
180
+ "kind": "track", // track | page | identify
181
+ "name": "listing.published",
182
+ "props": {"listing_id": "abc"},
183
+ "userHash": "<sha256 hex>", // camelCase — what the facade sends
184
+ "ts": 1755000000000} // epoch MILLISECONDS
185
+ ]
186
+ }
187
+ ```
188
+
189
+ Every per-event field is read in **both spellings** (`userHash`/`user_hash`,
190
+ `anonId`/`anon_id`, `sessionId`/`session_id`): the facade speaks the first,
191
+ every server-side producer speaks the second, and making them disagree would
192
+ hand somebody a translation layer to write by hand. `traits` is read as
193
+ `props` (that is what `identify()` sends). `ts` accepts epoch milliseconds
194
+ (what `Date.now()` produces) or an ISO-8601 string.
195
+
196
+ **Answer: 202 with a receipt**, even when some events were refused.
197
+
198
+ ```json
199
+ {"accepted": 19,
200
+ "rejected": [{"index": 7, "name": "checkout.paid", "reason": "pii",
201
+ "detail": "props.contact"}],
202
+ "unregistered": ["checkout.paid"],
203
+ "source": "web"}
204
+ ```
205
+
206
+ Partial acceptance is deliberate. The facade retries a batch until its ladder
207
+ gives up and DROPS all twenty events, so condemning nineteen good ones for
208
+ one bad one loses nineteen. A batch-level fault (bad shape, oversized, missing
209
+ write key) is a real 4xx — that one IS the caller's request being wrong.
210
+
211
+ Rejection reasons: `pii`, `unregistered`, `missing_name`, `name_too_long`,
212
+ `unknown_kind`, `not_an_object`, `props_not_an_object`,
213
+ `props_not_serializable`, `props_too_large`, `invalid_ts`, `too_old`. Each
214
+ maps to an i18n key (`stapel_analytics.errors.REJECTION_KEYS`).
215
+
216
+ ### The legacy alias
217
+
218
+ `@stapel/analytics` 0.1 hardcodes `COLLECTOR_PATH = "/analytics/api/events"`
219
+ — an un-versioned path the fleet canon does not have, and it shipped before
220
+ this module existed. So the same view is ALSO mounted there:
221
+
222
+ ```
223
+ /analytics/api/v1/events canon (api-versioning.md §2)
224
+ /analytics/api/events compatibility alias, LEGACY_INGEST_ALIAS
225
+ ```
226
+
227
+ It is a compatibility surface with an end date, not a second API — it goes
228
+ when the facade targets `api/v1` (§10). `LEGACY_INGEST_ALIAS = False` drops
229
+ it today.
230
+
231
+ ---
232
+
233
+ ## 5. Funnels
234
+
235
+ A funnel is an ordered list of event names plus a conversion window. Two
236
+ sources, merged, authored OVER declared:
237
+
238
+ - **declared** — `STAPEL_ANALYTICS["FUNNELS"]`, the Studio project-spec path
239
+ (analytics-standard §4: the CTO agent declares a funnel together with the
240
+ feature). Read-only over the API: its home is the spec, and an edit the
241
+ next deploy silently reverts is worse than a refusal (409).
242
+ - **authored** — rows of the `Funnel` table, created over the API.
243
+
244
+ **Computation.** One pass over the stream for the step names in the period,
245
+ grouped by SUBJECT (`SUBJECT_RESOLVER`: user hash, else anonymous id, else
246
+ session id). Per subject, steps are walked in order: the earliest step-1
247
+ event opens the window; each later step counts only if it happened at or
248
+ after the previous step and **within `window_seconds` of the FIRST one**.
249
+ That is the classic conversion window; stating it matters because the
250
+ alternative (window from the previous step) gives different numbers for the
251
+ same data and neither is "wrong". `window_seconds = 0` means unbounded.
252
+
253
+ `?compare=true` computes the same funnel over the **equal-length period
254
+ immediately preceding** the requested one — the only definition that stays
255
+ honest when somebody asks for eleven days.
256
+
257
+ Bounded by `MAX_REPORT_EVENTS`: a report that hits the bound comes back
258
+ `truncated: true` rather than becoming an unbounded scan somebody triggers by
259
+ widening a date picker.
260
+
261
+ Steps are validated **at authoring time** against the registry. A funnel with
262
+ a step nothing can emit reports 100% to step 1 and 0% after it forever, and
263
+ looks like a product problem rather than a typo.
264
+
265
+ ---
266
+
267
+ ## 6. The comm bridge — server steps of the same funnels
268
+
269
+ ```python
270
+ STAPEL_ANALYTICS = {
271
+ "COMM_BRIDGE": {
272
+ # the short form: action name -> analytics event name
273
+ "payment.completed": "payment_completed",
274
+ # the long form
275
+ "email.delivered": {
276
+ "event": "welcome_email_delivered",
277
+ "props": ["template"], # payload allowlist
278
+ "mapper": "app.analytics.map_email", # or a dotted path
279
+ },
280
+ }
281
+ }
282
+ ```
283
+
284
+ This is what makes a funnel able to END in something that happens on a
285
+ server. The subject is taken from the payload the way the fleet names people:
286
+ `user_id` (hashed HERE, exactly as the browser hashes it, so the server step
287
+ joins the clicks that preceded it), or `user_hash` / `anon_id` / `session_id`
288
+ when the emitter already speaks analytics. The raw `user_id` never reaches
289
+ the store.
290
+
291
+ Without an allowlist or a mapper, the bridge carries the payload's **scalar**
292
+ keys only. Nested structures are dropped rather than flattened: a bridged
293
+ event should read like a milestone, not like a copy of somebody else's
294
+ aggregate.
295
+
296
+ Configured-but-broken is loud: an unimportable or non-callable mapper raises
297
+ `ImproperlyConfigured` at `ready()`, because a bridge that quietly does not
298
+ fire looks exactly like a funnel with a bad conversion rate.
299
+
300
+ Server modules can also call it directly:
301
+
302
+ ```python
303
+ from stapel_analytics import track
304
+ track("payment_completed", {"amount": 10}, user_id=user.id)
305
+
306
+ call("analytics.track", {"name": "payment_completed", "user_id": str(user.id)})
307
+ ```
308
+
309
+ Both go through the same registry check and the same PII guard as HTTP
310
+ ingest — an app-layer module that could bypass either would be the hole the
311
+ guard exists to close.
312
+
313
+ ---
314
+
315
+ ## 7. Privacy and erasure
316
+
317
+ **Analytics rows are user data.** A behavioural stream keyed to a person is
318
+ personal data whether or not a name appears in it; hashing the user id is
319
+ pseudonymisation, not anonymisation. So the erasure provider ships in the
320
+ same release as the ingest.
321
+
322
+ - **PII guard** (`PII_MODE`, default `reject`): prop VALUES that look like an
323
+ email or a phone number refuse the event and the receipt names the prop
324
+ (`props.contact.phone`). `strip` redacts and stores; `warn` logs and
325
+ stores; `off` disables (and `analytics.W002` says so). Keys are never
326
+ judged — `{"email_verified": true}` is not PII and
327
+ `{"note": "call 555-0100"}` is. Same heuristics as the frontend's `pii.ts`,
328
+ with one deliberate one-directional refinement: an ISO-8601 date matches
329
+ the phone SHAPE and is exempted here (the browser still redacts it, so the
330
+ two tiers never disagree about a value that actually travels).
331
+ - **User hash**: `sha256(USER_HASH_SALT + user_id)`, and the salt is EMPTY by
332
+ default because `@stapel/analytics`' `hash.ts` hashes unsalted. A salt is a
333
+ real privacy improvement and a real break of the client/server funnel join
334
+ — `analytics.W008` states the trade on every boot.
335
+ - **Erasure** (`stapel_analytics.erasure`): owner name `analytics`, subject
336
+ types `account` and `anon`. The policy is **hard delete**, not anonymize —
337
+ an analytics row stripped of its subject cannot be attributed, funnelled or
338
+ reported on, so keeping it is "we kept a little bit".
339
+ `erase_account` also purges the anonymous sessions the person was ever seen
340
+ under, collected BEFORE the first pass because the linking rows are about
341
+ to go. Both protocols reach the same code: the 0.5.0 bus request (wired by
342
+ `stapel_core.gdpr.register_gdpr_owner` in `apps.py` — erasure request,
343
+ owner probe, legacy `user.deleted`) and the in-process
344
+ `AnalyticsGDPRProvider`.
345
+ - **Retention** (`RETENTION_DAYS`, default 400 days), applied by
346
+ `manage.py purge_analytics` / `stapel_analytics.tasks.purge_analytics_events`.
347
+ `None` means keep forever, which for personal data is a decision —
348
+ `analytics.W005` accepts either this horizon or the event store's own
349
+ per-stream `RETENTION`.
350
+
351
+ Declare the owner in the host:
352
+
353
+ ```python
354
+ STAPEL_GDPR = {"DATA_OWNERS": [..., "analytics"]}
355
+ ```
356
+
357
+ ---
358
+
359
+ ## 8. Server-side fan-out
360
+
361
+ Open adapter registry, same merge semantics as the event registry:
362
+
363
+ ```
364
+ built-ins <- ADAPTERS <- register_adapter()
365
+ ```
366
+
367
+ An entry merges OVER its built-in and `config` merges one level deep, so a
368
+ host names a URL without restating a handler it did not write. `None`
369
+ removes.
370
+
371
+ ```python
372
+ STAPEL_ANALYTICS = {
373
+ "ADAPTERS": {
374
+ "webhook": {"enabled": True, "config": {"url": "https://collect…"}},
375
+ "posthog": {"handler": "app.analytics.posthog", "enabled": True},
376
+ }
377
+ }
378
+ ```
379
+
380
+ Built-ins: `webhook` (POST the batch as JSON, through the fleet's SSRF guard)
381
+ and `log` (dev mirror of the frontend's console provider). **Both ship
382
+ disabled** — the webhook has no URL to send to, and writing every analytics
383
+ event into the application log puts the data the PII guard just protected
384
+ into a log aggregator nobody scoped.
385
+
386
+ **Delivery is out of band, never inline** (design §3: delivery goes through
387
+ the outbox, not inline). Recording appends to the store and emits
388
+ `analytics.events.recorded` inside the same transaction; that Action travels
389
+ comm's transactional outbox and the consumer in `actions.py` calls the
390
+ adapters. Nothing a third-party adapter does can be paid for by the browser
391
+ that sent the batch.
392
+
393
+ A failing adapter is **contained**, not retried: the store is the record,
394
+ fan-out is a mirror, and re-raising would re-deliver the batch to the
395
+ adapters that succeeded. Recovery is explicit and idempotent by range:
396
+
397
+ ```
398
+ python manage.py analytics_fanout --since 2026-08-24T00:00:00Z --adapter posthog
399
+ ```
400
+
401
+ A vendor SDK is never a built-in: it is one file in the app layer plus one
402
+ line of settings — which is exactly the fast-track contribution class
403
+ analytics-standard §5 describes.
404
+
405
+ ---
406
+
407
+ ## 9. Settings — `STAPEL_ANALYTICS`
408
+
409
+ | Key | Default | What it decides |
410
+ |---|---|---|
411
+ | `EVENTS` | `{}` | merge-registry of event definitions; `None` removes |
412
+ | `EVENTS_FILE` | `None` | path to the project's `analytics/events.json` |
413
+ | `REGISTRY_MODE` | `"warn"` | `warn` (store+mark) / `reject` / `off` |
414
+ | `MAX_BATCH_SIZE` | `500` | events per batch; larger is refused whole |
415
+ | `MAX_BODY_BYTES` | `1048576` | request body cap (413) |
416
+ | `MAX_PROPS_BYTES` | `16384` | per-event props cap |
417
+ | `MAX_NAME_LENGTH` | `200` | longest event/page/source name |
418
+ | `WRITE_KEYS` | `{}` | `{write_key: source_name}` |
419
+ | `REQUIRE_WRITE_KEY` | `False` | **decision**: refuse an unkeyed batch (401) |
420
+ | `DEFAULT_SOURCE` | `"web"` | source when no key names one |
421
+ | `INGEST_AUTHENTICATION` | `[]` | authenticator dotted paths for the ingest view only |
422
+ | `MAX_CLOCK_SKEW_SECONDS` | `300` | a clock further ahead is corrected to server time |
423
+ | `MAX_EVENT_AGE_SECONDS` | `604800` | older events are refused as a stale replay; `0` disables |
424
+ | `PII_MODE` | `"reject"` | **decision**: `reject` / `strip` / `warn` / `off` |
425
+ | `USER_HASH_SALT` | `""` | **decision**: a salt breaks the frontend funnel join |
426
+ | `MAX_ID_LENGTH` | `128` | anon/session id truncation |
427
+ | `STREAM` | `"analytics"` | event-store stream name |
428
+ | `RETENTION_DAYS` | `400` | **decision**: `None` = keep forever |
429
+ | `PURGE_SCHEDULE` | `{"hour": 4, "minute": 30}` | beat cadence for the purge |
430
+ | `QUERY_PAGE_SIZE` | `1000` | rows per store page on a report/erasure pass |
431
+ | `MAX_REPORT_EVENTS` | `200000` | scan ceiling; a report past it is `truncated` |
432
+ | `FUNNELS` | `{}` | funnels declared by the project spec (read-only over the API) |
433
+ | `DEFAULT_FUNNEL_WINDOW_SECONDS` | `604800` | window when a funnel names none |
434
+ | `MAX_FUNNEL_STEPS` | `12` | steps per funnel |
435
+ | `MAX_FUNNELS_PER_OWNER` | `50` | authored funnels per user |
436
+ | `ADAPTERS` | `{}` | merge-registry of fan-out adapters; `None` removes |
437
+ | `FANOUT_ENABLED` | `True` | emit `analytics.events.recorded` at all |
438
+ | `FANOUT_BATCH_SIZE` | `200` | events per fan-out Action |
439
+ | `COMM_BRIDGE` | `{}` | `{action_name: event_name \| spec}` |
440
+ | `SERVER_SOURCE` | `"server"` | source on bridged / `track()` events |
441
+ | `MAX_PAGE_SIZE` | `100` | cap on the funnel listing (`?limit=` may ask for less) |
442
+ | `LEGACY_INGEST_ALIAS` | `True` | mount `/analytics/api/events` for the shipped facade |
443
+ | `SUBJECT_RESOLVER` | `…ingest.default_subject` | dotted path: what "the same person" means |
444
+ | `PII_GUARD` | `…privacy.looks_like_pii` | dotted path: the PII heuristic |
445
+
446
+ `SUBJECT_RESOLVER` and `PII_GUARD` are `import_strings` members: they NAME
447
+ CODE, so they are never readable from an environment variable.
448
+
449
+ ### System checks
450
+
451
+ | Id | Level | Fires when |
452
+ |---|---|---|
453
+ | `analytics.E001` | Error | the default event store is used and `stapel_core.django.eventstore` is not installed |
454
+ | `analytics.E002` | Error | `EVENTS_FILE` cannot be read or parsed |
455
+ | `analytics.W001` | Warning | nothing beyond the built-ins is declared |
456
+ | `analytics.W002` | Warning | `PII_MODE = "off"` |
457
+ | `analytics.W003` | Warning | `REGISTRY_MODE = "off"` |
458
+ | `analytics.W004` | Warning | a funnel names steps outside the registry |
459
+ | `analytics.W005` | Warning | no retention horizon anywhere |
460
+ | `analytics.W006` | Warning | an enabled adapter cannot deliver |
461
+ | `analytics.W007` | Warning | the comm bridge targets unregistered events |
462
+ | `analytics.W008` | Warning | `USER_HASH_SALT` is set (the join breaks) |
463
+ | `analytics.W009` | Warning | `analytics` is not in `STAPEL_GDPR["DATA_OWNERS"]` |
464
+ | `analytics.W010` | Warning | `REQUIRE_WRITE_KEY` on with no `WRITE_KEYS` |
465
+
466
+ ---
467
+
468
+ ## 10. comm surface, commands, and the open follow-ups
469
+
470
+ **Functions** (`schemas/functions/`): `analytics.track`,
471
+ `analytics.event_registry`, `analytics.funnel_report`.
472
+ **Emits** (`schemas/emits/`): `analytics.events.recorded`, plus the GDPR
473
+ receipts `gdpr.section.erased` / `gdpr.owner.alive`.
474
+ **Consumes** (`schemas/consumes/`, documentation only — `autoload_schemas`
475
+ registers `emits/` and `functions/`): `gdpr.erasure.requested`,
476
+ `gdpr.owner.probe`, `user.deleted`, plus whatever `COMM_BRIDGE` names.
477
+
478
+ **Commands**: `analytics_event_registry`, `analytics_funnel_report`,
479
+ `analytics_fanout`, `purge_analytics`.
480
+
481
+ **Follow-ups filed here rather than left implicit:**
482
+
483
+ 1. `@stapel/analytics` should target `/analytics/api/v1/events` and send
484
+ `anon_id` / `session_id`; then `LEGACY_INGEST_ALIAS` can default to
485
+ `False` and be removed a minor later.
486
+ 2. The frontend's `pii.ts` should adopt the ISO-8601 exemption this module
487
+ ships (§7), so the two guards are identical again rather than
488
+ one-directionally compatible.
489
+ 3. `post_json` in `transport.py` duplicates the POST shape stapel-webhooks
490
+ also carries around core's GET-only `fetch_bytes`. Both are waiting on a
491
+ `post_bytes` in `stapel_core.net`.
492
+ 4. The funnel DASHBOARD of analytics-standard §3 (the house dashboard
493
+ pattern, as in stapel-translate) is not built: this ships the data behind it
494
+ (`/funnels/<slug>/report`, `/reports/events`) and Studio renders it.
495
+ 5. Erasure purges by a JSON payload key, which is correct on every backend
496
+ and slow on a large Postgres stream. The scale-out answer is the event
497
+ store's own (`STAPEL_EVENTSTORE["ROUTES"]` to a column-store backend),
498
+ not a schema here.