From empty project to first signal
A project is the reporting boundary. Add one source for every product surface that should contribute data.
- 01
Create a project
Open the dashboard, choose New project, give it a recognisable product name, then select the first source platform.
- 02
Configure the source
For web, add the production domain and any allowed alternates. For apps or extensions, add the exact bundle, package, add-on, or extension identifier.
- 03
Install the collector
Copy the source-specific setup shown in Settings → Data sources. App and extension collection must receive an explicit consent state.
- 04
Verify the signal
Generate a test visit or event. The source rail and Settings show the last event plus accepted and rejected ingestion counts. Realtime and headline readings update in place while the tab is visible.
First verification
Use a production-shaped test: the real origin or app ID, the intended environment, and the source's current write key. A successful request with a source mismatch is still rejected at the event level.
Projects, sources, and filters
One project can receive compatible signals from several platforms without pretending that every metric means the same thing.
Project
Owns reports, people, goals, funnels, cohorts, retention, share links, timezone, and retention policy. Existing website IDs remain valid project IDs.
Source
Represents one web, browser-extension, iOS, or Android input. Each source has its own validation rules, SDK, identifiers, write key, and health readings.
| Platform | Identifier | Best-fit readings |
|---|---|---|
| Web | Allowed domain(s) | Visitors, acquisition, SEO, engagement, heatmaps |
| Browser extension | Extension, add-on, or bundle ID | Installs, surfaces, browser/version, errors |
| iOS | Bundle ID | DAU, screens, releases, revenue, stability |
| Android | Package name | DAU, sessions, releases, revenue, timing |
The source signal rail beneath the dashboard navigation shows every configured platform, its last signal, and the active source filter. Select a source to scope compatible reports; choose all sources for a project-wide view. Acquisition, SEO, and heatmaps only appear when the project contains a web source.
Install each platform
Installation details and rotatable public write keys live under Settings → Data sources.
Website
Put the script before the closing </head> tag. Replace the host and project ID with the values shown by your source. The legacy endpoint and existing snippets remain supported.
Web collector
<script
defer
src="https://YOUR-MU-HOST/script.js"
data-website-id="PROJECT_ID"
></script>The web collector is cookie-free and storage-free. It records page views and privacy-safe engagement signals; it does not need an app write key in page markup.
Browser extensions
Bundle the TypeScript SDK into the extension worker. Popup, options, side-panel, and content-script clients proxy to that worker, which owns the IndexedDB queue. No extra storage permission is required.
WebExtension worker
const mu = await MuExtension.configure({
endpoint: "https://YOUR-MU-HOST",
writeKey: "mu_…",
appId: chrome.runtime.id,
collectionEnabled: consent.analytics,
runtime: chrome.runtime,
incognito: chrome.extension.inIncognitoContext,
});
await mu.capture("upgrade_clicked", { plan: "pro" });Store-safe defaults
Automatic collection is limited to install, update, and runtime lifecycle. Mu never automatically reads visited URLs, active tabs, page content, searches, or browsing history. Incognito contexts are excluded. Firefox builds must also declare the current built-in data-consent manifest requirements.
Mobile and cross-platform apps
Swift / iOS
Use the Swift Package core. It owns identity, foreground sessions, lifecycle, a file-protected queue, and optional automatic screen tracking.
Kotlin / Android
Use the Android library. It owns app lifecycle, five-minute foreground sessions, private preferences, and the durable event queue.
React Native / Expo
Use the JavaScript facade over the native cores so sessions, identity, queues, and lifecycle remain platform-consistent.
Flutter
Use the Dart facade and native plugin. Configuration always includes the endpoint, source write key, and explicit collection state.
Mobile queues flush every 10 seconds or 20 events, flush again when the app backgrounds, retry with exponential jitter, and cap themselves at 1,000 events or 5 MB. The default foreground inactivity boundary is five minutes.
Events, properties, and context
Mu keeps a small typed event vocabulary and carries platform context separately from your custom properties.
| Kind | Use it for | Required payload |
|---|---|---|
| pageview | A website location | Name and standard context |
| screen | An app screen | Screen name/class in context |
| event | A product action | Event name |
| lifecycle | Install, update, foreground, background | Lifecycle name |
| revenue | A purchase, refund, or renewal | Amount and ISO currency |
| error | A handled or unhandled failure | Message; optional type/stack |
| metric | Startup, screen, or custom timing | Duration in milliseconds |
| presence | A current activity heartbeat | Name and session |
Every event also has a client-generated event ID, occurrence time, anonymous install ID, optional external user ID, session ID, platform, SDK, environment, and optional release, screen, surface, OS, device, browser, locale, and timezone context.
Standard SDK surface
Direct batch request
curl https://YOUR-MU-HOST/api/v1/batch \
-H "Authorization: Bearer mu_…" \
-H "Content-Type: application/json" \
--data '{
"sentAt": 1786000000000,
"events": [{
"eventId": "evt_01JEXAMPLE",
"kind": "event",
"name": "upgrade_clicked",
"occurredAt": 1786000000000,
"anonymousId": "install_01JEXAMPLE",
"sessionId": "session_01JEXAMPLE",
"context": {
"platform": "ios",
"sdk": "swift",
"environment": "production",
"appId": "studio.selfhood.example",
"appVersion": "2.4.0"
},
"properties": { "plan": "pro" }
}]
}'Write keys are public identifiers
A client write key authenticates one source but is not a secret; it will ship inside your app. Mu protects ingestion with source identifiers, allowed origins, validation, rate limits, and key rotation. Never reuse it as an administrative credential.
People, installs, and sessions
Anonymous activity is useful by default; identified activity is intentionally opt-in.
Anonymous
App SDKs create a random install ID only after collection is enabled. Web visitors stay storage-free and rotate daily.
Identified
identify(userId) joins earlier anonymous activity to a canonical person using a project-scoped server hash.
Session
Mobile closes after five foreground-inactive minutes. Extensions share a 30-minute session across their product surfaces.
reset()ends the identified context and begins a fresh anonymous identity.setCollectionEnabled(false)clears the durable queue, persistent install identity, and identified user.- User properties describe the person; event properties describe one action. Secret-like and common PII property keys are removed.
- Settings can delete a person and their events from an external user ID without storing that raw external ID in Mu.
Cross-platform rule
Mu only builds unified cross-source funnels, journeys, and retention when events can be tied to the same identified person. It never silently equates a daily-rotating anonymous web visitor with a persistent app install.
Read reports without mixing meanings
The dashboard adapts its headline vocabulary to the selected source while project-wide analysis stays available where identity allows it.
| Area | Question it answers | Important behavior |
|---|---|---|
| Overview | What changed in this range? | Platform-aware totals, trends, and realtime |
| Explore | How does a metric move and segment? | Two breakdowns, typed filters, source/version/environment filters |
| Funnels | Where do people leave a fixed path? | Ordered steps and conversion drop-off |
| Journeys | What paths do sessions actually take? | Page, screen, and event transitions |
| Retention | Do identified people return? | Weekly cohorts; anonymous rotating web IDs are excluded |
| Cohorts | Who matches a reusable behavior? | Event occurrence within a chosen number of days |
| Goals | Did a named outcome happen? | Page or event outcomes and optional revenue |
| Users | What has one known person done? | Private only; never exposed by public share links |
Explore
Choose event totals, unique users, sessions, revenue, or average duration. Filter by event, source, platform, environment, app version, and typed property values; then add up to two breakdown dimensions. Save a useful configuration as a report so it can be reopened without rebuilding the query.
Date ranges use the project timezone. Event occurrence time—not upload time—places offline events in a report, subject to clock and retention validation.
Acquisition, SEO, engagement, and heatmaps
Web-only instruments stay attached to the relevant web source instead of appearing as generic app metrics.
Acquisition
Referrers, landing pages, UTM campaigns, locations, and automatic paid-search or affiliate channel grouping.
Engagement
Automatic scroll depth from 1–100%, outbound links, file downloads, form completion without values, and page journeys.
SEO
Connect Google Search Console per web source from Settings to read search queries and organic performance beside conversions.
Heatmaps
Inspect privacy-safe click density for the selected web page and range; app screens do not masquerade as DOM heatmaps.
Built-in filtering removes known bots, referrer spam, and data centre traffic from web readings. Allowed domains are configured per source; add preview or alternate domains deliberately rather than weakening origin validation.
Releases, stability, and performance
Quality readings connect errors and timings to the release and session in which they occurred.
- Releases shows adoption, sessions, errors, and crash-free sessions by application or extension version and build.
- Stability groups matching errors from type, scrubbed message, and a normalized raw-stack fingerprint. Mark groups open, resolved, or ignored as work progresses.
- Timings cover cold/warm app start, screen duration, and manually captured work, with percentile summaries instead of misleading averages alone.
- Abnormal-termination markers improve crash-free session estimates when the platform cannot submit a crash immediately.
Current reliability boundary
Mu stores scrubbed raw stacks and fingerprints errors, but does not yet symbolicate dSYM, ProGuard, or source-map artifacts. Treat release health as operational analytics, not a full crash-debugger replacement.
Realtime and ingestion health
Realtime answers a rolling activity question; source health answers whether data is reaching the server correctly.
Active in five minutes
A visitor or session appears when Mu has received qualifying activity inside the rolling five-minute window. It does not claim the person still has the page open.
Thirty-second refresh
Headline and realtime readings refresh in place every 30 seconds only while the browser tab is visible and online. The page and report editor are not reloaded.
Settings → Data sources shows request, accepted, and rejected totals for the last 24 hours plus the last event time. Requests with only duplicates are healthy and count as processed; rejected events point to validation, identity, source, time, or rate-limit problems.
Privacy, consent, exports, and sharing
Mu minimises collection before storage and keeps sensitive operational surfaces private.
Not collected
No raw IP addresses, advertising IDs, hardware IDs, automatic extension browsing history, form values, or remote extension code.
Protected at ingestion
Install and supplied user IDs are hashed with a project scope. Email-like values, query strings, bearer tokens, and common secret or PII keys are scrubbed.
- Extensions and apps require an explicit collection state at initialization. Disabled collection performs no network or persistent-identity writes.
- Public share links expose aggregate reports only. User profiles, raw events, write keys, error stacks, source health, and settings remain private.
- CSV and JSON exports contain up to 50,000 privacy-scrubbed events. External identity hashes and raw error stacks are omitted.
- A project retention window rejects data older than its policy. Run cleanup in Settings to remove existing expired events, sessions, source-health windows, and inactive identities.
Operate and upgrade the instance
Mu remains a one-process, self-hosted service, but production data still deserves an explicit migration and backup routine.
| Task | Command | When |
|---|---|---|
| Fresh schema | pnpm db:push | A new empty installation |
| Create migration | pnpm db:generate | After a reviewed schema change |
| Back up database | pnpm db:backup | Before every production migration |
| Apply migrations | pnpm db:migrate | Existing local or remote instance |
| Verify release | pnpm verify | Before deploying application code |
Production libSQL/Turso uses the database URL and auth token in the server environment. Keep JWT_SECRET and preferably a dedicated ANALYTICS_IDENTITY_SECRET stable across deploys: changing the identity secret changes future hashes and breaks continuity with existing people.
Safe migration order
Back up the remote database, test the committed Drizzle migration against a local copy, run the full verification suite, apply the migration remotely, then compare project, source, event, and relationship counts before deploying dependent code.
Troubleshoot by following the signal
Start at the source-health rail, then move from transport to validation to reporting.
01No requests+
Confirm collection is enabled, the SDK endpoint includes your Mu host, the worker or app has network access, and the current write key was bundled. Web users should confirm the script loads without a content-security-policy error.
02Requests but no accepted events+
Open the rejected count, then compare platform, SDK, origin, and appId with the configured source. Check required revenue/error/metric payloads and property limits.
03HTTP 401 or 403+
401 means a missing, invalid, or rotated write key. 403 means the web Origin is not in that source's allowed domains.
04HTTP 429+
The source exceeded its per-minute request or event budget. Keep original event IDs, back off with jitter, and retry later so deduplication remains effective.
05Accepted but absent from this range+
Check source filters, project timezone, environment/version filters, the client occurrence timestamp, and the project retention window. Offline data is charted at occurrence time.
06Realtime returned to zero+
The active reading is a rolling five-minute window. A person who was visible earlier naturally falls out after their last qualifying activity ages past five minutes; it is not a historical-visit counter.
07Person or retention looks incomplete+
Retention and cross-source identity require identify(userId). Anonymous web identity rotates daily and is intentionally excluded from identified-person retention.
08Extension events disappear+
Keep IndexedDB and batching in the background worker, send surface events through the runtime proxy, provide consent explicitly, and test service-worker termination plus restart.
Ingestion limits and response contract
These boundaries keep client behavior predictable and protect a single-process deployment.
| Boundary | Limit | Result when exceeded |
|---|---|---|
| Batch events | 50 | Request validation fails |
| Request body | 500 KB | HTTP 413 |
| Requests per source | 120 / minute | HTTP 429 with rejected events |
| Events per source | 3,000 / minute | HTTP 429 with rejected events |
| Properties | 100; 16 KB total | Event validation fails |
| Property key | 64 characters | Event validation fails |
| String / array | 2,048 characters / 50 values | Event validation fails |
| Future clock skew | 24 hours | future_timestamp rejection |
| Event ID | 8–128 characters | Event validation fails |
Send POST /api/v1/batch with Authorization: Bearer WRITE_KEY. The response reports accepted, duplicates, a rejected-event list with reason codes, and serverTime. Event IDs are deduplicated per source, so retry the same batch instead of generating new IDs.
POST /api/e, existing web snippets, dashboard URLs, share URLs, and the current stats API remain available through compatibility adapters.