Engineering reference: the lead source attributor architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the normalisation table, and how the report is computed.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Networking
- Analytics
- Front-end & mobile
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
ls-capture | Function URL | Normalises the source, writes one touch, sets the first-party id | 5s / 512 MB |
ls-stitch | DynamoDB stream on enquiries | Merges identities on an email match; rewrites the identity index | 60s / 1024 MB |
ls-report | API, cached one day | Walks paths, applies both rules, computes the unattributed share | 120s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
ls-capture-role | dynamodb:PutItem | The touches table only |
ls-stitch-role | dynamodb:Query, dynamodb:UpdateItem | Touches and identities |
ls-report-role | dynamodb:Query | Read-only across both tables |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: touches
PK visitor_id S first-party, this device
SK at S 2026-08-11T09:14:02Z
source S normalised: google | facebook | podcast_x
medium S organic | cpc | referral | direct | asked
campaign S from utm_campaign, if given
referrer_host S verbatim, before normalisation
landing S /pricing
how_detected S utm | referrer | none
ttl N epoch, +18 months
`how_detected` is what lets a strict report exclude guessed sources.
`medium=asked` is the free-text ’how did you hear about us’ answer,
stored as a touch so it appears in the same path.
Table: identities
PK identity_key S email hash, or the visitor id itself
SK visitor_id S one row per linked device
linked_by S cookie | email | token
linked_at S 2026-08-11T09:20:00Z
enquiry_id S set on the row that enquired
Merges are additive and never delete a visitor id, so a report can be
recomputed at any strictness level from the same rows.
Inbound and outbound
- One beacon per arrival, not per page view. An arrival is a session start or a referrer change.
- Source normalisation is a static table mapping the many spellings of each channel to one key. It is the highest-value twenty lines in the system.
- The enquiry write triggers stitching through a DynamoDB stream, so attribution improves retroactively as identity information arrives.
- Reports are computed on request and cached for a day. Nothing is precomputed and no source is ever written back onto the enquiry record.
The model call
- There is no model in this system. Capture is a lookup, stitching is an exact match, and the rules are arithmetic.
- The tempting use is classifying free-text ’how did you hear about us’ answers into channels, and it is defensible — but keep the raw text, because the classification loses exactly the specifics that make those answers valuable.
- The wrong use is algorithmic attribution, for the reasons in Part 4: at these volumes it produces an unexplainable number over data with the same blind spots.
- A second wrong use is inferring a probable source for unattributed enquiries. That converts an honest unknown into a confident guess, which is the failure this system exists to prevent.
- The cost page assumes none, which is why writes are the only variable band.
Things worth knowing before you build it
- Normalise sources at capture. Three spellings of one channel is the most common reason an attribution report is quietly wrong.
- Never write an attributed source onto the enquiry record. Something will read it, and then the rule can no longer be changed.
- Make stitching additive. A merge that deletes visitor ids makes strict-mode reports impossible to recompute.
- Store the raw referrer host as well as the normalised source, so a normalisation mistake can be fixed retrospectively.
- Put the unattributed share in the report generator itself, not in the dashboard template. If it lives in the template, someone will remove it.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts