Prima Get an API key

Module kit / Analytics and tracking

Tracking Settings and Site Registration

analytics-tracking/tracking-settings-and-site-registration

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/workspace-scoping, _shared/auth-dependency-pattern, _shared/alembic-migrations
Frontends analytics-tracking/ui/tracking-dashboard
Reference implementation
  • alchemy_labs/backend/services/tracking_settings_service.py
  • alchemy_labs/backend/api/routers/tracking_sites_router.py
  • alchemy_labs/backend/api/routers/tracking_router.py
  • alchemy_labs/backend/tests/test_tracking_scope.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A workspace registers which public site_id values may post tracking events to it, and holds, per workspace, a master enabled/test-mode switch, per-event-type per-platform send toggles, and two conversion-mapping tables that translate a form or a calendar meeting-type into the named event each ad platform expects. The invariant that makes the registry half trustworthy is enforced at the schema, not just the handler: site_id is the table's own primary key (models/tracking_model.py 39), so a site_id can belong to at most one workspace ever, which is what lets ingestion resolve an inbound X-Site-ID to exactly one workspace without a second lookup (tracking_sites_router.py module docstring, 1 to 8). The settings half has no equivalent hard guarantee: it is a handful of plain dict keys inside one JSON column shared with ad-platform credentials this recipe does not own (see Data model), and its only trustworthiness property, that a broken or missing row degrades to typed defaults rather than a 500, is a caught exception, not a schema constraint (get_workspace_tracking_settings, 75 to 90).

When to use

Read it before touching TrackingSite CRUD (list_sites/create_site/update_site/delete_site, tracking_sites_router.py), before touching get_tracking_config's enabled/test_mode/event-toggle computation or either conversion-mapping getter (tracking_settings_service.py), and when someone asks "why did site_id X get rejected", "is tracking on for this workspace", "why is test_mode logging instead of sending", "what does this form/this meeting-type fire as a conversion on Meta/LinkedIn/Google", or "why does /api/track/config 404". Trigger phrases: "tracking site registration", "site_id registry", "master enabled flag", "test_mode", "event toggle", "send_to_meta", "send_to_linkedin", "send_to_google", "form conversion mapping", "calendar conversion mapping", "conversion_id_lead", "conversion_id_high_intent".

Not this recipe: the per-platform credential fields inside the very same tracking dict (meta_pixel_id, meta_access_token, linkedin_access_token, linkedin_ad_account_id, google_ads_customer_id, google_ads_refresh_token, google_ads_client_id/_secret, and the rest of MetaTrackingConfig/ LinkedInTrackingConfig/GoogleAdsTrackingConfig's own fields), their Fernet encryption, and the OAuth connect/refresh flows that populate them, all owned by ads/ad-platform-connections as the typed reader of the same settings dict; the generic PATCH /api/workspaces/{workspace_id}/settings endpoint that is this repo's only write path for the keys this recipe documents (api/routers/workspace_router.py 606 to 641, services/workspace /tenant_service.py update_workspace_settings, outside this recipe's three assigned files, cited for grounding only, see Gotchas); the ingest pipeline that actually checks an inbound X-Site-ID against the registry (services/tracking_service.py, referenced in tracking_sites_router.py's own module docstring but not one of the three assigned files); the outbound CAPI senders that consume TrackingConfig/the conversion mappings to make the actual platform call (services/meta_capi_service.py, services/linkedin_capi_service.py, services/google_ads_service.py, all imported by tracking_router.py but not this recipe's subject); the read-scoping of tracking DATA (sessions, events, contacts) by site - require_site_id_or_admin, _session_visible_to, _contact_visible_to, _workspace_ids_for_site - a neighbouring concern in the same file (tracking_router.py) and this recipe's own assigned test file's actual subject, cited here because of that overlap but not walked as this recipe's own logic (see Tests).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for TrackingSiteResponse/TrackingSiteCreate/TrackingSiteUpdate, psycopg2-binary==2.9.10. Foundations assumed: S4 (fastapi-project-layout) for mount_router, which both routers in scope go through (app_fastapi.py 956, 960); S5 (auth-dependency-pattern) for ActiveUser (Annotated[User, Depends(get_current_active_user)]) on every tracking_sites_router.py route, and for the plain get_current_user dependency GET /config uses instead (see Security & tenancy); S6 (workspace-scoping) for WorkspaceID (Annotated[UUID, Depends(_get_workspace_id)]), which every route in scope takes to resolve and access-check the caller's workspace (api/dependencies.py 680 to 742); S8 (alembic-migrations) for the one migration under Deploy & ops.

Environment: none of this recipe's own settings keys (enabled, test_mode, events, form_conversion_mappings, calendar_conversion_mappings) has an environment-variable name anywhere in the three assigned files - unlike the adjacent credential fields in the same dict, which get_tracking_config does fall back to an env var for (owned by ads/ad-platform-connections, cited only for contrast). Fail behaviour: get_workspace_tracking_settings never raises, it catches every exception and logs a warning, returning None (75 to 90); every reader built on it degrades to a typed default rather than propagating a 500 - tracking_enabled defaults True (110 to 112), test_mode defaults False (172), both mapping getters return None (271 to 273, 321 to 323) when there is no workspace tracking config at all, or when the underlying decrypt or DB read failed. get_workspace_tracking_settings itself calls decrypt_tracking_tokens (services/security/oauth_token_crypto.py, 84 to 85) on every read, decrypting the WHOLE tracking dict even though none of the keys this recipe owns is ever Fernet-encrypted (see Security & tenancy) - that decrypt step and the key it depends on belong to ads/ad-platform-connections's Data model, cited here only because every read this recipe makes goes through it.

Data model

Two structurally different storage shapes for the two halves of this recipe's scope.

The registry, its own table, created by the migration under Deploy & ops (models/tracking_model.py 29 to 52):

tracking_sites (
  site_id         varchar PRIMARY KEY,                          -- the ingest-trusted key itself, not a surrogate id
  workspace_id    uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  name            varchar NOT NULL,
  allowed_origins jsonb DEFAULT '[]',                            -- CSP-style allowlist; empty = not enforced
  is_active       boolean NOT NULL DEFAULT true,
  created_at      timestamptz DEFAULT now(),
  updated_at      timestamptz DEFAULT now()
)
INDEX ix_tracking_sites_workspace (workspace_id)

Everything else lives inside workspace_settings.settings['tracking'], one JSON (not JSONB) column shared with ads/ad-platform-connections's OAuth tokens (table workspace_settings, services/workspace/models.py 82 to 96, cited in that recipe's own Data model, same column). The keys this recipe owns, reconstructed from every place tracking_settings_service.py reads them, no schema file defines the shape, and grounded by the default-settings seed written at workspace creation (services/workspace/tenant_service.py _get_default_settings, 405 to 462, outside this recipe's three assigned files, cited for grounding since no assigned file defines a default):

{
  "enabled": true,                 // master switch; AND-ed into every platform's own `enabled` in TrackingConfig
  "test_mode": false,              // true: CAPI calls are logged, never POSTed to a provider (tracking_settings_service.py 171-172)
  "events": {
    "form_submission": { "enabled": true, "send_to_meta": true, "send_to_linkedin": true, "send_to_google": true,
                          "meta_event_name": "Lead", "google_event_name": "generate_lead" },
    "page_view":        { "enabled": true, "send_to_meta": true, "send_to_linkedin": false, "send_to_google": true },
    "button_click":     { "enabled": false, "send_to_meta": false, "send_to_linkedin": false, "send_to_google": false }
  },
  "form_conversion_mappings": [
    { "form_id": "example-form-id", "form_name": "Example contact form",
      "meta_event": "Lead", "linkedin_event": "Lead", "google_event": "generate_lead", "enabled": true }
  ],
  "calendar_conversion_mappings": [
    { "slug": "strategy-call", "meeting_name": "Strategy Call",
      "meta_event": "Lead", "linkedin_event": "Lead", "google_event": "BOOK_APPOINTMENT", "enabled": true }
  ]
}

The two mapping arrays' shapes are fully determined by their own getters and, for the calendar one, by the function's own worked example in its docstring (tracking_settings_service.py 313 to 319). The default-settings seed above shows the three seeded events entries but seeds neither mapping array, so a freshly-created workspace starts with both empty.

What is NOT stored anywhere in the three assigned files: any per-platform token, pixel id, customer id, developer token or ad account id (meta_pixel_id, meta_access_token, linkedin_access_token, google_ads_refresh_token, etc.), real, adjacent keys in the same tracking dict, all owned and typed by ads/ad-platform-connections; a stable id for a row inside either conversion-mapping array, an entry is addressed only by its own form_id or slug, matched with a linear scan (get_form_conversion_mapping 280 to 289, get_calendar_conversion_mapping 329 to 338), first match wins, nothing rejects a stored duplicate; any FK from a mapping entry to an actual form template or calendar meeting-type row, form_id/slug are free strings, never validated against another table from within these three files.

API surface

RouteAuthTenancyBodyCodesNotes
GET /api/tracking-sites (tracking_sites_router.py 50 to 59)ActiveUserWorkspaceID-200 List[TrackingSiteResponse]WHERE workspace_id = :workspace_id ORDER BY created_at DESC, no pagination
POST /api/tracking-sites (62 to 86)ActiveUserWorkspaceIDTrackingSiteCreate {site_id (1-255 chars), name (1-255 chars), allowed_origins?, is_active?}201 TrackingSiteResponse; 409 if site_id already existsthe existence check (70 to 71) queries site_id alone, not scoped to the caller's workspace, see Gotchas
PATCH /api/tracking-sites/{site_id} (89 to 116)ActiveUserWorkspaceIDTrackingSiteUpdate {name?, allowed_origins?, is_active?}, all optional200 TrackingSiteResponse; 404 if no row matches site_id AND workspace_idpartial update, only fields that are not None are applied (108 to 113)
DELETE /api/tracking-sites/{site_id} (119 to 143)ActiveUserWorkspaceID-204; 404 if no row matches site_id AND workspace_iddeletes the registry row only; existing tracking_sessions/tracking_events keep their workspace_id (handler's own docstring, 126 to 129); future events for that site_id are simply no longer accepted at ingest, a check outside this recipe
GET /api/scribe/config (tracking_router.py 270 to 314)Depends(get_current_user) (not ActiveUser)WorkspaceID-200 {success, site_id, has_project, tracking_enabled, meta_pixel_id?, google_ads_id?, google_ads_label?, linkedin_partner_id?, bing_uet_tag_id?} (the last five present only if truthy, 313)see notes below

GET /config sits on tracking_router.router, mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956) - the surface gate itself imposes no credential requirement on this router - but the handler's own Depends(get_current_user) still 401s with no credential, making this the one authenticated route on an otherwise-public router; it does not get the is_active check ActiveUser would add. site_id in this response is NOT read from tracking_sites, it comes from projects.tracking_site_id, minted (f"site_{str(project.id)[:8]}") on first call if the user has a Project row and none is set yet (287 to 290), a second, older site-id mechanism that predates and does not consult the registry this recipe otherwise documents. The five extra pixel-id fields belong to ads/ad-platform-connections's Data model, surfaced here only for the client-side install snippet, wrapped in a bare try/except Exception that logs at debug and silently omits them on any failure (292 to 306). The legacy alias router (legacy_router, prefix /api/track) does NOT re-declare this route, see Gotchas.

Application logic

Site registration (tracking_sites_router.py, commit order):

  1. create_site (62 to 86): existence check queries TrackingSite by site_id alone (70), independent of workspace, raising 409 before any insert; else builds TrackingSite(site_id, workspace_id=<caller's, from the header>, name, allowed_origins or [], is_active), adds, commits, refreshes, returns 201. No advisory lock or retry around the check-then-insert; a concurrent duplicate POST would surface as an IntegrityError at commit rather than this handler's own 409, uncaught here.
  2. update_site (89 to 116): loads the row scoped to BOTH site_id and the caller's workspace_id (100 to 103), unlike create_site's existence check, so a caller cannot even learn whether a site_id it doesn't own exists (404, not 409, for someone else's site); applies only the payload fields that are not None; commits.
  3. delete_site (119 to 143): the same double-scoped lookup as update; deletes the row outright, no soft-delete path exists even though the model already has an is_active column that could serve one; does not touch tracking_sessions/tracking_events (handler's own docstring, 126 to 129).

get_tracking_config's enabled/test_mode/event-toggle computation (tracking_settings_service.py 93 to 180), in the order the function runs it:

  1. ws_tracking = get_workspace_tracking_settings(db, workspace_id) only if a workspace_id was passed at all (105 to 107); called with none, every downstream default applies as if no workspace config existed.
  2. tracking_enabled = ws_tracking.get('enabled', True) (110 to 112), the master switch, defaulting on.
  3. Each platform's own enabled is tracking_enabled AND _get_<platform>_enabled(ws_tracking) (116, 129, 143): the master switch is a hard AND-gate no per-platform function can override, but _get_meta_enabled / _get_linkedin_enabled / _get_google_ads_enabled (190 to 232) each return True from EITHER a stored credential (meta_pixel_id, linkedin_access_token, google_ads_customer_id/google_ads_id, owned by ads/ad-platform-connections) OR this recipe's own events.form_submission.send_to_<platform> toggle, which defaults True when absent (199, 214, 229). A platform with a toggle object but zero credentials therefore still reads enabled: true, purely off that default; see Gotchas.
  4. test_mode = bool(ws_tracking.get('test_mode')) if ws_tracking else False (171 to 172), the one field in this recipe's scope with no per-platform variant and no env-var fallback at all.

The two conversion-mapping getters (255 to 291, 305 to 340): each calls get_workspace_tracking_settings itself (271, 321) rather than accepting an already-loaded dict, so resolving both mappings for one request costs two separate DB reads and two separate whole-dict decrypts of data neither function needs decrypted; each then scans its own array (form_conversion_mappings / calendar_conversion_mappings) comparing mapping.get('form_id') == str(form_id) or mapping.get('slug') == slug, returning the first hit, None if the array is empty or absent.

Invariants

  • A site_id belongs to at most one workspace, ever: enforced by the column's own PRIMARY KEY (models/tracking_model.py 39, migration tracking_sites_001), with create_site adding a friendlier 409 in front of it on the happy path (70 to 75). This recipe's one assigned test file exercises neither the 409 path nor a concurrent-insert race; the guarantee is a schema-level fact here, not a tested one.
  • A caller can only read or modify a tracking_sites row it owns: update_site/delete_site scope their lookup on workspace_id as well as site_id (100 to 103, 133 to 136). The one asymmetry is create_site's existence check, which reveals, as a 409 with no row contents, whether a site_id is taken anywhere on the estate, before workspace ownership is even considered (see Gotchas).
  • get_workspace_tracking_settings never raises regardless of what is wrong with the row, the JSON, or the decrypt step beneath it (75 to 90); every function in this recipe built on it (get_tracking_config, both mapping getters) degrades to its own typed default rather than a 500 when workspace tracking config is missing or malformed.
  • The property this recipe's own assigned test file actually proves ("only an admin is ever unrestricted" over which site_ids a request may read, test_tracking_scope.py's own module docstring) belongs to require_site_id_or_admin, a read-scoping function for tracking DATA in the same file, not to the site REGISTRY or the SETTINGS this recipe documents. It is cited here because it is this recipe's one assigned test source, not because it tests registration or the config toggles; see Tests.

Frontend

The module that would render this, by id:

analytics-tracking/ui/tracking-dashboard   the workspace-level surface a person uses to see which site_ids are
                                            registered (name, allowed_origins, active/inactive), register a new
                                            one, and set the master enabled/test_mode switches, the per-event
                                            platform toggles, and the two conversion-mapping tables; at 62ba4fc
                                            nothing in this recipe's three assigned files renders any of this, and
                                            the write path for everything except the site registry itself is the
                                            generic PATCH /api/workspaces/{id}/settings (outside this recipe's
                                            scope, see Gotchas), so a real dashboard calls this recipe's CRUD and
                                            read routes plus that generic settings endpoint, not this recipe alone

Minimum admin/debug surface: none of the three assigned files exposes a dedicated admin/debug route or a scheduled log line (contrast the auto-topup and ads recipes, both of which have one or the other). The closest a human has: SELECT site_id, workspace_id, name, is_active FROM tracking_sites ORDER BY created_at DESC (unscoped version of what list_sites runs, useful for spotting a global site_id collision before a POST would hit the 409); reading the settings half at the Python layer only, e.g. (get_workspace_tracking_settings(db, workspace_id) or {}).get('events'), for the same reason given in the ad-platform-connections recipe: workspace_settings.settings is JSON, not JSONB, so no ->>/? operator is available for a plain SQL check.

Security & tenancy

Auth dependency: tracking_sites_router.router is mounted under LABS_SURFACES (app_fastapi.py 960), and all four of its routes take ActiveUser (_shared/auth-dependency-pattern), 401 on no or invalid credential, 403 on is_active=False (api/dependencies.py 344 to 348). tracking_router.router, carrying GET /config, is mounted surface_exempt("public", "Scribe beacon - no auth by design") (app_fastapi.py 956); the surface gate itself imposes nothing, but /config's own Depends(get_current_user) still 401s on no credential, without the is_active check ActiveUser would add (grounded by comparing the two routers' dependency signatures, both read directly).

Tenancy: WorkspaceID (_shared/workspace-scoping) resolves and access-checks the workspace on every route in scope in priority order - pre-validated request.state.workspace_id, a re-validated X-Workspace-ID header, or single-workspace inference (api/dependencies.py 680 to 742) - 400 on an invalid header, 403 if the resolved user lacks access to the requested workspace, 400/409 if no workspace can be inferred at all. create_site's existence check is the one place in scope where a caller learns something about a site_id before its own workspace access to that site is even considered; see Application logic and Gotchas.

Fail direction: get_workspace_tracking_settings fails toward the safest READ outcome (None, on any error, 88 to 90), but its CONSUMERS do not all fail toward the safer state: get_tracking_config reads tracking_enabled = True when there is no workspace config at all (110 to 112), so a workspace with a missing or broken settings row is, by default, MORE permissive about tracking than one that explicitly configured it, not less; test_mode defaults False under the same condition (172), so a broken settings row also fails toward "actually send to providers" rather than the safer "log only" state. Both are dated facts about this commit, not a claim about what should happen.

Secrets and PII at rest: none in the registry table (tracking_sites has no secrets column). The settings keys this recipe owns (enabled, test_mode, events, both mapping arrays) are never Fernet-encrypted, SCALAR_TOKEN_FIELDS (cited in the ad-platform-connections recipe) lists only credential fields, none of this recipe's own keys, but every read through get_workspace_tracking_settings decrypts the whole tracking dict regardless (84 to 85), so this recipe's own functions pay the decrypt cost for data they neither own nor ever see as ciphertext. A mapping entry's form_name/meeting_name is operator-chosen free text, unvalidated and untruncated by any of the three assigned files. Rate limits: none dedicated to any of the five routes/entry points in scope.

Attacker-controlled inputs: site_id on POST /api/tracking-sites (TrackingSiteCreate.site_id, 1 to 255 chars, no charset or slug-format restriction) is exactly the string ingest later trusts as X-Site-ID, per the router's own module docstring (1 to 8); allowed_origins is an unvalidated List[str] stored as-is into jsonb, its enforcement as a CSP-style allowlist happens at ingest, outside this recipe; name on create and update is 1 to 255 chars, no other constraint.

Tests

Run 2026-09-12 from alchemy_labs/backend at 62ba4fc, using the venv at alchemy_labs/backend/.venv, no database and no network:

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

prints 14 passed (0 failed; 162 warnings, all unrelated Pydantic-deprecation/regex noise from importing tracking_router.py's other schemas). grep -c "^def test_" tests/test_tracking_scope.py prints 12; grep -c "@pytest.mark.parametrize" tests/test_tracking_scope.py prints 1 (test_non_admin_is_never_unrestricted, 3 cases, the parametrize that takes 12 defs to 14 passed).

Hermetic vs live: fully hermetic, no database, every db argument is a unittest.mock.MagicMock whose .execute().all() is preset to a list of (site_id,) tuples (_db_with_sites, 44 to 47); every user is a MagicMock(is_admin=...) (_user, 50 to 53). Known-answer vectors, all against require_site_id_or_admin, _session_visible_to, and the two cache-key helpers: an admin with no requested site gets None (unrestricted, 58 to 59); an admin requesting one site gets exactly [that site] (62 to 65); a non-admin is NEVER unrestricted across zero, one, or three reachable sites, parametrized (67 to 72); a non-admin sees every site across their workspaces, proven with four Dein Team domains as the fixture data (77 to 82); a non-admin may narrow to one of their own sites (85 to 90) but requesting someone else's site is ignored, not honoured, their own set is returned unchanged (93 to 99); a non-admin with no reachable sites gets [NO_TRACKING], the sentinel that matches no row (102 to 104); the cache-key helper is order-independent (_k_sites(['b','a']) == _k_sites(['a','b']), 123 to 126) and distinguishes an admin's 'all' from any real scope (129 to 132).

What this recipe's own subject matter has NO test for, anywhere in the repo, not only outside this one file: grep -rl "FormConversionMapping\|CalendarConversionMapping" tests/ (run from alchemy_labs/backend) returns no file, confirming neither dataclass, nor the matching logic inside get_form_conversion_mapping/ get_calendar_conversion_mapping, is exercised anywhere; both getters DO appear elsewhere in the test tree (tests/v2/coverage/test_booking_g6_...py) but only as patch.object(bc, "get_calendar_conversion_mapping", return_value=...), a monkeypatched return value standing in for this recipe's function, never a test of the function's own array-scanning behaviour. TrackingSite similarly appears in other test files (tests/integration/test_tracking_multi_tenant.py, tests/test_scribe_query.py) only as a fixture-construction helper for testing the read-scoping concern named in Application logic's last invariant, never as a test of tracking_sites_router.py's own CRUD handlers, their 409/404 behaviour, or the tenancy asymmetry in Gotchas. A rebuild of this recipe has no existing test suite to port for its own registration or settings-computation logic, only for the neighbouring scoping concern this recipe's one assigned test file actually covers.

Deploy & ops

Config keys: none. This recipe's own settings (enabled, test_mode, events, both mapping arrays) have no environment-variable name anywhere in the three assigned files, unlike the credential fields elsewhere in the same tracking dict. Migrations: one in scope, tracking_sites_001 (migrations/versions/20260504c_tracking_sites_registry.py, down-revision workspace_id_2b_001): backfills projects.tracking_site_id if the column is missing (add_column_if_missing, an idempotent-add helper, not a bare Alembic op, 53 to 54, because the column was historically applied by hand outside Alembic's own tracking); creates tracking_sites and its ix_tracking_sites_workspace index (56 to 71); seeds rows from three sources in order - every active workspace self-mapped (site_id = workspace.id::text), every project's existing tracking_site_id resolved through its owner's single workspace, and a hardcoded operator-supplied domain-to-workspace map (real client domains and workspace ids in the migration's own source, elided here per house rules) - all three inserts are ON CONFLICT (site_id) DO NOTHING (76 to 126), so the migration is safe to re-run. downgrade() drops the index and the table (129 to 131), it does not undo the projects column backfill. No migration in scope touches the settings-dict keys this recipe documents; they live inside workspace_settings.settings, a column that already existed before this recipe's scope, seeded only at workspace-creation time (TenantService._get_default_settings, outside this recipe's three assigned files, cited for grounding).

Scheduler: none. Argus-style checks: none exist in the three assigned files; the natural one, given the grounding cited throughout (GET /api/scribe/config with a valid session and X-Workspace-ID returns 200 with success: true), is a recommendation here, not a measured fact. Rollback: tracking_sites_001's downgrade() works as described above; a tracking_sites row is an ordinary, deletable row with no other table's FK depending on it (tracking_sessions/tracking_events do not reference it, per delete_site's own docstring), so a rollback of the code carries no data-loss risk visible from these three files.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): legacy_router (tracking_router.py 263, mounted at app_fastapi.py 957 under the /api/track prefix with the tag scribe-legacy) carries zero routes. grep -n "legacy_router\." api/routers/tracking_router.py (the only place it could be decorated) returns no match anywhere in the file, and nothing elsewhere in the repo imports it either. GET /api/track/config does not exist despite the "Legacy" naming implying a full alias of the /api/scribe router; only GET /api/scribe/config is real.
  • 2026-09-12 (measured at 62ba4fc): POST /api/tracking-sites's existence check (70 to 71) queries site_id alone, with no workspace_id filter, so any authenticated caller learns, via a 409 with no row contents, whether a given site_id is already registered anywhere on the estate, before any workspace-ownership check runs. Not a data leak on its own, but a cross-tenant existence oracle on a value operators may pick predictably (a domain name, a short slug) - the one asymmetry with update_site/delete_site, which scope their lookup to the caller's own workspace from the start.
  • 2026-09-12 (measured at 62ba4fc): PATCH /api/workspaces/{workspace_id}/settings (api/routers/workspace_router.py 606 to 641, outside this recipe's three assigned files, cited for grounding) is the only write path this repo has for enabled, test_mode, events, and both conversion-mapping arrays, and TenantService.update_workspace_settings (services/workspace/tenant_service.py 329 to 355) REPLACES workspace_settings.settings outright (workspace_settings.settings = settings, line 350) rather than merging it. A client that PATCHes only {"settings": {"tracking": {"enabled": false}}} therefore overwrites every other top-level settings key AND every other tracking sub-key in the same request, including every ad-platform credential ads/ad-platform-connections owns, not just the field this recipe's caller meant to change. Neither of this recipe's three assigned files performs or guards against this write; it is named here because it is the single biggest operational risk to the data this recipe documents, and nothing in scope owns a fix for it.
  • 2026-09-12 (measured at 62ba4fc): get_form_conversion_mapping and get_calendar_conversion_mapping each call get_workspace_tracking_settings themselves (271, 321) rather than accepting an already-loaded dict; resolving both mappings for one event (a booking page that is also a form, say) costs two separate DB reads and two separate whole-tracking-dict decrypts, for data neither function needs decrypted at all.
  • 2026-09-12 (measured at 62ba4fc): _get_meta_enabled/_get_linkedin_enabled/_get_google_ads_enabled (190 to 232) default every send_to_<platform> event toggle to True when the key is simply absent, so a workspace with a stored events object but NO platform credentials configured at all still reads that platform's enabled as True from get_tracking_config, purely off the toggle default. Whether this is intended (a workspace using test_mode logging before ever connecting a platform) or an oversight in the default is not determinable from this recipe's three assigned files alone.

Reference implementation

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

Primary (the three assigned source files plus the one assigned test file):

  • alchemy_labs/backend/services/tracking_settings_service.py (340 lines, whole file in scope): module docstring (1 to 6); the MetaTrackingConfig/LinkedInTrackingConfig/GoogleAdsTrackingConfig/TrackingConfig dataclasses (20 to 61, fields outside this recipe's own enabled/test_mode cited only for shape, owned by ads/ad-platform-connections); get_workspace_tracking_settings (64 to 90); get_tracking_config (93 to 180); _get_setting/_get_meta_enabled/_get_linkedin_enabled/_get_google_ads_enabled/_get_event_setting (183 to 241); FormConversionMapping/get_form_conversion_mapping (244 to 291); CalendarConversionMapping/ get_calendar_conversion_mapping (294 to 340).
  • alchemy_labs/backend/api/routers/tracking_sites_router.py (143 lines, whole file in scope): module docstring (1 to 8); the three Pydantic schemas (24 to 47); list_sites (50 to 59); create_site (62 to 86); update_site (89 to 116); delete_site (119 to 143).
  • alchemy_labs/backend/api/routers/tracking_router.py: GET /config (270 to 314) is this recipe's own subject; NO_TRACKING/get_user_site_ids/require_site_id_or_admin/_session_visible_to/_k_sites (78 to 217) are the neighbouring read-scoping concern this recipe's own assigned test file exercises, cited for that reason, not walked as this recipe's Application logic (see When to use, Tests); legacy_router's declaration (263) and its lack of any registered route (see Gotchas).
  • alchemy_labs/backend/tests/test_tracking_scope.py (136 lines, whole file in scope): module docstring (1 to 27, the production incident that motivated the file); every test as listed under Tests.

Supporting (not among the assigned files; read to ground Data model, Deploy & ops and Gotchas, cited explicitly wherever used above rather than folded into the primary claims):

  • alchemy_labs/backend/models/tracking_model.py 29 to 52 (TrackingSite table definition).
  • alchemy_labs/backend/services/workspace/models.py 82 to 96 (WorkspaceSettings table definition, same column the ad-platform-connections recipe's Data model documents).
  • alchemy_labs/backend/services/workspace/tenant_service.py 329 to 355 (update_workspace_settings, the settings-replace behaviour in Gotchas), 405 to 462 (_get_default_settings, the seeded tracking shape in Data model).
  • alchemy_labs/backend/api/routers/workspace_router.py 606 to 641 (PATCH /{workspace_id}/settings, the only write path for this recipe's settings keys).
  • alchemy_labs/backend/services/security/oauth_token_crypto.py (decrypt path every read in this recipe goes through; owned and detailed by ads/ad-platform-connections).
  • alchemy_labs/backend/migrations/versions/20260504c_tracking_sites_registry.py (the one migration in scope).
  • alchemy_labs/backend/app_fastapi.py 956, 957, 960 (the three router mounts grounding maturity: prod and the surface-exemption claims in Security & tenancy).
  • alchemy_labs/backend/api/dependencies.py 187 to 230 (get_current_user), 338 to 349 (get_current_active_user), 680 to 742 (_get_workspace_id).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: POST /api/tracking-sites's existence check is a cross-tenant oracle, not workspace-scoped; the settings half of this recipe has no dedicated write endpoint of its own, only a generic whole-object-replace endpoint outside this recipe's scope; the two conversion-mapping getters each re-fetch and re-decrypt the whole settings dict independently; the legacy_router mount promises an /api/track alias that does not exist for this endpoint; per-platform "enabled" can read true off an event-toggle default with zero credentials configured; no test in the repo exercises the registry CRUD's own status-code behaviour or either mapping getter's own matching logic.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc with the repo's own .venv. Expected observations are exact, as measured 2026-09-12.

  1. .venv/bin/python -m pytest tests/test_tracking_scope.py -q --no-cov -o addopts="" -p no:cacheprovider prints 14 passed, 0 failed.
  2. grep -c "^def test_" tests/test_tracking_scope.py prints 12; grep -c "@pytest.mark.parametrize" tests/test_tracking_scope.py prints 1 (test_non_admin_is_never_unrestricted, 3 cases, taking the 12 defs to 14 passed).
  3. grep -n "site_id = Column(String, primary_key=True)" models/tracking_model.py shows line 39, confirming site_id (not a surrogate id) is the table's own primary key, the invariant Purpose and Application logic name.
  4. grep -n "workspace_settings.settings = settings" services/workspace/tenant_service.py shows line 350, confirming the one write path for this recipe's settings-dict keys replaces rather than merges (Gotchas).
  5. grep -n "legacy_router\." api/routers/tracking_router.py returns no match (exit code 1), confirming the legacy /api/track alias carries zero routes.
  6. grep -rl "FormConversionMapping\|CalendarConversionMapping" tests/ (from alchemy_labs/backend) returns no match (exit code 1), confirming neither dataclass is asserted against anywhere in the test tree.
  7. git log -1 --format=%H (run from alchemy_labs) prints 62ba4fc41009b454e1c033e5036c7e8577db2061, and git branch --show-current prints main, confirming measured_on and maturity: prod.

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.