Skip to content

Part 7 of 7 · Lead source attributor series ~7 min read

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

The lead source attributor drawn with AWS service namesThree boxes across the top outside the AWS account. Site pages, sending a beacon on arrival. Enquiry forms and the CRM. And The report, produced on request. Inside the account, three groups. A Function URL for touch intake and an API for report requests. Three Lambda functions named capture, stitch and report. And two DynamoDB tables named touches and identities. A note gives the region as us-east-1, one account, states that touches expire at eighteen months, and that no third-party identity data is used, ever.AWS ACCOUNTSite pagesa beacon on arrivalEnquiry formsand the CRMThe reporton requestFunction URL + APItouch intake,report requestLambda x3capture, stitch, reportDynamoDB x2touches, identitiesingroundsoutus-east-1. One account. Touches expire at 18 months; no third-party identity data, ever.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
ls-captureFunction URLNormalises the source, writes one touch, sets the first-party id5s / 512 MB
ls-stitchDynamoDB stream on enquiriesMerges identities on an email match; rewrites the identity index60s / 1024 MB
ls-reportAPI, cached one dayWalks paths, applies both rules, computes the unattributed share120s / 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

RoleAllowedOn
ls-capture-roledynamodb:PutItemThe touches table only
ls-stitch-roledynamodb:Query, dynamodb:UpdateItemTouches and identities
ls-report-roledynamodb:QueryRead-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