How Leed Tracks Visitors

Every number in Leed’s analytics starts as an event fired by a script on your own site. Those scripts are shipped by your site build, served from your own domain, and post back to your own domain. No third-party analytics vendor is involved, nothing is loaded from an ad network, and there is no tag manager to configure.

The honest counterpart to that: Leed ships no cookie-consent banner, does not check Do Not Track, and gives visitors no analytics opt-out. If you operate somewhere that requires one, you have to add it yourself. The rest of this page is what is actually collected, so you can make that call with the facts.

What gets loaded on every page

Your site build injects this into the <head> of every published page, in this order:

ScriptWhat it doesLoaded when
Alpine.js (CDN)Interactive behavior in templates and docs chromeAlways, deferred
js-cookie (CDN)Cookie helper the tracker depends onAlways
zoomable.jsClick-to-zoom for images and diagramsAlways
fp/fp.jsBuilds the visitor fingerprint used to resume an identityAlways
detectIncognito.jsPrivate-browsing detection, recorded on the sessionAlways
timeme.jsThe active/idle timing engine the timer tracker drivesAlways
tracker/whisper.jsThe tracker. Starts all six trackers belowAlways
tracker/webmcp.jsRegisters in-page tools for AI agentsOnly when MCP is enabled for the site
utilities.jsForms, recommendations and dynamic CTAsAlways
documentation.jsDocumentation chrome — menu, table of contents, tabsAlways
lunr-search.js or live-search.jsReader search. Mutually exclusive — which one ships depends on your planAlways, one of the two
Cloudflare Turnstile (CDN)Form spam challengeOnly when a Turnstile public key is configured

Each Leed-authored file carries a version number in its filename, which the site builder bumps when the file changes. That is a cache-busting device, not something you configure.

Two things about the ordering matter. The tracker is loaded after the timing and fingerprint libraries because it needs them. And the tracker does not begin recording the moment it parses — it first awaits a session. Nothing at all is captured before the browser has a session and visitor id, which is the first thing the diagram below shows.

The six trackers

The tracker constructs six independent trackers, each inside its own error guard, so one failing — a missing global, a DOM API gap in an old browser — cannot stop the other five.

TrackerWhat it recordsKey constantEvent table written
Page viewsEvery page load and in-page navigation—pageViewEvents
Active timeAlternating active and away segmentsIdle timeout 30 secondstimerEvents
ClicksLink and button clicks, classifiedNavigation delay 100 msclickEvents
Element visibilityWhich blocks scrolled into viewThresholds 0, 0.5, 1viewedElementEvents
VideoSecond-by-second playbackPulse 5 secondsvideoEvents
AudioSecond-by-second playbackPulse 5 secondsaudioEvents

Page views

One event per page load and per in-page navigation, carrying the referrer and the URL’s query parameters. UTM parameters are split out into their own columns; anything else is kept as a blob so an unusual campaign parameter is not lost.

The first time a given visitor sees a given page, a second row is also written to a per-visitor history table. That row exists once per visitor per page and is what lets Leed say when this reader first found this page, rather than only how many times it has been viewed.

Active time

Active time is not “time the tab was open”. The timing engine stops counting after 30 seconds with no interaction and emits an away segment; interaction resumes an active segment. Switching tabs stops the clock too. What you get is a stream of alternating segments rather than one duration.

A single active segment is capped at 10 minutes server-side. A reader who leaves a page open with the mouse twitching cannot contribute an hour to one page’s active time. Longer genuine reading arrives as several segments.

This is the measure behind “engaged minutes” on the Know donut, the Pages By Active Time ranking, and the Minimum Active Time filter in the editor’s panel.

Clicks

Click listeners are registered on both click and auxclick, in the capture phase. Two consequences worth knowing:

  • Middle-clicks count. Opening a link in a new tab with the middle button is an auxclick, and it is recorded.
  • A page’s own stopPropagation() cannot hide a click from Leed. Capture runs before bubbling, and the documentation menu component stops propagation on its own links. A bubble-phase listener would miss every menu click; this one does not.

Each click is classified into one of four types:

TypeWhen it applies
fileThe link’s extension is in the tracked file-type list
internalThe link’s host is the same as the page’s host
outboundThe link has a host, and it is a different one
otherSomething clickable that is not a link — a <button>, typically

The order matters: a .pdf on your own domain is a file click, not an internal one.

An other click with no resolvable element id is dropped. The tracker walks up from the clicked node looking for the nearest ancestor carrying a data-id; if a button has none anywhere above it, there is nothing to attribute the click to and no event is sent. Links are always recorded because their destination identifies them.

To keep outbound and file clicks honest, a plain same-tab left-click is intercepted, the event is queued, and the browser is sent to the link 100 ms later. Without that delay the page would unload before the beacon left. Three cases are deliberately left alone and navigate immediately: links carrying download, links with a target other than the current tab, and clicks with Ctrl, Cmd or Shift held.

The default tracked file extensions

pdf, xls, xlsx, csv, doc, docx, txt, rtf, exe, key, pps, ppt, pptx, 7z, pkg, rar, gz, zip, avi, mov, mp4, mpeg, wmv, midi, mp3, wav, wma

A link whose extension is not on this list and whose host is yours is an internal click, not a file download.

The list can be replaced or extended per site through file-types and add-file-types attributes on the tracker’s own <script> tag, which means editing the head template. Think carefully before you do: changing it changes what “file download” means in every report, retroactively for new data only, so your history will have a seam in it.

Element visibility (your scroll-depth measure)

Leed does not record a scroll percentage. It records which blocks of the page actually entered the viewport, which is a more useful thing and a more robust one — it survives a page getting longer.

Each tracked element is watched by three independent observers, at 0%, 50% and 100% visibility. Each fires once and then stops watching that element at that threshold, so a reader scrolling up and down does not inflate the count.

Three rules decide what gets watched:

  • The element’s tag must be one Leed tracks: p, ul, ol, blockquote, pre, img, iframe, audio, h1–h6, aside, details, figure, table, section, a tabs container, or a CTA container.
  • It must carry a data-id. No id, no observation.
  • It must not be inside a <header> or a <footer>. Site chrome is on every page and would drown the signal.

The scan runs once, on page load. Content inserted afterwards is invisible to it — which is why there is a global hook, window.leedObserveElement(el), that registers a single new element. That is how recommendation cards and dynamic CTAs, which arrive after a fetch, get observed at all. It deliberately registers one element rather than re-scanning, because a re-scan would re-fire viewed events for everything already reported.

Video and audio

Both media trackers pulse every 5 seconds while media is playing, and write one row per second consumed. Each row is typed end, pause or nav, which is what makes per-second retention, pause and replay curves possible later. Video and audio are separate tables even though they share a shape.

How events reach Leed

Events are not sent one at a time. They queue in the browser and go out in batches: whenever the queue reaches 100 events, and on a timer every 3 seconds regardless. Each batch is posted to POST /api/event on your own domain using navigator.sendBeacon, which is the browser API designed to survive the page being closed mid-flight.

sequenceDiagram
    autonumber
    participant B as Visitor's browser
    participant P as Published page
    participant L as POST /api/lid
    participant E as POST /api/event
    participant D as Event tables (D1)
    participant C as Session-closure cron
    participant S as sessions row

    B->>P: Requests a page
    P-->>B: HTML + tracker scripts
    B->>L: Session bootstrap (fingerprint, width, entry kind)
    L-->>B: Sets __sid (30 min) and __lid (1 year)
    Note over B: Only now do the six trackers start
    B->>B: Page view, clicks, timers, visibility, media queue up
    B->>E: sendBeacon batch (at 100 events, or every 3 s)
    E->>D: One row per event
    Note over B,D: Reader leaves. __sid stops being refreshed.
    Note over C: 30 minutes of silence
    C->>D: Read every event for the session
    C->>S: Write the rolled-up session record
    Note over S: Sessions, visitors, bounces and the funnel read THIS row

The ordering in that diagram is the whole point of it. Nothing is captured before the session exists, and no session aggregate exists until at least thirty minutes after the visitor stops browsing. A number that looks wrong five minutes after a test visit is usually not wrong — it has not been computed yet. This is the single most common source of “my analytics are broken” questions.

Identity: two cookies and a session

Both cookies are first-party, set by your own domain, marked secure, scoped to /, and not httpOnly — the tracker script has to read them.

CookieLifetimeFlagsWhat it identifies
__sid30 minutes, refreshed on every requestsecure, not httpOnlyThe current session. Thirty idle minutes ends it.
__lid1 yearsecure, not httpOnlyThe visitor — a UUID, stable across sessions and visits.
___gimme___30 minutessecure, not httpOnlyA flag the server sets when it needs the browser to send its fingerprint again. Not an identifier.

The two ids answer different questions, and this is exactly where Unique Sessions and Unique Visitors come from: Unique Sessions counts distinct __sid values, Unique Visitors counts distinct __lid values. A reader who comes back three times in a month is three sessions and one visitor.

An invalid or missing __lid forces re-identification. In a private window both cookies are new every time, so an incognito reader is a new visitor on every visit — the session record carries a flag saying so.

Those cookies are also what lets an anonymous reader later resolve to a named contact when they fill in a form: the path from cookie to person is described in lead profiles and visitor identity.

How a session knows where it started

When a session is minted, the entry path decides its starting event name:

Entry path containsStarting event
/clerkform_fill
/s/shortcode
/f/file_download
/e/email
anything elsepageview

This is a property of the whole session, not of one event, and it is what the Session Start filter in the editor’s Page Analytics panel selects on — “show me only the sessions that began from an email click”.

When a session closes

A session ends by expiring, not by the reader doing anything. Thirty minutes after the last request, a scheduled job picks it up, reads every event it produced, and writes one rolled-up sessions row. Sessions that produced no events at all are discarded as bot-style traffic and never written.

FieldHow it is computed
activeTime / awayTimeSum of active and away timer segments
activeCount / awayCountHow many of each segment there were
pageviewsPage views in the session, with 404 rows stripped out first
isBouncepageviews === 1 — exactly one page view, and nothing else
formFillsCount of distinct form ids submitted
entryPageId / exitPageIdFirst and last page viewed. For a session started by a short link, the entry comes from the link’s destination
videoPlayCount / audioPlayCountCount of distinct asset ids played
videoElapsedTime / audioElapsedTimeSeconds of media consumed
durationEnd time minus start time
private / privateBrowserWhether the incognito detector fired

The event tables

Each stream is its own table, so you can map any report back to a raw source.

TableWritten byKey fields
pageViewEventsPage-view trackerpage, content type, referrer, referrer host, UTM columns, other query params
userHistoryPage-view tracker, once per visitor per pagepage, visitor id, what first brought them there
clickEventsClick trackerclick type, element type and id, whether the id was on the clicked element, destination page / URL / asset, anchor, UTM columns
timerEventsTimer trackeractive or away, elapsed seconds, pause count
viewedElementEventsElement-view trackerelement id, percent (0, 0.5 or 1)
videoEventsVideo trackerasset id, second, end / pause / nav
audioEventsAudio trackerasset id, second, end / pause / nav
fileDownloadEventsThe file-serving route /f/…asset id, referrer
shortcodeEventsThe short-link route /s/…shortcode, destination, UTM columns
emailEventsThe email routes /e/i/, /e/u/, /e/f/email batch, recipient hash
formFillEventsThe form endpoint /clerkform id, and whether the spam challenge passed
mcpRequestEventsThe Docs MCP, the agent surface and reader searchsource, tool name, inputs, result count, denied flag
sessionsThe session-closure jobthe rolled-up record described above

Everything session-shaped in Leed’s reporting — sessions, visitors, bounces, entry and exit pages, the funnel — reads the last row, not the ones above it.

Making your own template elements trackable

Pages you write in the CMS already carry data-id on every block; the editor puts them there. You only add these attributes by hand in templates and partials you author yourself.

Two attributes do the work:

data-id is a UUID that identifies an element. It makes the element eligible for visibility tracking, and it is what a click on that element is attributed to. Put it on the thing that gets acted on — the button, not the wrapper that shows and hides it.

data-reason carries UTM context for a click without putting parameters in the URL. The click tracker reads it off the clicked link and the ingest endpoint parses it as UTM columns on the click event. This is the mechanism behind Leed’s “hidden UTMs”: your links stay clean and shareable, and attribution still works.

<a href="pageid:THE-DESTINATION-PAGE-ID"
   data-id="b2c3d4e5-f6a7-8901-bcde-f12345678901"
   data-reason="utm_campaign=leed&utm_source=internal&utm_medium=hero&utm_term=get-started">
  Get started
</a>

Write the fields in this order — campaign, source, medium, term, content — and keep utm_campaign=leed for everything internal, so all of your on-site interactions bucket into one campaign and utm_medium is what distinguishes them.

Never put UTM parameters on an internal link’s URL. Use data-reason instead. A UTM string in the href pollutes the address bar, gets shared, and shows up later as a self-referral.

Several Leed features already stamp data-reason for you. Do not replicate these by hand:

Featureutm_campaignutm_sourceutm_mediumutm_termutm_content
Menu linksleedmenuthe menu’s namemenu item id (plus instance)the item’s label
Autolinksleedinternalautolink—the matched text
Dynamic CTAsleedinternalctathe CTA idthe link text
Recommendationsleedinternalrecommendationthe page idthe link text
Docs header logo and buttonleedmenuheader——
Docs footer logoleedmenufooter——

Every element carrying a data-id is also a candidate for the click-count overlay, which draws these counts straight onto a preview of the page. The full patterns for adding both attributes to your own templates are in writing your own partials.

What Leed deliberately does not track

Three surfaces run entirely outside the session and cookie chain. A request to any of them mints no session, no visitor record and no cookie, and produces no analytics event of the kind described above:

  • The Docs MCP — the endpoint an AI client connects to for your documentation.
  • The public agent surface at /api/agent/* — the read-only tools an agent can call on your site.
  • Reader search at /api/search* — so a debounced keystroke does not mint a session per letter typed.

They are not invisible: each writes its own request log instead. AI and agent traffic is counted separately, and where to find it is covered in analytics for AI and MCP clients.

Privacy posture, stated plainly

Four facts, so you can reason about your own obligations rather than guess:

  • There is no cookie-consent banner anywhere in the published site, and no setting that adds one. If you need one, add it in your own templates.
  • navigator.doNotTrack is not checked. A visitor who has set it is tracked exactly like anyone else.
  • There is no visitor-facing analytics opt-out. No preference page, no toggle, no cookie the visitor can set to be excluded. The only opt-out Leed ships is the unsubscribe link in email, which stops email — it does not stop analytics.
  • Approximate location comes from network-level request data, not from the browser’s geolocation API. No visitor is ever prompted for their position, and nothing more precise than a coarse region is derived.

Everything captured is first-party and stays in your workspace’s own database. Retention is enforced as a query cutoff rather than a deletion — nothing here is ever thrown away, which is worth knowing in both directions.

ESC