Docs Sending data in

Sending events from a browser

The snippet, the origin allowlist, and what the script does when it cannot reach the server.

For a website or web app, @lyraflow/sdk-browser is a small script that calls /v1/track, /v1/page, /v1/identify and /v1/batch for you, and handles retries, an on-page queue, and (optionally) a consent gate. It is not published to npm and there is no CDN: the server serves its own bundle, so a self-hosted install never depends on infrastructure outside it.

Paste this before </head>:

<script>
  !function(){var l=window.lyraflow=window.lyraflow||{};l.q=l.q||[];
  ["init","track","page","identify","consent","reset","flush"].forEach(function(m){
    l[m]=l[m]||function(){l.q.push([m].concat([].slice.call(arguments)))}});
  }();
</script>
<script async src="https://analytics.example.com/lyraflow.js"></script>
<script>
  lyraflow.init({ host: "https://analytics.example.com", writeKey: "wk_live_…" })
</script>

The first block is a stub: it queues any call made before the async script finishes loading, so a track() fired the instant the page renders is never lost to a race with the network. init is queued the same way as every other method — the third block usually runs long before the async script has loaded, which is exactly why init has to be in the stub’s method list. The moment the real script loads it replaces the stub on window.lyraflow and takes the queue with it, running the queued init first whatever order the calls were made in. On a repeat visit the cached script can run before the third block; the queue is then held until that init arrives, and drained by it. Either way nothing queued is lost. Replace both occurrences of https://analytics.example.com with your own Lyraflow host, and writeKey with the wk_… key from Getting started above — the same one your server-side calls already use. Or skip the substitution entirely: lyraflow snippet (see packages/cli/README.md) prints this exact block with your project’s own host and write key already filled in, plus which event names have actually arrived, so you can tell “installed” from “firing” without opening a browser.

The bundle is served by the app itself at two paths, unauthenticated (a <script> tag has no way to send a header):

PathCache policy
GET /lyraflow.jsmax-age=300 — an upgrade reaches already-cached browsers within five minutes
GET /lyraflow-<version>.js (e.g. /lyraflow-0.2.0.js)max-age=31536000, immutable — these exact bytes never change, for as long as the server runs that version

Both paths are served gzipped to any client that accepts it, by the app itself — putting a compressing proxy in front is a valid thing to do, but it is not something you have to do to avoid shipping three times the bytes.

Put /lyraflow.js in your script tag. The versioned path is cache-busting, not pinning, and a script tag must not use it. A server only serves the versioned path for the version it is currently running, so upgrading makes the previous one answer 404 — naming the version it does serve, and telling you this. That failure is quiet in the worst way: browsers holding the old bundle keep working from cache for up to a year, so data goes on arriving while every new visitor silently collects nothing.

The versioned path exists so an upgrade cannot be served a stale cached bundle, not so a site can freeze one. There is no way to pin an SDK version against a server that has moved on; if you need that, pin the server to a release tag.

If the sibling package was never built into your image, both paths answer 503 rather than taking the rest of the server down.

The write key is public by design — it is meant to sit in page source, same as in any curl example above. It can only write events. The server key must never appear here or anywhere in browser-shipped code: it merges identities, reads and deletes person data, and runs segment queries — see Identity resolution, Segments, and Privacy below for everything it gates.

Methods

init() must be called once, before anything else. Every other method is silently dropped (and logs a console warning) if called first.

lyraflow.init({
  host: 'https://analytics.example.com',
  writeKey: 'wk_live_…',
  cookieDomain: '.example.com', // optional; auto-detected if omitted, see below
  requireConsent: false,        // optional; default false, see Consent below
  autoPageView: true,           // optional; default true — fires one page() at init, see Single-page apps below
  debug: false,                 // optional; default false — verbose console.debug logging
})
lyraflow.track('signup', { plan: 'trial', seats: 3 })
lyraflow.page()            // stored as $page, with no $page_name
lyraflow.page('Pricing')   // stored as $page, with $page_name = "Pricing"
lyraflow.identify('user-42', { plan: 'trial' })
lyraflow.consent(true)   // or false — see Consent below
lyraflow.reset()   // e.g. on logout: flushes, then rotates to a fresh anonymous id
await lyraflow.flush()   // e.g. before a manual redirect the browser's own unload handling won't catch

Events are queued in localStorage and sent in batches to /v1/batch (see The ingest API for that endpoint’s own semantics), on a timer and again on page unload using fetch’s keepalive option, so a tab closed mid-batch still delivers what was already queued.

Off by default (requireConsent: false): the SDK starts sending immediately, the same as any other analytics snippet. Set requireConsent: true and it starts in a pending state instead — nothing touches a cookie, localStorage, or the network until lyraflow.consent(true) is called. (One exception: if the browser already signals Do Not Track or Global Privacy Control, requireConsent: true starts the gate refused outright, without waiting for a call. With requireConsent left off, neither signal is read at all — that compliance decision is left entirely to you.) Anything tracked while pending is held in memory (not persisted) and released once consent is granted; lyraflow.consent(false) discards it and stops the SDK from sending anything further.

A refusal cannot be remembered by the SDK. Persisting “this visitor said no” would itself mean writing a cookie or localStorage entry — exactly what a refusal declines. Your application owns that choice: store it however you already store consent decisions, and pass the outcome back in on the next load (requireConsent: false once you know they said yes, or call lyraflow.consent(false) again before anything else runs if they said no).

LYRAFLOW_ALLOWED_ORIGINS

The CORS preflight restriction described in The ingest API applies here too, since this is exactly what triggers it: the same LYRAFLOW_ALLOWED_ORIGINS env var, and the same limit. It stops someone from quietly reusing your write key on a different origin without you noticing — it is not a security boundary, because the write key already ships in page source and any non-browser sender ignores CORS entirely.

When the allowlist does not take effect

Setting LYRAFLOW_ALLOWED_ORIGINS somewhere the server never reads it fails silently and in the permissive direction: nothing errors, the stack comes up healthy, and every origin is still allowed. There is no outward difference between that and a working allowlist until somebody tries the thing the allowlist was meant to stop.

So the server says which mode it booted in, every time:

docker compose logs lyraflow | grep 'ingest CORS'
  • ingest CORS restricted to 2 origin(s) … — it took effect, and the line names the origins it parsed.
  • ingest CORS unrestricted … — it did not. Every origin is allowed.

The usual cause of the second line is Compose. .env alone is not enough unless the variable is also named in the environment: block of the lyraflow service — Compose uses .env for substitution inside the compose file and passes the container only the variables that block names. The shipped docker-compose.yml names this one, so a plain .env line works on a stock install; a compose file predating that, or a hand-edited one, does not. Check what actually reached the container:

docker compose exec lyraflow env | grep ALLOWED_ORIGINS

Empty output there, with the value present in .env, is exactly this bug.

The same silence applies to a value that did arrive but does not match: origins are compared exactly. https://example.com and https://www.example.com are two different origins, http:// and https:// are two different origins, and a trailing slash or a port that the browser does not send makes an entry match nothing. A blocked preflight is not rejected, dead-lettered, or counted anywhere — so if one instrumented site goes quiet while the others keep reporting, compare its entry against the Origin header the browser actually sends, character for character.

Single-page apps

The SDK does not patch history.pushState or listen for route changes, and autoPageView does not change that: its one automatic page() call fires once, on this hard load, and never again for the life of the tab. A visitor who navigates client-side through five routes without a full reload produces exactly one page view unless you call lyraflow.page() yourself after each client-side navigation completes.

This page is the Sending events from a browser section of the product README at v0.15.0. It is generated from that file rather than written here, so a correction belongs upstream.