Prima Get an API key

Module kit / Analytics and tracking

Event Ingestion and Sessions

analytics-tracking/event-ingestion-and-sessions

Kind backend
Domain analytics-tracking
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, redis-6, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/alembic-migrations, _shared/test-layout
Frontends analytics-tracking/ui/tracking-dashboard
Reference implementation
  • alchemy_labs/backend/services/tracking_service.py
  • alchemy_labs/backend/models/tracking_model.py
  • alchemy_labs/backend/api/routers/tracking_router.py
  • alchemy_labs/backend/api/routers/tracking_sites_router.py
  • alchemy_labs/backend/tests/test_tracking_scope.py
  • alchemy_labs/backend/tests/integration/test_tracking_multi_tenant.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A client-side beacon (scribe.js) can fire an event with nothing but a public site_id string and get it persisted against the correct tenant, without ever being trusted to say which workspace it belongs to, and without ever being able to create a session or event under a workspace it has no registered site in. The invariant that makes it trustworthy is that workspace_id is derived exactly once, from a server-side lookup of a registered, active TrackingSite row (tracking_service.py _resolve_workspace_for_site, 41 to 61), never copied from anything the client sent, and every session and event row this path writes carries that resolved id, not the client's string.

When to use

Read it before touching _resolve_workspace_for_site, get_or_create_session, track_event, _create_crm_activity, _map_event_to_activity_type, _format_activity_title, _format_activity_description, or POST /api/scribe/event; before changing what counts as a registered site; and when someone asks "why did this event not show up", "why is site X's data landing under the wrong workspace", "does an event always create a CRM activity", or "what happens when X-Site-ID is wrong". Trigger phrases: "scribe ingest", "site_id", "X-Site-ID", "anonymous_id", "tracking_sites registry", "get_or_create_session", "CRM activity fan-out", "unknown_site".

Not this recipe: POST /api/scribe/identify and the fingerprint-based session-merging / cross-email identity resolution it drives (identify_visitor, _merge_sessions_by_fingerprint, _link_contacts_by_fingerprint, tracking_service.py 161 to 335) - a separate recipe on the same service; the read-side dashboard aggregates GET /stats, GET /timeseries, GET /sessions, GET /sessions/{id}/events and the access-scoping helper they share (require_site_id_or_admin) are covered here only as far as the API surface table, because this recipe's frontend module renders them, but their heavier aggregation logic (_compute_stats_aggregate, _compute_timeseries, the warm-cache job warm_scribe_reports) is not walked step by step; ad-platform conversion forwarding (GET/POST /conversion, _forward_ga4_event, _forward_meta_pageview, Meta/Google/LinkedIn CAPI) is scheduled from inside track_event's router handler but is its own seam, out of scope here except where it touches the same TrackingEvent row; the tracking_sites registry's own CRUD (tracking_sites_router.py, POST/GET/PATCH/DELETE /api/tracking-sites) is the write side of the table this recipe only reads from; contact/lead creation itself (identify_visitor, _link_to_crm_lead, _backfill_events_to_crm) is not this recipe - this recipe only reads an already-linked contact's lead_id to decide whether to fan an event out to the CRM activity log.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for the request body, psycopg2-binary==2.9.10, redis==6.4.0 (async client, services/cache_service.py, used by the GET endpoints' response cache, not by the ingest write path itself), Postgres 14 in the test harness (docker-compose.test.yml line ~114 to 122, service postgres-test, port 5434). Foundations assumed: S4 (fastapi-project-layout) for mount_router and the app's blanket Exception handler (app_fastapi.py 795 to 814) that every uncaught error on the GET endpoints ultimately falls into; S5 (auth-dependency-pattern) for get_current_user, which gates every endpoint in this recipe except POST /event itself; S8 (alembic-migrations) for the migration chain under Data model; S9 (test-layout) for the pytest.ini markers (database, critical, api) and the db_session/client fixtures test_tracking_multi_tenant.py uses.

This recipe deliberately does not depend on _shared/workspace-scoping: that foundation resolves the tenant from an authenticated caller's X-Workspace-ID/session (workspace-scoping.md, "Not this recipe: machine-to-machine seams that carry no session"). The ingest path here has no session at all - POST /event is mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956) - and resolves tenancy from a different, bespoke mechanism: a public site_id string checked against a registry table. The two GET-side tenancy helpers (require_site_id_or_admin, get_user_site_ids) do sit on top of S5's get_current_user and are closer in shape to S6, but they resolve a list of allowed site_ids through workspace membership rather than a single X-Workspace-ID, so they are native to this router, not a reuse of the shared dependency.

Environment: no dedicated env var gates ingestion itself (unlike the auto-topup recipe's PRIMA_LOW_BALANCE_ALERT_SECRET-style seam, there is no shared credential here - the "credential" is knowing a registered site_id, which is not confidential, it ships in the public scribe.js snippet). Two settings from core/config.py shape the surface indirectly: RATE_LIMIT_ENABLED (default True, line 882) and RATE_LIMIT_PUBLIC_IP_PER_MINUTE (default 240, line 893) - see Security & tenancy. Fail behaviour: track_event (the router handler, tracking_router.py 538 to 674) never lets an exception propagate to a 5xx; both UnknownTrackingSite and any other exception are caught and answered with HTTP 200 and {"success": false, ...} in the body (661 to 674) - the beacon must never see a hard failure that would make it retry or break the page.

Data model

Three tables this recipe writes or reads directly (models/tracking_model.py); TrackingContact is read only, never created, by this recipe's code paths.

tracking_sites (                                    -- the tenancy registry (29 to 52)
  site_id          varchar PRIMARY KEY,              -- the public string scribe.js sends as X-Site-ID
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  name             varchar NOT NULL,
  allowed_origins  jsonb DEFAULT '[]',                -- CSP-style allowlist; empty = Origin/Referer not enforced
  is_active        boolean NOT NULL DEFAULT true,
  created_at, updated_at timestamptz
)

tracking_sessions (                                  -- one row per browser session (55 to 114)
  id               uuid PRIMARY KEY,
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  anonymous_id     varchar UNIQUE NOT NULL,           -- per-session client id; the idempotency key for get_or_create
  fingerprint      varchar,                           -- device/browser hash; set opportunistically, never overwritten
  fingerprint_components jsonb,
  contact_id       uuid REFERENCES tracking_contacts(id),  -- NULL until identify_visitor or this recipe's email match links it
  site_id          varchar,
  user_agent       varchar,
  ip_hash          varchar,                           -- sha256(ip)[:16], never the raw IP
  utm_source, utm_medium, utm_campaign, utm_content, utm_term,
  referrer, landing_page, fbclid, gclid, li_fat_id     varchar,
  is_internal      boolean NOT NULL DEFAULT false,     -- latches true, never reverts (get_or_create_session, 97-98)
  first_seen, last_seen timestamptz
  -- INDEX ix_tracking_sessions_fingerprint_site (fingerprint, site_id)
)

tracking_events (                                    -- one row per tracked event (214 to 260)
  id               uuid PRIMARY KEY,
  workspace_id     uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  session_id       uuid NOT NULL REFERENCES tracking_sessions(id),
  contact_id       uuid REFERENCES tracking_contacts(id),   -- copied from the session, or resolved by email at write time
  event_type       varchar NOT NULL,                  -- 'page_view', 'button_click', 'form_submitted', ...; free-form
  event_data       jsonb,                              -- arbitrary client-supplied payload, no schema/allowlist
  page_url, page_path, page_title  varchar,
  site_id          varchar,
  created_at       timestamptz
  -- INDEX ix_tracking_events_contact_created (contact_id, created_at)
  -- INDEX ix_tracking_events_session_type (session_id, event_type)
)

What is NOT stored anywhere in this recipe's write path: the raw client IP (only its truncated sha256, tracking_service.py 108); a workspace_id chosen by the client (always the registry lookup's answer); a new TrackingContact row (this recipe links an existing contact by email match, track_event 561 to 570, it never inserts one - that is identify_visitor's job, out of scope); any validation or allowlist on event_type or event_data - both are stored exactly as the client sent them.

API surface

RouteAuthTenancyCodesNotes
POST /api/scribe/event (tracking_router.py 538 to 674; TrackEventRequest schema 342 to 363)none - surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956)site_id from X-Site-ID header, falling back to the body's site_id, falling back to the literal string "unknown" (553); resolved to workspace_id via _resolve_workspace_for_site200 always on the code paths this recipe walks - {"success": true, "event": ..., "event_id": ..., "session_id": ..., "contact_linked": bool, "ga4_sent": bool, "meta_capi_sent": bool} on success (648 to 659); 200 {"success": false, "error": "unknown_site"} when the site is unregistered/inactive (665 to 666); 200 {"success": false, "message": str(e), "event": ...} on any other exception (667 to 674)No HTTPException is ever raised from this handler for a tracking failure - see Gotchas for the mismatch between this and _resolve_workspace_for_site's own docstring. anonymous_id, if the client omits it, is minted in the handler as f"anon_{datetime.utcnow().timestamp()}" (556 to 558), not inside the service.
GET /api/scribe/stats (1680 to 1707)get_current_userrequire_site_id_or_admin200Merges a cached heavy aggregate (_compute_stats_aggregate, hourly-warmed) with a short-TTL live feed (_compute_stats_feed); read here only for the API-surface line, not walked.
GET /api/scribe/timeseries (2408 to 2447)get_current_userrequire_site_id_or_admin200days 1 to 365, granularity hour|day|week (`^(hour
GET /api/scribe/sessions (2454 to 2591)get_current_userrequire_site_id_or_admin200Paginated (page, page_size 1 to 100), filters is_internal, optional source/campaign/site_id.
GET /api/scribe/sessions/{session_id}/events (2594 to 2641)get_current_useradmin unrestricted; non-admin checked against get_user_site_ids(...) or [NO_TRACKING] (2616 to 2622)404 session not found or internal (2607 to 2613); 403 non-admin whose site set excludes the session's site_id (2620 to 2622); 200 otherwise, heartbeat events filtered outsession_id is typed str in the route signature (2596), not UUID - see Security & tenancy.

The three Python entrypoints the ingest route actually calls, the real surface a rebuild reimplements:

FunctionRaisesReturns
_resolve_workspace_for_site(site_id) (41 to 61)UnknownTrackingSite if site_id is falsy, or matches no active TrackingSite rowUUID (the workspace id)
get_or_create_session(anonymous_id, ..., site_id, ...) (67 to 155)whatever _resolve_workspace_for_site raises; swallows IntegrityError internally(TrackingSession, is_new: bool)
track_event(event_type, anonymous_id, ..., site_id, ...) (528 to 597)whatever get_or_create_session raisesTrackingEvent

Application logic

track_event (router handler, 538 to 674) then TrackingService.track_event (528 to 597), in commit order:

  1. The router resolves site_id from X-Site-ID, else the body, else "unknown" (553); mints anonymous_id if the client sent none (556 to 558) - a client that never sends anonymous_id gets a new session on every single call, since the minted value is never reused (Gotchas).
  2. service.track_event(...) runs in a threadpool (run_in_threadpool, 570), not on the event loop - the write is synchronous SQLAlchemy.
  3. Inside the service, get_or_create_session first calls _resolve_workspace_for_site(site_id) (84): queries tracking_sites for site_id == <given> AND is_active IS TRUE (55 to 58); no row ⇒ raises UnknownTrackingSite, and nothing downstream runs - no session, no event.
  4. Existing-session path: a TrackingSession row is queried by anonymous_id (87 to 89, the table's own unique column). If found: fingerprint is set only if the row had none and one is now given (93 to 95, never overwritten once set); is_internal latches to true if either side says so, never back to false (97 to 98); last_seen is bumped and committed (99 to 105); a concurrent IntegrityError here is caught and rolled back with no retry (101 to 104) - the update is simply dropped on that race, not reapplied.
  5. New-session path (no existing row): the IP, if given, is hashed sha256(ip)[:16] (108); a TrackingSession is built with the workspace_id from step 3, the given fingerprint, and every attribution field copied from the attribution dict verbatim (110 to 128); committed (129 to 131). On IntegrityError (another worker won the same anonymous_id insert race), the transaction rolls back, the winning row is re-fetched, and the SAME fingerprint/is_internal/last_seen update as step 4 is applied to it (132 to 152) - the caller always gets back a real session either way, never a raised race error.
  6. Back in track_event (558 onward): contact_id starts as session.contact_id. If that is None and the request carried an email, a TrackingContact is looked up scoped to (session.workspace_id, email.lower().strip()) (561 to 565); if found, the session is linked to it and committed (568 to 570) - no contact is ever created here, only an existing one is attached.
  7. A TrackingEvent row is built from workspace_id, session.id, the resolved contact_id, event_type, the raw event_data, page_url/page_title from context, page_path from event_data, and site_id (573 to 583); added and committed (584 to 585).
  8. If contact_id was resolved (not None): the contact's total_events counter is incremented by 1 in its own commit (588 to 594), then _create_crm_activity(contact_id, event_type, event_data, context, site_id) is called (595) - this is the fan-out this recipe's scope names.
  9. _create_crm_activity (599 to 648) re-fetches the contact; if it has no lead_id, returns immediately (612 to 613) - the fan-out only fires for a contact already linked to a CRM Lead, never for a merely-identified contact. event_type is mapped through the fixed dict in _map_event_to_activity_type (650 to 665: page_view→page_viewed, button_click/cta_click/navigation_click/package_click/service_click/whatsapp_click/calendly_click→cta_clicked, video_play/video_complete→video_watched, form_submitted→form_submitted); an unmapped type (e.g. heartbeat, scroll_depth) returns None and the whole fan-out is skipped (616 to 618) - no activity row, no scoring call.
  10. A ClientActivityLog row is built: activity_title from _format_activity_title (667 to 690, per-event-type copy, e.g. f'Viewed page: {page}'), activity_description from _format_activity_description (692 to 713, a ' | '-joined string of whatever's present among page URL, page title, time-on-page, scroll depth - None if nothing is), meta_data a JSON-dumped dict of {event, event_data, context, site_id, page_path, page_url, timestamp} (625 to 633); added and committed (635 to 636).
  11. Best-effort: LeadScoringService.update_lead_score(lead) is called and committed inside a try; any exception is caught, logged as a warning, and swallowed (639 to 648) - a scoring failure never rolls back the activity row already committed in step 10, and never surfaces to the ingest caller.
  12. Back in the router: GA4 and Meta CAPI forwarding are scheduled as BackgroundTasks for page_view/button_click/form_submit/scroll_depth events (585 to 646) - they read event.id (642) but run after the response body is already assembled; out of this recipe's scope past this note.
  13. The handler returns 200 with success: true and the ids from steps 3 to 7 (648 to 659) regardless of whether steps 8 to 11 ran (they only run when contact_id was resolved at all).

Invariants

  • workspace_id on every session and event this path writes comes from exactly one place, _resolve_workspace_for_site's registry lookup, never from the client's site_id string directly (_resolve_workspace_for_site docstring, 41 to 47; tracking_sites_router.py module docstring, 1 to 7). Not exercised by either test file this recipe measures against - no test in test_tracking_scope.py or test_tracking_multi_tenant.py calls _resolve_workspace_for_site or track_event directly; the closest coverage is test_identify_visitor_respects_workspace_id, which exercises identify_visitor, a different entry point (Gotchas).
  • An unregistered or inactive site_id writes nothing: _resolve_workspace_for_site raises before any TrackingSession or TrackingEvent is constructed (41 to 61). Also not exercised by either given test file.
  • Two racing inserts of the same anonymous_id resolve to one session row: the unique constraint plus the catch-and-refetch in get_or_create_session (132 to 152) is the same shape as the auto-topup recipe's IntegrityError-race handling, but unlike that recipe, no threaded concurrency test in either given test file proves it under real contention (Gotchas).
  • The CRM fan-out never creates a Lead or a TrackingContact, and never fires for a contact with no lead_id (_create_crm_activity, 612 to 613).
  • A lead-scoring failure after a successful activity write never undoes the write (_create_crm_activity, 639 to 648, the try wraps only the scoring call, which runs after the activity's own db.commit() at 636).
  • is_internal on a session is one-directional: once true, no later call can clear it (get_or_create_session, 97 to 98, 145 to 146).

Frontend

The module that renders this data, by id:

analytics-tracking/ui/tracking-dashboard   the workspace-scoped Scribe dashboard: totals/feed from GET /stats,
                                            charts from GET /timeseries, the paginated list from GET /sessions,
                                            and a session's event timeline from GET /sessions/{id}/events.
                                            Not yet written in this kit (zero recipes existed in this domain
                                            before this one); this is a forward reference to the module id the
                                            four GET endpoints above exist to serve, not a claim that the demo
                                            already exists.

Minimum admin/debug surface: none beyond the GET endpoints themselves - unlike the billing-credits recipe's hand-run SQL queries, every piece of state this recipe touches (tracking_sites, tracking_sessions, tracking_events) already has an authenticated read path in this same router; a human debugging a rejected site_id reads the tracking_sites table directly (or GET /api/tracking-sites, tracking_sites_router.py 50 to 59) rather than through a dedicated debug query.

Security & tenancy

Auth dependency: POST /api/scribe/event has none - mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956), same treatment as the legacy alias router (957). The only gate is the site_id registry lookup, and site_id is not confidential: it ships in the public scribe.js embed. The four GET endpoints require get_current_user (S5) and are additionally scoped by require_site_id_or_admin (177 to 207): admin with no site_id requested ⇒ None (unrestricted); admin with one requested ⇒ that one site; non-admin ⇒ every site reachable through their workspace memberships (get_user_site_ids, 131 to 174, resolved via a raw SQL join on workspace_users/workspaces, never via Project.user_id, per that function's own docstring on the bug this replaced); non-admin with zero reachable sites ⇒ the sentinel [NO_TRACKING] (110), which matches no row rather than resolving to None/unrestricted.

Fail direction: tenancy resolution on the write path is closed (_resolve_workspace_for_site raises rather than guessing); the write path's HTTP-level fail direction is deliberately open at the transport layer - track_event's handler answers 200 even on failure (661 to 674), because the beacon must not treat a tracking failure as something to retry or surface to the visitor. GET /sessions/{id}/events's non-admin check is explicitly fail-closed per its own inline comment: a prior version's if user_site_id and ... short-circuited past the check entirely when user_site_id resolved to nothing, letting any non-admin read any session's events by id; the fix substitutes [NO_TRACKING] so the comparison always runs (2616 to 2622).

A gap between documentation and code, both read directly: _resolve_workspace_for_site's own docstring says "Callers should translate this to a 404 response" for UnknownTrackingSite (49 to 52), but the only caller in this recipe's scope, track_event's router handler, does not - it catches the exception and answers HTTP 200 with {"success": false, "error": "unknown_site"} (661 to 666). Whether that is the intended contract or drift from an earlier version is not determinable from the code alone.

session_id on GET /sessions/{id}/events is typed str in the route signature (2596), not UUID - FastAPI/Pydantic does not reject a malformed id before it reaches db.query(TrackingSession).filter(TrackingSession.id == session_id) (2607 to 2610), a comparison against a UUID column. A malformed value would raise at the database layer and be caught only by the app's blanket Exception handler (app_fastapi.py 795 to 814), which answers a generic 500, not a clean 4xx. This is read from the code shape, not fired against a live request in this pass.

Rate limiting: /api/scribe is listed under UnifiedMiddleware.PUBLIC_PREFIXES (api/middleware/unified_middleware.py line ~134), so every request to this recipe's routes is subject to the stricter public-surface per-IP ceiling - RATE_LIMIT_PUBLIC_IP_PER_MINUTE, default 240/minute (core/config.py line 893) - on top of the general RATE_LIMIT_IP_PER_MINUTE (default 1000/minute, line 889), both Redis-backed and failing open on a limiter error (comment at unified_middleware.py, "never block traffic because the limiter itself errored"); a 429 carries Retry-After.

Secrets and PII at rest: ip_hash is sha256(ip_address)[:16], the raw IP is never stored (tracking_service.py 108). event_data/context/page_url are arbitrary client-supplied JSON with no schema or allowlist in this recipe - whatever the embedding site's own JavaScript chooses to send lands verbatim in tracking_events.event_data and, when fanned out, in ClientActivityLog.meta_data.

Tests

Run 2026-09-12 against the venv at alchemy_labs/backend/.venv, on main at 62ba4fc4.

test_tracking_scope.py is fully hermetic - every db is a unittest.mock.MagicMock, no Postgres required:

python -m pytest tests/test_tracking_scope.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 14 passed. grep -c "^def test_" tests/test_tracking_scope.py is 12; one of them, test_non_admin_is_never_unrestricted, carries @pytest.mark.parametrize("sites", [(), ("a",), ("a", "b", "c")]), which is what takes the collected total from 12 defs to 14. This file covers require_site_id_or_admin, get_user_site_ids's caller-facing contract, _session_visible_to, and the _k_sites cache-key helper - the GET-side tenancy scoping - not _resolve_workspace_for_site, get_or_create_session, or track_event themselves.

test_tracking_multi_tenant.py needs a real Postgres and the app's create_all()-based schema (tests/conftest.py test_engine, session-scoped, drops/creates the whole Base.metadata). Against the docker-compose.test.yml postgres-test service on port 5434, running this file against the shared test_db database failed at collection with sqlalchemy.exc.IntegrityError: duplicate key value violates unique constraint "pg_type_typname_nsp_index" on CREATE TYPE clientstatus - measured contention from another concurrent test run's Base.metadata.create_all() racing on the same shared schema (this repo has several agents authoring different recipes against the same running containers in parallel; this is not a defect in the recipe's code). Against a dedicated scratch database, isolated from that contention:

PGPASSWORD=test_password psql -h localhost -p 5434 -U test_user -d test_db -c "CREATE DATABASE tracking_scratch;"
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/tracking_scratch \
    python -m pytest tests/integration/test_tracking_multi_tenant.py -q --no-cov -o addopts="" -p no:cacheprovider

printed 7 passed (measured 2026-09-12). grep -c "^def test_" tests/integration/test_tracking_multi_tenant.py is 7, no @pytest.mark.parametrize in the file. This file covers TrackingContact's per-workspace uniqueness and identify_visitor's workspace resolution from site_id - adjacent to this recipe (both rely on the same TrackingSite registry and the same workspace-scoped TrackingContact lookup pattern this recipe's step 6 uses) but not track_event/get_or_create_session themselves; identify_visitor is explicitly out of this recipe's scope (When to use).

What neither file exercises, stated plainly: no test in either given file calls _resolve_workspace_for_site, get_or_create_session, track_event, or _create_crm_activity directly; the CRM fan-out (_map_event_to_activity_type, _format_activity_title, _format_activity_description, the lead-scoring best-effort call) has no test coverage in either file. This recipe's Invariants section flags each such gap at the invariant it would otherwise prove.

Deploy & ops

Config keys: RATE_LIMIT_ENABLED (default True), RATE_LIMIT_IP_PER_MINUTE (default 1000), RATE_LIMIT_PUBLIC_IP_PER_MINUTE (default 240) - all core/config.py, all shape the middleware layer in front of this router, none gate the ingest logic itself. No dedicated credential or feature flag controls whether POST /event is reachable; it is reachable whenever the app boots (TRACKING_ROUTER_AVAILABLE at app_fastapi.py 955 is an import-success flag, not a runtime toggle).

Migrations touching this recipe's tables, in chain order (backend/migrations/versions/): 20260312_add_is_internal_tracking.py (is_internal_001) adds the internal-traffic flag; 20260313_add_tracking_performance_indexes.py (tracking_perf_idx_001) adds the two composite indexes under Data model; 20260504_workspace_id_completeness_2a.py (workspace_id_2a_001) and 20260504b_workspace_id_tracking_2b.py (workspace_id_2b_001) add workspace_id (NOT NULL, FK CASCADE) to tracking_sessions/tracking_events/tracking_contacts - the second one's own docstring records a dated 2026-05-04 production diagnostic finding three different informal site_id schemes already in the table (a workspace UUID used directly, a projects.tracking_site_id value, and bare domain strings with no DB link at all) and a real prior incident where analytics rows for one tenant's site could not be mapped to any workspace before this backfill; 20260504c_tracking_sites_registry.py (tracking_sites_001) creates the tracking_sites table this recipe's whole tenancy model depends on, seeded idempotently (ON CONFLICT DO NOTHING) from three sources so existing analytics kept flowing across the cutover; 20260504d_workspace_rls_policies.py (workspace_rls_001) adds row-level security policies on top. 20260610_0001_tracking_contacts_workspace_unique.py (tracking_contacts_ws_unique_001, chained off a different, later head) adds the composite (workspace_id, email)/(workspace_id, lead_id) uniqueness this recipe's step 6 contact lookup relies on.

Argus-style check: SELECT count(*) FROM tracking_sites WHERE is_active compared against the count of distinct site_ids seen in tracking_events over the last 24 hours names any site sending events under a site_id that is registered-but-inactive or unregistered (which, per the Application logic, simply drops silently on the ingest side - see Gotchas). Rollback: no destructive migration in this chain; a code rollback of track_event leaves already-written rows untouched, there is nothing to unwind.

Gotchas

  • 2026-09-12 (measured at 62ba4fc4): _resolve_workspace_for_site's docstring promises callers a 404 translation for UnknownTrackingSite (tracking_service.py 49 to 52); the actual caller, track_event's router handler, answers 200 {"success": false, "error": "unknown_site"} instead (tracking_router.py 665 to 666). A rebuild that takes the docstring at face value and returns a 404 would break the beacon's expectation of always getting 200, unless that expectation is itself the thing being changed.
  • 2026-09-12 (measured at 62ba4fc4): an omitted anonymous_id is minted per-request as f"anon_{datetime.utcnow().timestamp()}" in the router (556 to 558), not memoized anywhere the client can see. A caller that never sends anonymous_id - a bot, a server-to-server integration, a broken embed - gets a brand new TrackingSession row on every single event, never reusing one.
  • 2026-09-12 (measured at 62ba4fc4): a malformed session_id on GET /sessions/{id}/events is not validated as a UUID by the route signature (session_id: str, 2596) before it reaches a UUID-column comparison (2607 to 2610); on the evidence of app_fastapi.py's blanket Exception handler (795 to 814), this surfaces as a generic 500 rather than a 404/422. Not fired against a live server in this pass - determined by reading the route signature and the query it feeds, not by observing the response.
  • 2026-09-12 (measured at 62ba4fc4): running test_tracking_multi_tenant.py against the shared docker-compose.test.yml test_db database failed at fixture setup with a pg_type_typname_nsp_index IntegrityError on CREATE TYPE clientstatus, from another process's concurrent Base.metadata.create_all() against the same schema (several agents share this repo's running test containers). Isolating to a dedicated scratch database (tracking_scratch) resolved it; a CI runner that shares one Postgres across parallel jobs would need the same isolation, not a code fix.
  • 2026-09-12 (measured at 62ba4fc4): neither given test file calls _resolve_workspace_for_site, get_or_create_session, track_event, or _create_crm_activity directly (Tests). A rebuild that changes any of the four has no test in this recipe's measured surface to catch a regression; a hermetic unit test for each (mirroring test_tracking_scope.py's MagicMock-db style for the pure-logic parts, and test_tracking_multi_tenant.py's real-session style for the tenancy-sensitive parts) is what a new app adds and this branch lacks.

Reference implementation

Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/tracking_service.py (891 lines): module docstring (1 to 9); UnknownTrackingSite (31 to 32); _resolve_workspace_for_site (41 to 61); get_or_create_session (67 to 155); track_event (528 to 597); _create_crm_activity (599 to 648); _map_event_to_activity_type (650 to 665); _format_activity_title (667 to 690); _format_activity_description (692 to 713). (identify_visitor and its helpers, 161 to 522, read for context, out of this recipe's scope.)
  • alchemy_labs/backend/models/tracking_model.py (348 lines): TrackingSite (29 to 52); TrackingSession (55 to 114); TrackingEvent (214 to 260). (TrackingContact, 117 to 211, read only, not owned by this recipe.)
  • alchemy_labs/backend/api/routers/tracking_router.py (3576 lines, whole file in repo, only the cited ranges in this recipe's scope): TrackEventRequest (342 to 363); NO_TRACKING sentinel (110); get_user_site_ids (131 to 174); require_site_id_or_admin (177 to 207); _session_visible_to (210 to 217); router = APIRouter(prefix="/api/scribe", ...) (260); POST /event (538 to 674); GET /stats (1680 to 1707) plus _compute_stats_aggregate/_compute_stats_feed (1546 to 1677, read for the API-surface line only); GET /timeseries (2408 to 2447) plus _compute_timeseries (2282 to 2405, same); GET /sessions (2454 to 2591); GET /sessions/{session_id}/events (2594 to 2641).
  • alchemy_labs/backend/api/routers/tracking_sites_router.py (144 lines, whole file in scope): module docstring (1 to 8); list_sites (50 to 59); create_site (62 to 86); update_site (89 to 116); delete_site (119 to 143).
  • alchemy_labs/backend/app_fastapi.py: the two Scribe router mounts (956 to 957); the blanket Exception handler (795 to 814).
  • alchemy_labs/backend/api/middleware/unified_middleware.py: PUBLIC_PREFIXES including /api/scribe (~134); _check_rate_limit (the stricter public-surface ceiling and its fail-open behaviour).
  • alchemy_labs/backend/core/config.py: RATE_LIMIT_ENABLED (882); RATE_LIMIT_IP_PER_MINUTE (889); RATE_LIMIT_PUBLIC_IP_PER_MINUTE (893).
  • alchemy_labs/backend/static/scribe.js: the deployed beacon that is this recipe's real client - config.apiUrl default https://track.alchemylabs.ch (~135); sendToApi posts to config.apiUrl + '/api/scribe/' + endpoint (~1054), confirming this path carries live production traffic.
  • alchemy_labs/backend/migrations/versions/: 20260312_add_is_internal_tracking.py, 20260313_add_tracking_performance_indexes.py, 20260504_workspace_id_completeness_2a.py, 20260504b_workspace_id_tracking_2b.py, 20260504c_tracking_sites_registry.py, 20260504d_workspace_rls_policies.py, 20260610_0001_tracking_contacts_workspace_unique.py.
  • Tests as listed under Tests: alchemy_labs/backend/tests/test_tracking_scope.py (136 lines), alchemy_labs/backend/tests/integration/test_tracking_multi_tenant.py (402 lines), and alchemy_labs/backend/tests/conftest.py for the test_engine/db_session/client fixtures the second file depends on.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the docstring/code mismatch on UnknownTrackingSite's HTTP translation (Security & tenancy, Gotchas); session_id typed str instead of UUID on the events-by-session route; no test in either given file exercises the ingest write path (_resolve_workspace_for_site/get_or_create_session/track_event/_create_crm_activity) directly, only the GET-side tenancy scoping and the adjacent identify_visitor path.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc4 with the .venv there and the docker-compose.test.yml postgres-test service reachable on localhost:5434. Expected observations are exact, as measured 2026-09-12.

  1. source .venv/bin/activate && python -m pytest tests/test_tracking_scope.py -q --no-cov -o addopts="" -p no:cacheprovider prints 14 passed (no database needed, every db is a MagicMock).
  2. grep -c "^def test_" tests/test_tracking_scope.py prints 12; grep -c "@pytest.mark.parametrize" tests/test_tracking_scope.py prints 1 (the 3-case sites parametrize on test_non_admin_is_never_unrestricted, which is what takes 12 defs to 14 collected tests).
  3. PGPASSWORD=test_password psql -h localhost -p 5434 -U test_user -d test_db -c "CREATE DATABASE tracking_scratch;" then TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/tracking_scratch python -m pytest tests/integration/test_tracking_multi_tenant.py -q --no-cov -o addopts="" -p no:cacheprovider prints 7 passed; PGPASSWORD=test_password psql -h localhost -p 5434 -U test_user -d test_db -c "DROP DATABASE tracking_scratch;" cleans up. Running the same command with TEST_DATABASE_URL pointed at the shared test_db instead is expected to be flaky under concurrent load from other agents' test runs against the same containers (Gotchas), not a reliable check on its own.
  4. grep -n "tracking_router.router\|tracking_router.legacy_router" app_fastapi.py prints two lines (956, 957), both containing surface_exempt("public", confirming POST /event carries no auth dependency.
  5. grep -n "site_id == site_id" services/tracking_service.py (a paraphrase check) is not meaningful; instead sed -n '54,61p' services/tracking_service.py shows the exact query: TrackingSite.site_id == site_id filtered with TrackingSite.is_active.is_(True), confirming an inactive site is refused identically to an unregistered one.
  6. grep -n "PUBLIC_PREFIXES" -A 10 api/middleware/unified_middleware.py shows /api/scribe in the tuple, confirming the stricter per-IP ceiling applies to every route in this recipe.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.