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:
| Script | What it does | Loaded when |
|---|---|---|
| Alpine.js (CDN) | Interactive behavior in templates and docs chrome | Always, deferred |
| js-cookie (CDN) | Cookie helper the tracker depends on | Always |
zoomable.js | Click-to-zoom for images and diagrams | Always |
fp/fp.js | Builds the visitor fingerprint used to resume an identity | Always |
detectIncognito.js | Private-browsing detection, recorded on the session | Always |
timeme.js | The active/idle timing engine the timer tracker drives | Always |
tracker/whisper.js | The tracker. Starts all six trackers below | Always |
tracker/webmcp.js | Registers in-page tools for AI agents | Only when MCP is enabled for the site |
utilities.js | Forms, recommendations and dynamic CTAs | Always |
documentation.js | Documentation chrome — menu, table of contents, tabs | Always |
lunr-search.js or live-search.js | Reader search. Mutually exclusive — which one ships depends on your plan | Always, one of the two |
| Cloudflare Turnstile (CDN) | Form spam challenge | Only 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.
| Tracker | What it records | Key constant | Event table written |
|---|---|---|---|
| Page views | Every page load and in-page navigation | — | pageViewEvents |
| Active time | Alternating active and away segments | Idle timeout 30 seconds | timerEvents |
| Clicks | Link and button clicks, classified | Navigation delay 100 ms | clickEvents |
| Element visibility | Which blocks scrolled into view | Thresholds 0, 0.5, 1 | viewedElementEvents |
| Video | Second-by-second playback | Pulse 5 seconds | videoEvents |
| Audio | Second-by-second playback | Pulse 5 seconds | audioEvents |
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:
| Type | When it applies |
|---|---|
file | The link’s extension is in the tracked file-type list |
internal | The link’s host is the same as the page’s host |
outbound | The link has a host, and it is a different one |
other | Something 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.
| Cookie | Lifetime | Flags | What it identifies |
|---|---|---|---|
__sid | 30 minutes, refreshed on every request | secure, not httpOnly | The current session. Thirty idle minutes ends it. |
__lid | 1 year | secure, not httpOnly | The visitor — a UUID, stable across sessions and visits. |
___gimme___ | 30 minutes | secure, not httpOnly | A 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 contains | Starting event |
|---|---|
/clerk | form_fill |
/s/ | shortcode |
/f/ | file_download |
/e/ | email |
| anything else | pageview |
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.
| Field | How it is computed |
|---|---|
activeTime / awayTime | Sum of active and away timer segments |
activeCount / awayCount | How many of each segment there were |
pageviews | Page views in the session, with 404 rows stripped out first |
isBounce | pageviews === 1 — exactly one page view, and nothing else |
formFills | Count of distinct form ids submitted |
entryPageId / exitPageId | First and last page viewed. For a session started by a short link, the entry comes from the link’s destination |
videoPlayCount / audioPlayCount | Count of distinct asset ids played |
videoElapsedTime / audioElapsedTime | Seconds of media consumed |
duration | End time minus start time |
private / privateBrowser | Whether the incognito detector fired |
The event tables
Each stream is its own table, so you can map any report back to a raw source.
| Table | Written by | Key fields |
|---|---|---|
pageViewEvents | Page-view tracker | page, content type, referrer, referrer host, UTM columns, other query params |
userHistory | Page-view tracker, once per visitor per page | page, visitor id, what first brought them there |
clickEvents | Click tracker | click type, element type and id, whether the id was on the clicked element, destination page / URL / asset, anchor, UTM columns |
timerEvents | Timer tracker | active or away, elapsed seconds, pause count |
viewedElementEvents | Element-view tracker | element id, percent (0, 0.5 or 1) |
videoEvents | Video tracker | asset id, second, end / pause / nav |
audioEvents | Audio tracker | asset id, second, end / pause / nav |
fileDownloadEvents | The file-serving route /f/… | asset id, referrer |
shortcodeEvents | The short-link route /s/… | shortcode, destination, UTM columns |
emailEvents | The email routes /e/i/, /e/u/, /e/f/ | email batch, recipient hash |
formFillEvents | The form endpoint /clerk | form id, and whether the spam challenge passed |
mcpRequestEvents | The Docs MCP, the agent surface and reader search | source, tool name, inputs, result count, denied flag |
sessions | The session-closure job | the 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:
| Feature | utm_campaign | utm_source | utm_medium | utm_term | utm_content |
|---|---|---|---|---|---|
| Menu links | leed | menu | the menu’s name | menu item id (plus instance) | the item’s label |
| Autolinks | leed | internal | autolink | — | the matched text |
| Dynamic CTAs | leed | internal | cta | the CTA id | the link text |
| Recommendations | leed | internal | recommendation | the page id | the link text |
| Docs header logo and button | leed | menu | header | — | — |
| Docs footer logo | leed | menu | footer | — | — |
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.doNotTrackis 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.