attribution-campaign-context is a downstream Tigrbl package for portable marketing-attribution telemetry. It captures UTM parameters, click IDs, referrer data, landing URLs, visitor IDs, and session IDs without forcing every consuming app into one business schema.
The package is designed for apps that need:
- request-level attribution extraction
- append-only attribution-touch records
- polymorphic attribution association tables
- optional validation of downstream business subjects
- composable middleware and hook contracts for conversions and first/last-touch linking
uv add attribution-campaign-contextFor local development:
uv sync --all-groups
uv run pytestThis package exposes two distinct layers.
These are concrete symbols you can import today:
UTM_KEYSCLICK_ID_KEYSAttributionContextextract_attributionAttributionTouchAttributionSubjectLinkSubjectRefSubjectResolverSubjectValidationErrorPublicSurfacePUBLIC_SURFACESAttributionMiddlewareAttributionRuntimeStatetouch_from_contextsubject_refvalidate_subject_refsubject_linkattribution_pre_handlerattribution_post_handlerattribution_post_commit
These are the package's documented integration contracts, exposed through PUBLIC_SURFACES metadata:
- tables:
AttributionTouch,AttributionSubjectLink - planned tables:
AttributionVisitor,AttributionSession - hooks:
attribution_pre_handler,attribution_post_handler,attribution_post_commit - middleware:
AttributionMiddleware - helpers:
extract_attribution,SubjectResolver,touch_from_context,subject_ref,subject_link
The middleware and hook names are implemented as framework-light helpers so a consuming Tigrbl app can wire them into its own persistence and request lifecycle.
Allowlisted UTM keys:
utm_sourceutm_mediumutm_campaignutm_termutm_contentutm_id
Allowlisted click-ID keys:
gclidgbraidwbraidfbclidmsclkidttclidli_fat_id
extract_attribution(...) returns an AttributionContext with:
utm: dict[str, str]click_ids: dict[str, str]raw_params: dict[str, str]referer: str | Nonelanding_path: str | Nonelanding_url: str | Nonevisitor_id: str | Nonesession_id: str | Nonehas_signal: bool
AttributionTouch is the canonical append-only touch ledger model.
Primary fields:
idvisitor_idsession_idutm_sourceutm_mediumutm_campaignutm_termutm_contentutm_idclick_idslanding_pathlanding_urlrefereruser_agent_haship_hashraw_paramscreated_atexpires_at
AttributionSubjectLink is the generic attribution association table model.
Primary fields:
idtouch_idsubject_resourcesubject_idrelationsubject_tablesubject_pk_typesubject_tenant_idsnapshotcreated_at
Supported relation values:
first_touchlast_touchconversionassist
SubjectRef is a normalized description of a downstream business subject:
resourcesubject_idtablepk_typetenant_id
SubjectResolver is the application-provided validation protocol:
canonical_resource(model_or_resource) -> strcanonical_id(obj_or_payload) -> strexists(resource, subject_id, db) -> bool
touch_from_contextconverts extracted request context into anAttributionTouch.subject_refcanonicalizes loose or resolver-backed subject references.validate_subject_refasks an application resolver whether a subject exists.subject_linkcreatesAttributionSubjectLinkassociation rows.attribution_pre_handlersnapshots request attribution before business handling.attribution_post_handlerresolves downstream subject identifiers after business handling.attribution_post_commitcreates conversion, first-touch, last-touch, or assist links after commit.AttributionMiddlewareis a pure ASGI middleware for extraction, visitor/session cookie minting, request-state storage, and optional touch recording.
PUBLIC_SURFACES is the package's machine-readable inventory of public operator surfaces. It is useful for integration docs, app bootstrapping, SSOT alignment, and tooling that needs to know what the package claims as first-class or planned.
| Key | Meaning | Typical Values | Notes |
|---|---|---|---|
utm_source |
The source that sent the visitor. | google, newsletter, linkedin |
Lowercased during extraction. |
utm_medium |
The acquisition medium or channel. | cpc, email, social, referral |
Lowercased during extraction. |
utm_campaign |
The campaign name or grouping. | spring_launch, retargeting_q2 |
Preserved as provided after trim. |
utm_term |
Paid-search keyword or audience term. | crm, founder tools |
Preserved as opaque business text. |
utm_content |
Creative, link, placement, or variant marker. | hero_cta, sidebar_a, video_15s |
Useful for A/B attribution. |
utm_id |
Stable campaign identifier. | cmp_2026_04_001 |
Useful when campaign names change. |
| Key | Meaning | Typical Source | Notes |
|---|---|---|---|
gclid |
Google Click ID. | Google Ads | Preserved as an opaque identifier. |
gbraid |
Google privacy-preserving click identifier. | Google Ads | Preserved as an opaque identifier. |
wbraid |
Google web-to-app / privacy-preserving click identifier. | Google Ads | Preserved as an opaque identifier. |
fbclid |
Meta click identifier. | Meta | Preserved as an opaque identifier. |
msclkid |
Microsoft Advertising click identifier. | Microsoft Advertising | Preserved as an opaque identifier. |
ttclid |
TikTok click identifier. | TikTok Ads | Preserved as an opaque identifier. |
li_fat_id |
LinkedIn ad tracking identifier. | LinkedIn Ads | Preserved as an opaque identifier. |
extract_attribution(
request,
*,
max_value_length: int = 256,
extra_keys: set[str] | frozenset[str] = frozenset(),
visitor_cookie: str = "tigrbl_vid",
session_cookie: str = "sid",
) -> AttributionContext- request query parameters
- request headers
- request cookies
- request path
- request URL
- allowlists only known UTM keys, click IDs, and optional
extra_keys - trims values
- drops empty values
- truncates values to
max_value_length - lowercases
utm_sourceandutm_medium - returns normalized visitor and session cookie values
from attribution_campaign_context import extract_attribution
context = extract_attribution(request)
if context.has_signal:
print(context.utm)
print(context.click_ids)
print(context.referer)from attribution_campaign_context import extract_attribution
context = extract_attribution(
request,
extra_keys={"affiliate_id", "creative_id"},
visitor_cookie="visitor_id",
session_cookie="session_id",
)AttributionTouch is the package-owned record of what attribution signal was present on a request. Treat it as append-only telemetry rather than mutable business state.
Typical uses:
- preserve first observed marketing context
- preserve last observed marketing context
- support funnel, conversion, and assist analysis
- support downstream event uploads or warehouse joins
from attribution_campaign_context import AttributionTouch, extract_attribution
context = extract_attribution(request)
touch = AttributionTouch(
visitor_id=context.visitor_id,
session_id=context.session_id,
utm_source=context.utm.get("utm_source"),
utm_medium=context.utm.get("utm_medium"),
utm_campaign=context.utm.get("utm_campaign"),
utm_term=context.utm.get("utm_term"),
utm_content=context.utm.get("utm_content"),
utm_id=context.utm.get("utm_id"),
click_ids=context.click_ids,
landing_path=context.landing_path,
landing_url=context.landing_url,
referer=context.referer,
raw_params=context.raw_params,
)AttributionSubjectLink is the package's attribution association table. It links one package-owned touch record to one downstream business subject without forcing a hard foreign key to an arbitrary application table.
That is the key portability boundary in this package.
Use AttributionSubjectLink when:
- one package must work across many different consuming apps
- the converted subject might be a
lead,user,organization,opportunity,quote, ororder - multiple touch relationships may exist for the same subject
- you need first-touch, last-touch, conversion, and assist rows without mutating the original touch
If this package hard-coded a foreign key to one app table, it would stop being portable. The association table keeps the subject side polymorphic:
touch_id -> package-owned touch row
subject_resource -> app-defined logical resource name
subject_id -> app-defined opaque primary key
from attribution_campaign_context import AttributionSubjectLink
link = AttributionSubjectLink(
touch_id="touch_123",
subject_resource="lead",
subject_id="lead_456",
relation="conversion",
subject_table="crm_leads",
subject_pk_type="uuid",
subject_tenant_id="tenant_123",
snapshot={"status": "qualified"},
)- one lead with one
first_touchrow and onelast_touchrow - one order with one
conversionrow - one opportunity with multiple
assistrows - one user linked across multiple sessions to many touches
SubjectResolver lets an app decide how strictly it wants to validate downstream subject references.
Loose mode records subject_resource and subject_id without existence checks.
Use loose mode when:
- the business record is created asynchronously
- the subject may exist in another service
- the app wants maximal portability with minimal coupling
Example:
subject_resource = "lead"
subject_id = "lead_456"Validated mode uses a SubjectResolver to canonicalize resource names, normalize ids, and confirm that the subject exists before writing the association row.
Use validated mode when:
- the business object is local to the app
- you want to avoid orphaned attribution links
- multiple models alias the same logical resource
Example resolver:
from attribution_campaign_context import SubjectResolver
class AppSubjectResolver(SubjectResolver):
def canonical_resource(self, model_or_resource):
return str(model_or_resource).lower()
def canonical_id(self, obj_or_payload):
return str(getattr(obj_or_payload, "id", obj_or_payload))
async def exists(self, resource, subject_id, db):
return await db.subject_exists(resource, subject_id)Strict app mode keeps the package's portable association row, but the consuming app may additionally:
- validate through a
SubjectResolver - write app-local foreign keys
- enforce tenant isolation rules
- restrict which
subject_resourcevalues are allowed - attach app-owned denormalized fields for reporting
Use strict app mode when:
- the app has a stable local business schema
- the app wants stronger invariants than the package itself can enforce
- compliance or governance requires hard business constraints
In strict app mode, attribution-campaign-context remains the shared portable layer, and app-specific tables or joins add stronger local guarantees on top.
The package documents middleware and hook surfaces as first-class operator contracts.
AttributionMiddleware is the intended request-entry integration point.
Responsibilities:
- call
extract_attribution - read or mint visitor/session cookies
- decide whether the request has meaningful attribution signal
- persist an
AttributionTouch - attach touch/context state for later hooks
- emit
Set-Cookieonly when state changes
Middleware-level adoption is the first full-runtime integration tier.
Use this before business create/update logic when you want to snapshot current attribution state into request-local context or into a local payload before persistence.
Typical uses:
- enrich a create payload with current touch id
- cache the extracted attribution context for downstream logic
- set request-scoped first-touch or last-touch candidates
Use this after business handling when the downstream subject id is only known after creation.
Typical uses:
- database-generated primary keys
- handler-generated lead ids
- post-validation resource canonicalization
This is the default conversion-link hook. Write AttributionSubjectLink rows here, after the business transaction succeeds.
Typical uses:
- create a
conversionrow for a lead, order, or signup - update or insert
first_touchandlast_touchassociations - attach
assistrows when multi-touch attribution is desired
This hook exists so attribution linkage does not get written for business operations that later roll back.
The intended package flow is:
- request enters middleware
- middleware calls
extract_attribution - middleware persists an
AttributionTouch - middleware stores touch/context on request state
- business handler runs
attribution_pre_handlercan copy or snapshot current attribution state- business object is created or updated
attribution_post_handlerresolves the final downstream subject id- transaction commits
attribution_post_commitwritesAttributionSubjectLinkassociation rows
That gives the app a clean split between touch capture and business-subject association.
Use only the request helper.
from attribution_campaign_context import extract_attribution
context = extract_attribution(request)Use this when you only need attribution in handler logic or logging.
Capture extraction results into AttributionTouch.
from attribution_campaign_context import AttributionTouch, extract_attribution
context = extract_attribution(request)
touch = AttributionTouch(
visitor_id=context.visitor_id,
session_id=context.session_id,
click_ids=context.click_ids,
raw_params=context.raw_params,
)Use this when you want an append-only attribution ledger but are not yet linking touches to business entities.
Capture the touch, then link it to a downstream entity through AttributionSubjectLink.
from attribution_campaign_context import AttributionSubjectLink
link = AttributionSubjectLink(
touch_id="touch_123",
subject_resource="signup",
subject_id="signup_456",
relation="conversion",
)Use this when you want attribution attached to signups, leads, quotes, opportunities, or orders.
Use a SubjectResolver before writing association rows.
resource = resolver.canonical_resource("Lead")
subject_id = resolver.canonical_id(created_lead)
if await resolver.exists(resource, subject_id, db):
...Use this when you want to keep portability but block invalid downstream references.
Keep the portable package rows, and add app-local constraints on top.
Examples:
- local FK from an app-specific reporting table to the app's
leadtable - local whitelist for allowed
subject_resourcevalues - tenant-aware existence checks
- app-specific denormalized reporting columns
Use this when one app wants stronger invariants than the portable package should require globally.
AttributionSubjectLink(
touch_id=current_touch_id,
subject_resource="signup",
subject_id=signup.id,
relation="conversion",
)AttributionSubjectLink(
touch_id=first_touch_id,
subject_resource="lead",
subject_id=lead.id,
relation="first_touch",
)
AttributionSubjectLink(
touch_id=last_touch_id,
subject_resource="lead",
subject_id=lead.id,
relation="last_touch",
)AttributionSubjectLink(
touch_id=assist_touch_id,
subject_resource="opportunity",
subject_id=opportunity.id,
relation="assist",
)- attribution data must not drive auth, tenancy, billing, entitlements, or authorization
- the package must not require foreign keys to arbitrary downstream business tables
- the package must stay portable across multiple consuming apps