Client reference
The client is a single script with no dependencies and no build step. It captures a defined set of interactions automatically and exposes one function for everything else.
Current version: 2.0.0.
Installation
<script src="https://www.clientlog.bayis.co.uk/clientlog.js"></script>
<script>
const logger = clientlog.createLogger({
endpoint: "https://api.dev.clientlog.bayis.co.uk/v1/event",
project: "myapp/home"
});
</script>
The script attaches a global named clientlog.
Subresource integrity
A hash is published alongside each release so the browser can verify the file has not been altered:
<script
src="https://www.clientlog.bayis.co.uk/clientlog.js"
integrity="sha384-BASE64HASH"
crossorigin="anonymous"></script>
The current value is at /clientlog.integrity.txt. Pin it, and update it when
you update the client.
Options
const logger = clientlog.createLogger({
endpoint: "https://api.dev.clientlog.bayis.co.uk/v1/event",
project: "myapp/home",
options: {
session: true,
scroll: true,
clicks: true,
navigation: true,
dwell: true,
scrollMilestones: [25, 50, 75, 90, 100],
clickSelector: "a, button, [data-clientlog]",
minDwellMs: 250,
includeVersion: true,
respectDoNotTrack: false,
sessionIdFn: null,
pageNameFn: null,
debug: false,
dryRun: false
}
});
Capture
| Option | Default | Description |
|---|---|---|
session |
true |
Generate and attach a session identifier. |
scroll |
true |
Emit scroll at reading-depth milestones. |
clicks |
true |
Emit click for elements matching clickSelector. |
navigation |
true |
Emit page views on history changes as well as load. |
dwell |
true |
Emit page_exit with visible time and reading depth. |
Behaviour
| Option | Default | Description |
|---|---|---|
scrollMilestones |
[25, 50, 75, 90, 100] |
Depth percentages that emit an event. |
clickSelector |
a, button, [data-clientlog] |
Which elements produce click events. |
minDwellMs |
250 |
Below this, no page_exit is emitted. |
includeVersion |
true |
Add _v with the client version to every payload. |
respectDoNotTrack |
false |
Disable all logging when the browser sets DNT. |
Overrides
| Option | Default | Description |
|---|---|---|
sessionIdFn |
null |
Function returning a session identifier. |
pageNameFn |
null |
Function mapping a pathname before it becomes an action name. |
Development
| Option | Default | Description |
|---|---|---|
debug |
false |
Log every outgoing event to the console. |
dryRun |
false |
Capture and log without sending. |
debug and dryRun together let you inspect the full event stream during
development without writing anything.
Automatic events
page_view.<name>
On load, and on every history change when navigation is enabled.
{
"action": "page_view.pricing",
"payload": {
"path": "/pricing.html",
"query": "?ref=nav",
"full_path": "/pricing.html?ref=nav",
"referrer": "https://news.ycombinator.com/"
}
}
The name derives from the path: extension stripped, slashes and punctuation
replaced with underscores, lowercased. / becomes home.
/ page_view.home
/pricing.html page_view.pricing
/docs/rules.html page_view.docs_rules
/blog/why-we-built page_view.blog_why-we-built
Page identity in the action name is what makes the transition matrix, the landing page breakdown and the exit report meaningful. It also means rules can match a page without loading the session’s events, which is faster and cheaper.
page_exit.<name>
When the page is hidden or unloaded, if dwell is enabled and visible time
exceeds minDwellMs.
{
"action": "page_exit.pricing",
"payload": {
"path": "/pricing.html",
"dwell_ms": 47200,
"max_scroll": 82
}
}
dwell_ms counts visible time only. A backgrounded tab does not
accumulate. A page hidden and returned to emits more than one page_exit; sum
dwell_ms across them for total attention.
max_scroll is the deepest position reached, as a percentage, whether or not
it crossed a milestone.
These two fields are what makes “how long did they spend on the pricing page, and did they read it” answerable.
scroll
At each milestone, once per page.
{ "action": "scroll", "payload": { "percent": 75, "path": "/pricing.html" } }
Milestones fire at the first position at or past the threshold, so a single fast gesture from 20 to 90 emits 25, 50 and 75 as well as 90. Pages shorter than the viewport emit no scroll events at all.
click
For elements matching clickSelector.
{
"action": "click",
"payload": {
"tag": "A",
"text": "See pricing",
"href": "https://example.com/pricing.html",
"id": "nav-pricing",
"label": "nav-pricing-cta"
}
}
text is trimmed to 100 characters. href, id and label appear only when
present. label comes from a data-clientlog attribute:
<a href="/pricing.html" data-clientlog="nav-pricing-cta">See pricing</a>
That attribute is a stable identifier you control. It survives copy changes
that would break a rule matching on text, and it survives the class renames
that break CSS selectors.
Custom events
logger.log(action, payload);
logger.log("user.payment.success", { plan: "standard", currency: "GBP" });
logger.log("story.viewer.load.complete", { duration_ms: 412 });
logger.log("filter.applied", { field: "category", value: "electronics" });
action is a string you choose. payload is any JSON-serialisable object,
capped at 512 characters after serialisation. Every payload also carries
path and, unless disabled, _v.
Naming conventions are in events.
Declarative binding
For click tracking on named elements without writing handlers:
clientlog.bindEvents({
"#signup-button": "user.signup.click",
".cta-primary": { event: "cta.click", payload: { position: "hero" } },
"#search-form": { type: "submit", event: "search.submit" }
}, logger);
Every element matching each selector is bound. A string value means a click
event with that action name; an object accepts type, event and payload.
Page naming
Paths carrying identifiers produce one action name per identifier, which fills the transition graph with single-visit nodes and makes it unreadable.
options: {
pageNameFn: (path) => path
.replace(/^\/products\/[^/]+$/, "/products/:id")
.replace(/^\/users\/[^/]+\/posts\/[^/]+$/, "/users/:id/posts/:id")
}
Which yields page_view.products_:id for every product page, with the
specific path still available in the payload.
Apply this to any route with a numeric or slug segment. It is the single most useful piece of configuration on a content or commerce site.
Session identity
By default the identifier is opaque, generated in the browser, held in
sessionStorage, and scoped to one tab on one site. It correlates with
nothing: not with a previous visit, not with another site, not with a person.
Closing the tab ends it.
1782693098872-7xzwuxbzd
sessionIdFn replaces the generator:
options: {
sessionIdFn: () => "user-" + currentUser.id
}
This groups every session by the same authenticated user, which makes returning behaviour and multi-visit journeys measurable.
It also introduces identity linkage. Doing so makes you responsible for the lawful basis under UK GDPR, the consent notice your users see, and the retention and erasure policy that follows. Clientlog provides the mechanism; the obligation is the site owner’s. See privacy.
Storage is unavailable in some private browsing modes and sandboxed frames. In
those cases the session identifier is null and events are recorded without
one.
Single-page applications
With navigation enabled the client hooks pushState, replaceState and
popstate, so route changes emit page_view and page_exit pairs without
further work in React, Vue, Svelte or any router built on the History API.
Two things worth knowing:
- Create the logger once, at application start. The history patch is applied once per document, so a second logger will not duplicate page views, but it will duplicate scroll and click listeners.
- Only pathname changes emit a page view. A route change that alters only
the query string does not. Call
logger.logexplicitly if that transition matters.
Do not track
options: { respectDoNotTrack: true }
When the browser sets DNT, createLogger returns a logger whose log does
nothing and whose disabled property is true. No requests are made and no
session identifier is created.
Off by default, because the default configuration collects no personal data and DNT signals in current browsers are inconsistent. Enable it if your privacy policy commits to honouring the header.
Content Security Policy
connect-src 'self' https://api.dev.clientlog.bayis.co.uk;
script-src 'self' https://www.clientlog.bayis.co.uk;
Ad blockers
The client and the API are served from the same infrastructure, and neither host appears on the filter lists that block advertising and analytics trackers. Blocking rates are consequently far lower than for Google Analytics or the Facebook pixel.
Serving the client from your own domain removes the question entirely. Host
clientlog.js yourself and point endpoint at a CNAME under your domain.
Contact us to have this configured.