Module kit / Analytics and tracking
Visitor Identification and Session Merging
analytics-tracking/visitor-identification-and-session-merging
| Kind | backend |
|---|---|
| Domain | analytics-tracking |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping |
| Frontends | analytics-tracking/ui/tracking-dashboard |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
When a visitor on a tracked site gives an email (a form, a booking, a checkout), identify_visitor turns the anonymous session(s) that browser has accumulated into one named TrackingContact, folds in any other anonymous session that shares the same device fingerprint, links contacts that turn out to be the same physical person using a different email, and, if that email already has a CRM Lead, backfills up to 100 of the contact's historical events onto that Lead's activity timeline so the sales view is not missing everything that happened before the visitor typed their email (tracking_service.py 161 to 271, 456 to 522). The invariant that keeps this trustworthy in a multi-tenant estate is that every read and write in the chain is scoped to the session's own workspace_id, resolved server-side from the registered site_id, never from a client-supplied value or an unscoped email lookup, so the same email used on two different tenants' sites never merges into one contact and never reads one tenant's tracking history onto another tenant's Lead (get_or_create_session calling _resolve_workspace_for_site, 41 to 61, 84; _link_to_crm_lead's Lead query filtered on Lead.workspace_id == contact.workspace_id, 425 to 428; proven by test_identify_visitor_respects_workspace_id).
When to use
Read it before touching identify_visitor, _merge_sessions_by_fingerprint, _link_contacts_by_fingerprint, _update_contact_stats, _link_to_crm_lead, _backfill_events_to_crm, get_contact_journey, or _get_linked_contacts; and when someone asks "why did two people become one contact", "why didn't these sessions merge", "who is the primary contact for this email", or "why is this Lead's activity log missing everything before the form submit". Trigger phrases: "identify visitor", "fingerprint match", "session merge", "merge sessions", "link contact to lead", "backfill events to CRM", "primary contact" / "secondary contact", "TrackingContact".
Not this recipe: get_or_create_session and the /event route's own session lookup (event ingestion is a neighbouring recipe; this recipe only calls get_or_create_session as its first step, 191 to 200); the /event route's ad-platform forwarding (Meta/Google/LinkedIn CAPI, GA4 - track_event, tracking_router.py 538 onward, out of scope); the V2 "Contact spine" (the ContactTimeline model in tracking_model.py 263 to 320, and services/contacts_service.py's resolve_or_create_contact) - a separate, newer identity system that reads and writes a different table (contact_timeline, migration contact_spine_001) than the one this recipe reads and writes (tracking_contacts); identify_visitor never touches ContactTimeline at this commit (grep -n ContactTimeline services/tracking_service.py api/routers/tracking_router.py matches nothing); GET /timeseries, GET /stats, GET /contacts, GET /campaigns, GET /sessions and the rest of tracking_router.py's dashboard/reporting endpoints, which read tracking data but do no identification or merging.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), Postgres 15 in prod (docker-compose.prod.yml 9, postgres:15-alpine) / 14 in the test harness (docker-compose.test.yml 6, the postgres-test service on :5434), pydantic==2.12.0 for the /identify request body (EmailStr on IdentifyRequest.email, tracking_router.py 321 to 339). Foundations assumed: S4 (fastapi-project-layout) for the app and the router mount (mount_router(app, tracking_router.router, ..., surfaces=surface_exempt("public", "Scribe beacon - no auth by design")), app_fastapi.py 956); S5 (auth-dependency-pattern) for get_current_user, used on every route in this recipe except POST /identify itself; S6 (workspace-scoping) for the workspace_id resolution and scoping pattern every guarantee in this recipe depends on.
Env/config: none specific to this recipe. No env var gates identify_visitor, the fingerprint merge, the contact-linking, or the CRM backfill (contrast the /event route's ad-platform forwarding, which does read platform credentials). The one config surface this recipe touches indirectly is the site-to-workspace registry itself (the tracking_sites table, not an env var - see Data model). Fail behaviour: identify_visitor and its private helpers do not catch their own exceptions; the router's POST /identify wraps the whole call in one try/except, turning UnknownTrackingSite into 404 and anything else into 500 (tracking_router.py 497 to 502) - unlike a "never raises" seam, nothing here guarantees the caller always gets a clean answer, an unhandled IntegrityError past the two retried paths (session insert, contact insert) or a DB outage surfaces as a 500.
Data model
Relevant columns of tracking_sessions, tracking_contacts, tracking_events and session_merge_logs (models/tracking_model.py 55 to 260, 323 to 348; full column lists there, only what this recipe reads or writes is repeated here):
tracking_sites ( -- the trusted site -> workspace registry (29 to 52)
site_id varchar PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
is_active boolean NOT NULL DEFAULT true
);
tracking_sessions ( -- 55 to 114
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
anonymous_id varchar UNIQUE NOT NULL,
fingerprint varchar, -- hash of device/browser characteristics
fingerprint_components jsonb,
contact_id uuid REFERENCES tracking_contacts(id),
site_id varchar,
first_seen timestamptz DEFAULT now(),
last_seen timestamptz DEFAULT now()
);
INDEX ix_tracking_sessions_fingerprint_site (fingerprint, site_id) -- 112-114, what the merge query hits
tracking_contacts ( -- 117 to 211
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email varchar, -- unique PER WORKSPACE, not globally
primary_contact_id uuid REFERENCES tracking_contacts(id), -- self-referential identity link
status enum('anonymous','identified','contact','lead') DEFAULT 'anonymous',
lead_id uuid REFERENCES leads(id), -- unique PER WORKSPACE
total_sessions integer DEFAULT 1,
total_events integer DEFAULT 0,
total_page_views integer DEFAULT 0,
first_touch_attribution jsonb,
first_seen timestamptz DEFAULT now(),
last_seen timestamptz DEFAULT now(),
identified_at timestamptz
);
UNIQUE (workspace_id, email) -- uq_tracking_contacts_workspace_email
UNIQUE (workspace_id, lead_id) -- uq_tracking_contacts_workspace_lead_id
tracking_events ( -- 214 to 260, read by _backfill_events_to_crm and get_contact_journey
id, workspace_id, session_id, contact_id, event_type, event_data jsonb,
page_url, page_path, page_title, site_id, created_at
);
INDEX ix_tracking_events_contact_created (contact_id, created_at)
session_merge_logs ( -- 323 to 348, append-only audit trail written by both merge paths
id, contact_id, source_session_id, target_session_id,
merge_reason varchar, -- 'fingerprint_match' | 'manual'
merged_by varchar, -- 'system' | 'admin'
merged_at timestamptz DEFAULT now()
);
What is NOT stored, or not stored by this recipe's code: a fingerprint is a caller-supplied string (typically a client-side device hash) - this recipe never computes one, it only matches and stores what it is given; ContactTimeline (tracking_model.py 263 to 320, migration 20260610_0003_contact_spine_001.py) exists in this same file but is written only by the separate V2 Contact-spine service, never by any function in tracking_service.py - a contact created or merged by identify_visitor produces no contact_timeline row at this commit, which a rebuild that assumes the two systems are one would get wrong.
API surface
| Route | Auth | Tenancy | Body / params | Codes | Notes |
|---|---|---|---|---|---|
POST /api/scribe/identify (tracking_router.py 405 to 502; the router's own module docstring at the top of the file still says POST /api/track/identify, 1 to 8 - stale, see Gotchas) | none - Depends(get_session) only, mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956) | site_id from the X-Site-ID header (falling back to data.site_id or the literal string "unknown", 428); workspace_id resolved server-side from the tracking_sites registry, never trusted from the client (_resolve_workspace_for_site, tracking_service.py 41 to 61); an unregistered site_id raises UnknownTrackingSite | IdentifyRequest (321 to 339): email: EmailStr (required), anonymous_id, fingerprint, fingerprint_components, properties, attribution, engagement_level, site_id, is_internal, plus fbc/fbp/li_fat_id/ga_client_id/first_seen_url (all optional, threaded onto the User row for later ad-attribution, best-effort) | 200 {success, message, email, contact_id, status, sessions_merged, total_sessions, total_events, lead_linked}; 404 "Unknown site_id" on UnknownTrackingSite; 500 "Identification failed: {e}" on any other exception | The client-side caller is window.AlchemyScribe.identify() in backend/static/scribe.js (1622 to 1657), which gates the call on hasConsent('personalization') (1043 to 1047) - see Security & tenancy, this consent check has no server-side counterpart |
GET /api/scribe/contact/{email} (1710 to 1757) | get_current_user (api/dependencies.py 187, JWT / cookie / X-API-Key) | non-admin restricted via require_site_id_or_admin → _workspace_ids_for_site; a contact outside the caller's sites answers 404, never 403, "so a non-admin cannot distinguish exists-in-another-tenant from does-not-exist" (1741 to 1744) | query: site_id optional | 200 {success, journey}; 404 "Contact not found" (both when the scoped lookup misses and when get_contact_journey itself returns None) | Calls TrackingService.get_contact_journey(email, workspace_id=contact.workspace_id) - the workspace is taken from the already-authorized contact row, not re-derived from the caller, so the second lookup cannot widen scope |
DELETE /api/scribe/contact/{email} (1988 to 2073) | get_current_user | non-admin: workspace-scoped find, then a second has_access check against get_user_site_ids(...) or [NO_TRACKING] (NO_TRACKING = "__no_tracking_configured__", 110); the comment at 2024 to 2032 documents a fixed regression: an unresolvable caller used to fall through the old if user_site_id: guard and delete any contact in the estate, the sentinel now fails closed instead | path email | 200 {success, message, events_deleted, sessions_deleted, merge_logs_deleted}; 404 "Contact not found: {email}"; 403 "Cannot delete contact from another site"; 500 str(e) | Deletes, in order, inside one call: SessionMergeLog rows, TrackingEvent rows, TrackingSession rows (all by contact_id), then the TrackingContact row itself; only frontend caller in the repo is TrackingDashboard.tsx 421 (api.delete('/api/scribe/contact/' + encodeURIComponent(email))) |
GET /api/scribe/session/{anonymous_id} (1760 to 1824) | get_current_user | require_site_id_or_admin restricts TrackingSession.site_id.in_(effective_site_id) for non-admins | query: site_id optional | 200 {success, session{...}, events[...]}; 404 "Session not found" | Returns the session's attribution fields and its 20 most recent events; no frontend caller found in this repo at this commit (grep -rn "scribe/session/" frontend/src matches nothing) |
POST /api/scribe/merge-sessions (2197 to 2274) | get_current_user | same require_site_id_or_admin → _workspace_ids_for_site pattern as GET /contact/{email}; each anonymous_id is looked up scoped to the resolved contact's own workspace_id (TrackingSession.workspace_id == contact.workspace_id, 2237 to 2240), "else a caller could reassign another tenant's session ... into their own contact by passing a foreign anonymous_id" (2234 to 2236) | email and site_id are query params, anonymous_ids: list[str] is the JSON request body (confirmed from the route's own dependant.query_params / body_params at this commit - no IdentifyRequest-style envelope) | 200 {success, message, sessions_merged, total_sessions, total_events}; 404 "Contact not found" | No code caller anywhere in this repo (grep -rln "merge-sessions" backend matches only the route file itself; the only other hit in the whole repo is a changelog line in docs/v2/AUTOBUILD.md noting a workspace-scoping fix applied to this same lookup on 2026-06-10, not a caller) - a manual/admin tool with no UI built for it yet |
Application logic
identify_visitor (tracking_service.py 161 to 271), in call order:
- Normalizes the email (
.lower().strip(), 188). get_or_create_session(...)(191 to 200) - resolvesworkspace_idfromsite_idvia thetracking_sitesregistry and either updates the existing session'sfingerprint/last_seenor inserts a new one, retrying once onIntegrityError(a race onanonymous_id, 130 to 152).- Finds or creates the
TrackingContactscoped to(session.workspace_id, email)(204 to 241); a new contact takesstatus=identified,first_touch_attribution,engagement_level,identified_at=now(), and anyfirst_name/last_name/phone/companypresent inproperties; a concurrent insert of the same(workspace_id, email)is caught asIntegrityErrorand re-read rather than raised (231 to 241). - If the current session is not yet linked to a contact, links it (
session.contact_id = contact.id) and bulk-updates everyTrackingEventalready recorded under that session to carry the newcontact_id(244 to 250). - Only if a
fingerprintwas supplied:_merge_sessions_by_fingerprint(255 to 260), then_link_contacts_by_fingerprint(263). Without a fingerprint, steps 6 and 7 still run but no other session or contact is ever touched - the whole cross-session/cross-contact resolution is fingerprint-gated. _update_contact_stats(contact)(266) - recomputestotal_sessions,total_events,total_page_viewsfromCOUNTqueries and bumpslast_seen._link_to_crm_lead(contact, fingerprint)(269), which is a no-op oncecontact.lead_idis set (419 to 420).
_merge_sessions_by_fingerprint (337 to 388): finds every TrackingSession with the same fingerprint (and the same site_id, if given) that is NOT the current session and has contact_id IS NULL (350 to 354) - a session already linked to a different contact is never reassigned by this path. For each match: sets session.contact_id, bulk-updates its events to the new contact_id, and inserts one SessionMergeLog row (merge_reason='fingerprint_match', merged_by='system', target_session_id = the current session). Commits once at the end if anything merged; returns the count.
_link_contacts_by_fingerprint (273 to 335): skips entirely if contact.primary_contact_id is already set (287 to 288, a secondary is never re-linked). Otherwise finds other TrackingContact rows that own a session sharing the fingerprint (and site_id, if given), ordered by first_seen ascending (291 to 303). If none, returns. Picks a primary: the oldest of those other contacts that has no primary of its own (309 to 313); if every other contact already has a primary, falls back to other_contacts[0].primary_contact, or to other_contacts[0] itself if even that is null (316 to 320). Then compares ages: if the current contact is older than the candidate primary, the current contact becomes primary and every other contact still without a primary is pointed at it (322 to 330); otherwise the current contact is pointed at the existing primary (331 to 335). One commit per branch.
_link_to_crm_lead (417 to 454): looks up Lead filtered on both email == contact.email AND workspace_id == contact.workspace_id (425 to 428, the comment calls out the cross-tenant risk of dropping that second filter by name). On a match: sets contact.lead_id, contact.status = lead, backfills lead.fingerprint only if the Lead doesn't already have one, writes a tracking_data JSON summary onto the Lead (total_sessions, total_events, total_page_views, first_seen, first_touch_attribution, engagement_level, linked_at), commits, then calls _backfill_events_to_crm.
_backfill_events_to_crm (456 to 522): loads up to 100 of the contact's events, oldest first (464 to 466); loads every existing (activity_type, created_at) pair already on the Lead's ClientActivityLog in one query to avoid an N+1 duplicate check (472 to 482, the comment marks this as a fix for a former per-event SELECT); for each event, maps its event_type to a ClientActivityType via _map_event_to_activity_type (650 to 665, unmapped types like heartbeats/scroll are skipped), skips it if the (activity_type, created_at) pair is already present (checked against both the pre-loaded set and same-batch insertions, 492 to 495), and otherwise inserts a ClientActivityLog row with created_at preserved from the original event (not now(), 515) and meta_data carrying backfilled: true. One commit at the end if anything was inserted.
get_contact_journey (719 to 800) and _get_linked_contacts (802 to 846): read-only. get_contact_journey requires a caller-supplied workspace_id and raises ValueError if it is None - "refusing to run an unscoped (cross-tenant) email lookup" (735 to 739) - then returns the contact, its sessions, its 100 most recent events, and _get_linked_contacts' view of same-fingerprint contacts (walking primary_contact_id in either direction, 802 to 846).
Invariants
- A
TrackingContact's identity is unique per(workspace_id, email), never globally:uq_tracking_contacts_workspace_email(test_same_email_different_workspaces_creates_separate_contacts,test_duplicate_email_same_workspace_still_rejected). identify_visitornever merges two workspaces' contacts sharing an email, even called twice with the identical email and no session in common (test_identify_visitor_respects_workspace_id).- A repeat
identify_visitorcall with the same(workspace_id, email)reuses the existing contact rather than creating a duplicate (test_identify_visitor_same_site_reuses_contact). get_contact_journeyrefuses to run without an explicitworkspace_id(tracking_service.py735 to 739) - enforced by araise, not exercised by a dedicated test in the file this recipe was measured against.- Fingerprint-based session merging only claims sessions with
contact_id IS NULL; a session already linked to a different contact is never reassigned by a lateridentify_visitorcall (_merge_sessions_by_fingerprint350 to 354) - this is a code-level guarantee only;test_tracking_multi_tenant.py, the one test file in this recipe's scope, does not exercise it, a genuine coverage gap worth flagging rather than papering over. _link_to_crm_lead's Lead lookup is workspace-scoped (425 to 428) so the same email's Lead in a different workspace's CRM is never attached to this contact - also not directly exercised by the given test file (its Lead tests cover uniqueness constraints, not theidentify_visitor→ Lead auto-link path).- The CRM backfill never double-inserts the same
(activity_type, created_at)pair on a repeat link (472 to 495) - also not covered by the given test file.
Frontend
The module that would render this, by id:
analytics-tracking/ui/tracking-dashboard does not exist yet as a written recipe in this repo at this
commit (only its containing domain folder was created by this
recipe). The real Alchemy Labs surface it would document is
frontend/src/pages/TrackingDashboard.tsx (1517 lines) and
frontend/src/pages/AdminTracking.tsx (753 lines); of the five
routes/functions in this recipe's scope, only DELETE
/api/scribe/contact/{email} is actually called from that
dashboard (TrackingDashboard.tsx:421, deleteContact()) - GET
/contact/{email} (the journey view), GET /session/{anonymous_id}
and POST /merge-sessions have no frontend caller anywhere in
this repo at this commit. POST /identify's real caller is the
client-side tracker, window.AlchemyScribe.identify() in
backend/static/scribe.js (1622-1657), not the Labs frontend.
Minimum admin/debug surface (no dedicated route exists for these at this commit, they are queries a human runs by hand): SELECT id, email, primary_contact_id, status, lead_id, total_sessions, total_events FROM tracking_contacts WHERE workspace_id = :ws AND email = :email (the row identify_visitor would act on); SELECT id, source_session_id, target_session_id, merge_reason, merged_at FROM session_merge_logs WHERE contact_id = :id ORDER BY merged_at DESC (the audit trail both merge paths write); SELECT id, anonymous_id, fingerprint, contact_id, site_id FROM tracking_sessions WHERE fingerprint = :fp (what _merge_sessions_by_fingerprint/_link_contacts_by_fingerprint would match on for a given device).
Security & tenancy
Auth dependency: POST /identify has none - it is a public beacon endpoint, mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956); the other four routes require get_current_user (JWT bearer, httpOnly cookie, or X-API-Key, in that priority, api/dependencies.py 187 to 202). Workspace derivation: for /identify, workspace_id comes only from the registered tracking_sites.site_id row, never from a client-supplied field (_resolve_workspace_for_site, tracking_service.py 41 to 61); for the four authenticated routes, require_site_id_or_admin (tracking_router.py 177 to 207) resolves the caller's own site set through workspace membership (get_user_site_ids, 131 to 174, itself a fix for a prior bug where a user with no Project row - "every Dein Team account" - resolved to an empty dashboard despite 6,897 real sessions on file, per that function's own docstring).
Fail direction: closed. DELETE /contact/{email}'s non-admin path uses the NO_TRACKING sentinel ("__no_tracking_configured__", 110) so an unresolvable caller's site list is a value that matches no row rather than None/empty being read as "unrestricted" - the docstring at 2024 to 2032 names the exact regression this replaced (an unresolvable non-admin used to fall through and could delete any contact in the estate). GET /contact/{email} answers 404, never 403, for a contact outside the caller's scope, so a non-admin cannot distinguish "exists in another tenant" from "does not exist" (1741 to 1744).
Consent: POST /identify itself carries no consent field in IdentifyRequest (321 to 339) and the handler performs no consent check; the only consent gate in the whole path is client-side, in scribe.js's sendToApi() - if (endpoint === 'identify' && !hasConsent('personalization')) { ...; return; } (1043 to 1047) - a caller that talks to the API directly, bypassing scribe.js, is not stopped by anything server-side. This is a genuine gap worth being explicit about, not a documented-and-accepted trade-off the way the auto-topup engine's nonce-replay gap is.
Secrets and PII at rest: an email is stored in plain text on tracking_contacts.email (it is the contact's primary identifier, not credential material, but it is PII); ip_hash on tracking_sessions is a truncated SHA-256 of the raw IP (hashlib.sha256(ip_address.encode()).hexdigest()[:16], tracking_service.py 108), the raw IP itself is never persisted by this recipe. Rate limits: none observed in tracking_router.py or on the /identify//merge-sessions routes specifically; the public /identify beacon is reachable by anyone who can reach the API and knows (or brute-forces) a registered site_id. Attacker-controlled inputs, all on the public /identify route: email (Pydantic EmailStr-validated, format only); anonymous_id, fingerprint, fingerprint_components, properties, attribution (all free-form strings/dicts, none server-validated beyond type); site_id (looked up against the registry, cannot pick a workspace that is not registered and active).
Tests
Run 2026-09-12 against the docker-compose.test.yml postgres-test service already listening on :5434, from alchemy_labs/backend with the repo's own .venv:
TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5434/test_db" \
python -m pytest tests/integration/test_tracking_multi_tenant.py -q --no-cov -o addopts="" -p no:cacheprovider
printed 7 passed (273 warnings, all pre-existing PydanticDeprecatedSince20 / datetime.utcnow() deprecation noise unrelated to this recipe, 8.08s). grep -c "^def test_" tests/integration/test_tracking_multi_tenant.py prints 7; grep -c "@pytest.mark.critical" prints 6, grep -c "@pytest.mark.database" prints 6, grep -c "@pytest.mark.api" prints 1 (the one HTTP-layer test, the rest are model/service layer against the transactional db_session fixture).
Hermetic vs live: all seven tests run against the real test Postgres (no mocking of the database); the one HTTP test, test_calendly_webhook_workspace_scoped, monkeypatches api.routers.tracking_router.track_conversion_event to a stub so no ad-platform CAPI call happens (361 to 367) - it is not itself a test of identify_visitor, it exercises a Calendly-webhook contact-upsert path, included in this file's scope as multi-tenant evidence but outside this recipe's own Application logic section. Known-answer vectors: none of the sort the auto-topup recipe has (no classify-outcome table); this suite is entirely "does the workspace boundary hold" assertions.
What this recipe's own logic lacks a test for, stated plainly rather than invented: no test in test_tracking_multi_tenant.py (the only test file in this recipe's scope) exercises _merge_sessions_by_fingerprint, _link_contacts_by_fingerprint, _link_to_crm_lead's auto-link-on-identify path, or _backfill_events_to_crm at all - every one of those functions is measured here only by reading the source, not by a passing assertion. No Playwright/E2E spec exists for any of the five routes in this recipe's API surface.
Deploy & ops
Config keys per app: none specific to this recipe (see Stack assumptions). Migrations, in the order they add what this recipe depends on (backend/migrations/versions/): 20260504b_workspace_id_tracking_2b.py (workspace_id_2b_001) adds workspace_id to tracking_sessions/tracking_contacts/tracking_events; 20260504c_tracking_sites_registry.py (tracking_sites_001, down-revision workspace_id_2b_001) creates the tracking_sites registry _resolve_workspace_for_site depends on; 20260610_0001_tracking_contacts_workspace_unique.py (tracking_contacts_ws_unique_001) adds uq_tracking_contacts_workspace_email and uq_tracking_contacts_workspace_lead_id, the constraint the multi-tenant test file is entirely about. Scheduler: none - every step in this recipe runs synchronously inside the HTTP request that triggered it (/identify, /merge-sessions, or the Calendly webhook), there is no background job to register or monitor. Rollback: tracking_contacts/tracking_sessions/tracking_events rows are never deleted by identify_visitor itself (only the separate DELETE /contact/{email} route deletes anything), so rolling back the application code does not require unwinding data; rolling back past tracking_contacts_ws_unique_001 would need the composite unique constraints dropped first if any workspace had, by then, produced rows that only the constraint prevents from colliding.
Gotchas
- 2026-09-12 (measured at 62ba4fc,
tracking_router.py1 to 8 vs 260, 263): the module's own docstring documentsPOST /api/track/identify,POST /api/track/event,GET /api/track/stats,GET /api/track/contact/{email}, but the router that actually defines every route in this file isrouter = APIRouter(prefix="/api/scribe", ...)(260); a secondlegacy_router = APIRouter(prefix="/api/track", ...)exists and is mounted (app_fastapi.py957) but has zero routes registered on it (grep -n "legacy_router\.\(get\|post\|delete\)" tracking_router.pymatches nothing). The real, live paths for every route in this recipe are under/api/scribe/*; the docstring is stale documentation, not a second live surface. - 2026-09-12 (measured at 62ba4fc):
POST /api/scribe/merge-sessionshas no code caller anywhere in this repository (grep -rln "merge-sessions" backendmatches only the route's own file) and no frontend UI. It is fully reachable and covered by nothing but manual testing; a rebuild should treat it as unexercised in practice, similar in spirit to (though a smaller gap than) the auto-topup recipe's unbuilt-sender note. - 2026-09-12 (measured at 62ba4fc):
ContactTimelineis modeled in the same file as everything this recipe uses (tracking_model.py263 to 320) but is never written by any function this recipe covers; it belongs to a separate, newer "V2 Contact spine" system (services/contacts_service.py, migrationcontact_spine_001, git log entry14cd700 feat(v2): Contact spine (additive)). A rebuild that assumesidentify_visitorkeepsContactTimelineup to date will be wrong; the two systems currently run side by side, unconnected, in this codebase.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/tracking_service.py(890 lines): module docstring (1 to 9);UnknownTrackingSite(31 to 32);_resolve_workspace_for_site(41 to 61);get_or_create_session(67 to 155);identify_visitor(161 to 271);_link_contacts_by_fingerprint(273 to 335);_merge_sessions_by_fingerprint(337 to 388);_update_contact_stats(390 to 415);_link_to_crm_lead(417 to 454);_backfill_events_to_crm(456 to 522);track_event/_create_crm_activity/_map_event_to_activity_type/_format_activity_*(528 to 713, out of this recipe's scope, cited for context);get_contact_journey(719 to 800);_get_linked_contacts(802 to 846);get_tracking_stats(848 to 885).alchemy_labs/backend/models/tracking_model.py(347 lines):VisitorStatus(21 to 26);TrackingSite(29 to 52);TrackingSession(55 to 114);TrackingContact(117 to 211);TrackingEvent(214 to 260);ContactTimeline(263 to 320, out of scope, see Gotchas);SessionMergeLog(323 to 347).alchemy_labs/backend/api/routers/tracking_router.py(3576 lines, whole file in scope for its imports and helpers, only five routes/handlers in scope for this recipe): module docstring (1 to 15);NO_TRACKING(110);get_user_site_id/get_user_site_ids(113 to 174);require_site_id_or_admin(177 to 207);_session_visible_to(210 to 217, out of this recipe's scope, cited for completeness of the helper block);_contact_visible_to(220 to 236);_workspace_ids_for_site(239 to 257);router/legacy_router(260 to 263);IdentifyRequest(321 to 339);POST /identify→identify_user(405 to 502);GET /contact/{email}→get_contact_journey(1710 to 1757);GET /session/{anonymous_id}→get_session_info(1760 to 1824);DELETE /contact/{email}→delete_tracking_contact(1988 to 2073);POST /merge-sessions→merge_sessions_manually(2197 to 2274).alchemy_labs/backend/migrations/versions/20260504b_workspace_id_tracking_2b.py,20260504c_tracking_sites_registry.py,20260610_0001_tracking_contacts_workspace_unique.py.alchemy_labs/backend/static/scribe.js:window.AlchemyScribe.identify()(1622 to 1657);sendToApi()'s consent gate (1042 to 1054).alchemy_labs/frontend/src/pages/TrackingDashboard.tsx:deleteContact()(416 to 427), the only frontend call into any route in this recipe's scope.- Tests:
alchemy_labs/backend/tests/integration/test_tracking_multi_tenant.py(402 lines, whole file in scope).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: the router's module docstring documents dead /api/track/* paths instead of the live /api/scribe/* ones; POST /merge-sessions ships with no caller and no UI; ContactTimeline sits unconnected to the identify/merge path it would seem to belong to; POST /identify has no server-side consent check, relying entirely on the client-side gate in scribe.js; no rate limiting on the public /identify beacon.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc with the postgres-test service (docker-compose.test.yml) reachable on :5434. Expected observations are exact, as measured 2026-09-12.
TEST_DATABASE_URL="postgresql://test_user:test_password@localhost:5434/test_db" python -m pytest tests/integration/test_tracking_multi_tenant.py -q --no-cov -o addopts="" -p no:cacheproviderprints7 passed(warning count/timing not load-bearing, observed as273 warnings in 8.08sthis run).grep -c "^def test_" tests/integration/test_tracking_multi_tenant.pyprints7.grep -n "^router = \|^legacy_router = " api/routers/tracking_router.pyprints260:router = APIRouter(prefix="/api/scribe", tags=["scribe"])and263:legacy_router = APIRouter(prefix="/api/track", tags=["scribe-legacy"]);grep -c "legacy_router\.\(get\|post\|delete\)(" api/routers/tracking_router.pyprints0, confirming the legacy prefix carries no live routes.sed -n '41,61p' services/tracking_service.py | grep -c "raise UnknownTrackingSite"prints2(one raise for a missing/emptysite_id, 54, one for asite_idnot found in the registry, 59), confirming both cases raise rather than either silently defaulting a workspace.grep -n "TrackingSession.contact_id.is_(None)" services/tracking_service.pyprints one match inside_merge_sessions_by_fingerprint(line 353), confirming the merge only ever claims currently-unlinked sessions.grep -rln "merge-sessions" backendfrom the repo root prints exactly one file,backend/api/routers/tracking_router.py, confirming no code elsewhere in the repository callsPOST /merge-sessions(a changelog line indocs/v2/AUTOBUILD.mdalso mentions the endpoint by name, but that is history, not a caller).grep -n "ContactTimeline" services/tracking_service.py api/routers/tracking_router.pyprints nothing, confirming this recipe's code path never writes the V2 Contact-spine table.
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.