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):
| Path | Cache policy |
|---|---|
GET /lyraflow.js | max-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.
Consent
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.