A reference for what the KO counter puts on window. How to add the wrapper and send your
first event is in Custom events from a single-page app
and Custom events from a regular website; how to name
an event is in The event dictionary.
This describes the counter as it works today. It is not a contract promising compatibility.
Two calls
Everything custom events need is two methods on the ko.visit module:
ko.visit.track(event_name, envelope) // send an event
ko.visit.setTrackData(data, merge) // set the context for following events
ko.visit exists only once the counter has loaded. Before that — the koLayer queue.
track(event_name, envelope)
event_name is a string. envelope is the envelope, not the event’s data:
ko.visit.track('report_exported', {
data: { rows: 42, format: 'xlsx' }, // event fields go here and nowhere else
income: 0, // money, optional
cost: 0, // spend, optional
});
Envelope fields:
| Field | Type | What it does |
|---|---|---|
data | object | The event’s fields. A report breakdown uses the field:data.<key> dimension |
income | number | Adds up as money. Sits next to data, not inside it |
cost | number | The same, for spend |
entity | string | visitor by default — the second part of a metric formula. No reason to change it |
The common mistake is a flat object:
ko.visit.track('report_exported', { rows: 42 }); // ← rows lands in the service part
The event is recorded, but rows is not among its data: a breakdown by that field gives one
empty row.
An amount inside data does not become money — it adds up as an ordinary number. Money is
income and cost on the envelope, nothing else.
setTrackData(data, merge)
Mixes fields into every following event on this page.
ko.visit.setTrackData({ user_id: 42, plan: 'pro' }, true); // extend
ko.visit.setTrackData({ user_id: 42 }); // replace the whole context
merge defaults to false. A call without the second argument replaces the context
entirely rather than adding to it — a second setTrackData wipes what the first one set. If
you set context in layers as facts become known, the second argument is not optional.
On a key collision the context wins, not the event. Context fields are applied on top of
the event’s data, so a plan in the context overwrites the plan you passed to track.
A given key lives either in the context or in the event, never in both.
The context lives in page memory. A reload clears it.
The koLayer queue
The counter loads asynchronously and the first event often happens before it. Until it
loads, calls queue up in window.koLayer.
window.koLayer = window.koLayer || [];
window.koLayer.push(['visit', 'track', ['report_exported', { data: { rows: 42 } }]]);
A queue item is one array of three parts: module name, method name, array of arguments.
Four separate arguments to push will not be executed.
A function is also accepted, and runs in the counter’s context:
window.koLayer.push(function () { /* the counter is here by now */ });
On initialisation the counter replaces push with its own implementation, so a write after
loading executes immediately instead of waiting to be drained. The “counter already loaded →
call it directly” branch in the wrapper is still worth having: it gives you a return value.
There is a second queue inside the counter that needs nothing from you: an event fired before the visit has an identifier waits there and ships once the visit opens.
Counter options — ko_options
The ko_options object is declared before the counter loads; afterwards it is not read.
var ko_options = {
modules: {
visit: {
url_tracking_hash_router: false,
url_tracking_query_patterns: ['*', '!utm_*', '!_*'],
url_tracking_debounce_ms: 100,
},
},
};
| Option | Default | What it does |
|---|---|---|
url_tracking_hash_router | false | true if the app routes through #/ |
url_tracking_query_patterns | ['*', '!utm_*', '!_*'] | Which query parameters are part of the page address. * allows everything, ! excludes by mask |
url_tracking_debounce_ms | 100 | Delay before an address change is processed |
The parameter filter is what stops one page multiplying into a dozen report rows because of
?tab=2&_r=17. By default utm_* and anything starting with _ are already excluded.
Debugging
| What | How |
|---|---|
| Turn debugging on | ?ko_debug in the page address |
| Silence the counter | ?ko_off in the page address |
| Confirm an event shipped | Network tab, the visit.php?type=event request; its body is the whole envelope |
The counter also stays silent without a key: an empty hash in the install snippet switches
it off by itself.
Limits
- There is no deduplication. One call, one record. A screen that re-renders itself fires on every pass; the “already counted” key is yours to keep.
- An event lives on a visit. An action that did not happen in a browser — a payment confirmed server-side, a nightly recalculation — has no visit and cannot be a site event.
- Context does not survive a reload: it lives in page memory.
- An event fired while leaving the page may not make it if the counter has not loaded. The reliable anchor is a fact on the receiving screen.
- The counter records page views itself, SPA routes included. Your own router subscription doubles them.
Related pages
- The event dictionary — naming events and where to put the call
- Custom events from a single-page app
- Connecting a website — installing the counter