Skip to content
  1. Home
  2. Help center
  3. For developers
  4. Custom events from a single-page app

Custom events from a single-page app

Last updated:

By the end of this page you will be sending user actions from inside your app to KO — opened a report, upgraded a plan, reached step three of a wizard — and seeing them in a report split by source and campaign. The counter has to be on your pages already: see connecting a website.

What the counter already does

In a single-page app the counter watches address changes and records a view on every move between screens. Visits, sources, campaigns, device and visitor come from there too. None of that needs configuring — and none of it should be duplicated in your code: a router subscription with its own “page view” doubles the views rather than backing them up.

Your code sends only what the counter cannot see: actions inside the app. The event lands on the visit, which turns “how many times was it clicked” into “how many of those came from paid traffic, and what did they turn into”.

One thing is worth configuring — the address parameter filter, so that one screen does not multiply into a dozen report rows because of ?tab=2&_r=17. Declare it before the counter loads:

var ko_options = {
  modules: { visit: {
    url_tracking_hash_router: false,               // true if your routes go through #/
    url_tracking_query_patterns: ['*', '!utm_*', '!_*'],
  } }
};

What those keys mean is in Counter calls.

Add the wrapper

Write one thin wrapper and call only that. Calling the counter directly from a component is how an event eventually ships bypassing the dictionary and in the wrong shape.

In Angular this is a service, in React a module; it amounts to the same thing.

const PROD_HOST = 'app.example.com';
const COUNTER_SRC = 'https://cdn.kodata.pro/site/?hash=YOUR_HASH';

const enabled = window.location.hostname === PROD_HOST;

export function initCounter(): void {
  if (!enabled) return;
  const script = document.createElement('script');
  script.src = COUNTER_SRC;
  script.async = true;
  document.head.appendChild(script);
}

export function track(event: string, data: Record<string, string | number | boolean> = {}): void {
  call('track', [event, { data }]);
}

export function setContext(data: Record<string, string | number | boolean>): void {
  call('setTrackData', [data, true]);
}

function call(method: 'track' | 'setTrackData', args: any[]): void {
  if (!enabled) return;
  const visit = (window as any).ko?.visit;
  if (visit && typeof visit[method] === 'function') {
    visit[method].apply(visit, args);
    return;
  }
  (window as any).koLayer = (window as any).koLayer || [];
  (window as any).koLayer.push(['visit', method, args]);
}

Put your own host in PROD_HOST and your own key in COUNTER_SRC — nothing else in this block needs changing. The call shape, how the koLayer queue works and what the second argument of setTrackData does are in Counter calls.

The host gate is not optional. Without it the project grows “sites” such as localhost and 192.168.0.5, and half your usage numbers are your developers at work. In the wrapper above the gate stops both the counter itself and every call.

Send your first event

Take an action the server confirms, and put the call in the success branch of its answer:

track('report_exported', { rows: rowCount, format: 'xlsx' });

The event name is Latin, lower case, past tense; differences between cases go into the data, not into the name. The full rules are in The event dictionary.

Fields that do not change from event to event belong in the context, set where the fact becomes known — then you do not repeat them in every call:

setContext({ user_id: user.id, plan: user.plan });   // after sign-in
setContext({ workspace_id: workspaceId });           // when the workspace changes

Layers live in page memory and are set again after a reload.

Measure screen time yourself

The counter sends time in its page-close event, but that is no use for “how many minutes did they spend on this screen”: that event carries no address, and active time accumulates for the page as a whole. On an ordinary site those are the same thing; in an SPA they are not.

So a screen whose time you need measures itself:

let openedKey = '', openedAt = 0;

function openScreen(code: string) {
  if (code === openedKey) return;        // router subscriptions fire more often than the screen changes
  closeScreen();
  openedKey = code; openedAt = Date.now();
  track('screen_opened', { screen: code });
}

function closeScreen() {
  if (!openedKey) return;
  track('screen_closed', { screen: openedKey, duration_ms: Date.now() - openedAt });
  openedKey = '';
}

Cover three cases or half your “closes” never ship: a screen change inside the app, the tab being hidden (visibilitychange, if you count active time separately) and leaving the page (pagehidebeforeunload does not fire on mobile).

Check that the event arrives

Open the live app on your production host, open the Network tab and perform the action you attached the call to. The sign of success is a visit.php?type=event request; its body carries your event name and your fields inside data.

If there is no request, add ?ko_debug to the address and try again: the counter starts reporting to the console what is happening to it.

Numbers appear in reports once you turn the event into a metric — that is the next section. But the fact that it shipped is visible here, immediately, with nothing to wait for.

Turn the event into a metric

  1. In the project’s Metrics section create a metric: your own key (say appReportExported) and the formula {sites_events:visitor:report_exported} — your event name in the third part.
  2. Rates and conversions are formulas over metric keys: {appExports}/{appReports}.
  3. People rather than clicks: {sites_events:visitor:report_exported[:value=visitors_id][:aggregate=uniq]}.
  4. A breakdown by event data is the field:data.product dimension in your report’s Breakdown section. You do not need a metric per value.
  5. If the event carries money, pass the amount as income next to data, not inside it — that is what makes it add up as money.

If it doesn’t add up

The metric shows 0 although events are flowing. Almost always a colon in the event name. Check it: Latin letters, digits and _ only.

One empty row in the breakdown. Event data was passed as a flat object. The working shape is { data: { … } }, which the wrapper provides; a direct counter call bypassing the wrapper loses it.

No events at all. The counter did not load (check in a clean profile, without a blocker), or the call happened on a host your own gate disabled.

Twice as many events as actions. The call sits on a render rather than on a fact: the screen re-rendered, the event went again.

A field in the report is not what you passed. The key exists both in the context and in the event data — the context wins. A given key lives in one of the two, never both.

The numbers look too good. Check whether your own development traffic is in the project — a breakdown by domain shows it.