SDK
Analytics client
Record page views and custom events from the browser with a publishable key, and attach the Myna content each page is showing.
@myna-sh/sdk/analytics records what people do in your product. It is a subpath rather than a separate package, so importing it pulls this entry point and nothing else. A page that only tracks never carries the management client.
$ npm install @myna-sh/sdk
import { createAnalytics } from "@myna-sh/sdk/analytics";
export const analytics = createAnalytics({
ingestKey: "myna_ik_...",
environment: "production",
appVersion: import.meta.env.VITE_COMMIT_SHA,
autoPageviews: true,
});
The key is a publishable ingest key, the same myna_ik_… credential Feedback uses. It is meant to sit in your bundle. What makes that safe is that it is bound to the origins your project registered, it can only append, and the project has to be running Analytics.
What it does not do
No session replay, no heatmaps, no autocapture. It does not read the DOM, wrap fetch, patch console, or fingerprint the browser. An event exists because you called track(), or because you asked for page views.
It sets no cookie. The visitor id is generated here and kept in the storage you choose.
Recording
analytics.track("project_created", { template: "nextjs" });
analytics.page(); // the current URL
analytics.page("/pricing"); // an explicit path
The client buffers events and sends them on a timer (2s by default), when the buffer fills (20 events by default), and again when the page is hidden. That last send uses fetch(..., { keepalive: true }), the only way a batch survives the page that queued it while still carrying the Authorization header.
Properties are scalars: strings, numbers, booleans, null. Up to 32 per event. Nested objects and arrays are dropped rather than stored, because a group key that is a JSON blob is a group of one.
Event names
$pageview is the only event Myna defines, and the $ prefix is reserved for Myna. Every other name is yours: letters, digits, spaces, and . _ - :, up to 80 characters.
Identity
analytics.identify({
id: user.id,
email: user.email,
properties: { plan: "pro", seats: 12 },
signature, // HMAC-SHA256(identitySecret, user.id), from your server
});
Compute signature on your backend with signIdentity from @myna-sh/sdk/feedback/server. It is the same secret and the same function Feedback uses, so a person who files a bug and a person who abandons a checkout are one row.
Without a valid signature the visitor stays anonymous. An unsigned claim would let any visitor file their behaviour against a stranger's profile.
analytics.identify(null); // on sign-out
Pass null when they sign out. Without it, the next person on a shared machine inherits the last one's identity. The client flushes buffered events before the switch, so events produced while somebody was signed out are not retroactively filed against whoever signed in.
Content context
Tell the client which Myna entry the page is showing, and every event from then on carries it:
const page = await myna.entries.get("pages", "pricing");
analytics.setContent({
entry: page.id,
collection: "pages",
revision: page.revisionId,
});
That one line is what makes release impact work. Myna already knows which revision it served and which release that shipped in. The client is only telling it which page the visitor is on.
Pass null to clear it, or pass per-event content as track()'s third argument.
Options
| Option | Default | Means |
|---|---|---|
ingestKey |
— | Required. A publishable myna_ik_… key. |
apiUrl |
https://api.myna.sh |
Override for a self-hosted or staging deployment. |
environment |
— | Your environment name, attached to every event. |
appVersion |
— | Your build: a version, a commit sha, a deployment id. |
autoPageviews |
false |
Send $pageview on load and on History navigation. |
storage |
"local" |
Where the visitor id lives: local, session, or memory. |
flushInterval |
2000 |
Milliseconds to buffer before sending. |
batchSize |
20 |
Events to buffer before sending regardless of the timer. |
identity |
— | An initial signed identity. |
content |
— | Initial content context. |
onError |
— | Called when a batch is refused, so a broken key is not silent. |
Storage and consent
storage: "memory" writes nothing to the device. Events still record and still stitch into a session; the same person tomorrow is simply a new visitor. It is the honest setting for a page rendered before a consent decision has been made, and there is nothing else to clear, because Myna sets no cookie and stores no IP address.
storage: "session" keeps the id for the tab. storage: "local" survives a browser restart.
autoPageviews and routers
autoPageviews wraps history.pushState and history.replaceState to notice client-side navigation, and restores both on shutdown(). It reads the URL and nothing else.
If your router already tells you when a route resolved, prefer that. A router knows when a navigation actually finished. The History API only knows when the URL changed, which is earlier and is sometimes wrong. In React, use usePageview.
Use one or the other. Two sources of page views double every number on the dashboard, and nothing in the data says which half is spurious.
Errors
A refused batch calls onError and is not retried. A retry queue on a page that is navigating away grows in a tab nobody is looking at and duplicates whatever did arrive. A lost batch is a small hole in a chart; a duplicated one is a wrong number nobody can explain.
The common refusals:
| Code | Means |
|---|---|
AUTHENTICATION_REQUIRED |
The key is missing, malformed, or revoked. |
PERMISSION_DENIED |
The request carried no Origin, or one the project has not registered. |
PRODUCT_NOT_ENABLED |
The project does not run Analytics. |
RATE_LIMITED |
Too many batches from one address. |
A successful response reports what was dropped and why, so a partly-wrong integration shows up during development rather than a month later:
{
"accepted": 18,
"dropped": 2,
"sessionId": "ase_...",
"reasons": [{ "reason": "excluded_path", "count": 2 }]
}
Reading the data back
There is no read here. The analytics client can only append, because a credential that can also read is not a containment boundary and this one ships in a browser bundle. Query the data with the management client's analytics namespace, the CLI, or the MCP tools.
