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.
- stapel_analytics-0.1.0/CONFIG.MD +108 -0
- stapel_analytics-0.1.0/LICENSE +21 -0
- stapel_analytics-0.1.0/MODULE.md +498 -0
- stapel_analytics-0.1.0/PKG-INFO +168 -0
- stapel_analytics-0.1.0/README.md +139 -0
- stapel_analytics-0.1.0/__init__.py +66 -0
- stapel_analytics-0.1.0/_codegen_settings.py +89 -0
- stapel_analytics-0.1.0/actions.py +214 -0
- stapel_analytics-0.1.0/adapters.py +235 -0
- stapel_analytics-0.1.0/admin.py +28 -0
- stapel_analytics-0.1.0/apps.py +35 -0
- stapel_analytics-0.1.0/checks.py +380 -0
- stapel_analytics-0.1.0/conf.py +184 -0
- stapel_analytics-0.1.0/conftest.py +130 -0
- stapel_analytics-0.1.0/dto.py +117 -0
- stapel_analytics-0.1.0/erasure.py +162 -0
- stapel_analytics-0.1.0/errors.py +133 -0
- stapel_analytics-0.1.0/events.py +32 -0
- stapel_analytics-0.1.0/functions.py +117 -0
- stapel_analytics-0.1.0/funnels.py +352 -0
- stapel_analytics-0.1.0/gdpr.py +77 -0
- stapel_analytics-0.1.0/ingest.py +343 -0
- stapel_analytics-0.1.0/manage.py +27 -0
- stapel_analytics-0.1.0/migrations/0001_initial.py +35 -0
- stapel_analytics-0.1.0/migrations/__init__.py +0 -0
- stapel_analytics-0.1.0/models.py +91 -0
- stapel_analytics-0.1.0/presenters.py +220 -0
- stapel_analytics-0.1.0/privacy.py +147 -0
- stapel_analytics-0.1.0/py.typed +0 -0
- stapel_analytics-0.1.0/pyproject.toml +85 -0
- stapel_analytics-0.1.0/registry.py +282 -0
- stapel_analytics-0.1.0/schemas/consumes/gdpr.erasure.requested.json +18 -0
- stapel_analytics-0.1.0/schemas/consumes/gdpr.owner.probe.json +11 -0
- stapel_analytics-0.1.0/schemas/consumes/user.deleted.json +12 -0
- stapel_analytics-0.1.0/schemas/emits/analytics.events.recorded.json +31 -0
- stapel_analytics-0.1.0/schemas/emits/gdpr.owner.alive.json +24 -0
- stapel_analytics-0.1.0/schemas/emits/gdpr.section.erased.json +36 -0
- stapel_analytics-0.1.0/schemas/functions/analytics.event_registry.json +8 -0
- stapel_analytics-0.1.0/schemas/functions/analytics.funnel_report.json +14 -0
- stapel_analytics-0.1.0/schemas/functions/analytics.track.json +17 -0
- stapel_analytics-0.1.0/serializers.py +135 -0
- stapel_analytics-0.1.0/services.py +305 -0
- stapel_analytics-0.1.0/setup.cfg +4 -0
- stapel_analytics-0.1.0/stapel_analytics.egg-info/PKG-INFO +168 -0
- stapel_analytics-0.1.0/stapel_analytics.egg-info/SOURCES.txt +118 -0
- stapel_analytics-0.1.0/stapel_analytics.egg-info/dependency_links.txt +1 -0
- stapel_analytics-0.1.0/stapel_analytics.egg-info/requires.txt +4 -0
- stapel_analytics-0.1.0/stapel_analytics.egg-info/top_level.txt +1 -0
- stapel_analytics-0.1.0/store.py +169 -0
- stapel_analytics-0.1.0/tasks.py +70 -0
- stapel_analytics-0.1.0/tests/test_adapters.py +288 -0
- stapel_analytics-0.1.0/tests/test_api.py +434 -0
- stapel_analytics-0.1.0/tests/test_checks.py +293 -0
- stapel_analytics-0.1.0/tests/test_comm_bridge.py +294 -0
- stapel_analytics-0.1.0/tests/test_contract.py +258 -0
- stapel_analytics-0.1.0/tests/test_functions.py +163 -0
- stapel_analytics-0.1.0/tests/test_funnels.py +343 -0
- stapel_analytics-0.1.0/tests/test_gdpr.py +284 -0
- stapel_analytics-0.1.0/tests/test_ingest.py +368 -0
- stapel_analytics-0.1.0/tests/test_library_commands.py +216 -0
- stapel_analytics-0.1.0/tests/test_presentation.py +189 -0
- stapel_analytics-0.1.0/tests/test_privacy.py +192 -0
- stapel_analytics-0.1.0/tests/test_public_api.py +82 -0
- stapel_analytics-0.1.0/tests/test_registry.py +228 -0
- stapel_analytics-0.1.0/tests/test_services.py +180 -0
- stapel_analytics-0.1.0/tests/test_store.py +193 -0
- stapel_analytics-0.1.0/tests/test_transport.py +270 -0
- stapel_analytics-0.1.0/tests/test_wire_compat.py +294 -0
- stapel_analytics-0.1.0/translations/errors.es.json +24 -0
- stapel_analytics-0.1.0/translations/errors.ru.json +24 -0
- stapel_analytics-0.1.0/transport.py +130 -0
- stapel_analytics-0.1.0/urls.py +44 -0
- stapel_analytics-0.1.0/urls_v1.py +58 -0
- 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.
|