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.1.

Installation

<script src="https://www.clientlog.bayis.co.uk/clientlog.js"></script>
<script>
  const logger = clientlog.createLogger({
    endpoint: "https://api.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.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 page.scroll.<page> at reading-depth milestones.
clicks true Emit page.click.<page> for elements matching clickSelector.
navigation true Emit page views on history changes as well as load.
dwell true Emit page.exit.<page> 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 event 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

Every automatic event is named page.<kind>.<page>, where <kind> is view, exit, scroll or click and <page> is derived from the path. One page’s whole lifecycle therefore shares a suffix, and one kind of event shares a prefix:

  • page.view. matches every page view.
  • page.click.pricing matches clicks on the pricing page only.
  • .pricing matches everything that happened on the pricing page.

page.view.<page>

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 page segment derives from the path: the extension (.html, .htm, .php, .aspx) and any query string are removed, leading and trailing slashes are removed, inner slashes become dots, other punctuation becomes an underscore, and the result is 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

The query string changes the payload and leaves the action name alone, so a page visited with different tracking parameters remains one action.

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.<page>

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.

page.scroll.<page>

At each milestone, once per page view.

{ "action": "page.scroll.pricing", "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.

page.click.<page>

For elements matching clickSelector.

{
  "action": "page.click.pricing",
  "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. The colon is punctuation and becomes an underscore.

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.exit and page.view 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.log explicitly 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.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.