
Privacy-respecting, self-hostable, unlimited analytics platform with cross-platform SDKs, interchangeable backends, bcrypt-hashed API keys, async inbox→processing pipeline, offline queuing, and built-in dashboard.
Privacy-respecting analytics platform — open source, self-hostable. A Ktor + PostgreSQL server (Docker), a PHP + MySQL server (shared hosting), and a Kotlin Multiplatform SDK for 7 targets.
Everything is MIT-licensed. No vendor lock-in.
QuietMetrix is a full-stack analytics platform. It gives you:
| Component | Technology | Purpose |
|---|---|---|
| SDK | Kotlin Multiplatform | Client library for tracking events |
| Ktor server | Kotlin + Ktor 3 + PostgreSQL | Docker-native backend |
| PHP server | PHP 8.2+ + MySQL | Shared-hosting compatible backend |
SDK targets: Android, iOS, macOS, Windows (MinGW), Linux, Web (Wasm/JS), JVM
Both backends implement the same OpenAPI 3.1 contract (docs/openapi.yaml). Any SDK or HTTP client works against either backend — swap backends without changing a single line of SDK code.
Key design decisions:
(metric, dims) -> n counters itself and sends
only those — no raw event or per-session trail ever leaves it, and none is stored// 1. Add dependency (KMP project, build.gradle.kts)
implementation(project(":quietmetrix-sdk"))
// or from Maven Central when published:
// implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
// 2. Initialize once at app startup
import com.quietmetrix.analytics.*
QuietMetrix.init(
QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_your_api_key_here",
)
)
// 3. Track events
trackEvent("page_view", screen = "home")
trackEvent("button_click", screen = "settings", props = mapOf("button_id" to "save"))
// 4. Consent (GDPR / cookie law)
setCookieConsent(true)
setAnalyticsEnabled(true)
// 5. Force flush (e.g., before app goes to background)
suspend fun onPause() { QuietMetrix.flush() }Want to track conversion through a multi-step flow (signup, checkout, onboarding)? See Funnels below — declare the steps once and the dashboard analyzes drop-off, breakdown, and time-to-convert automatically.
One command brings up Postgres, runs migrations, builds the dashboard, and starts the Ktor server:
git clone https://github.com/SobuuMedia/QuietMetrix.git
cd QuietMetrix
cp docker/.env.example .env # set QM_DB_PASSWORD, QM_JWT_SECRET
docker compose up --build
# API: http://localhost:8080/api/v1/health → {"ok":true}
# Dashboard: http://localhost:8080/dashboard/
# Create the admin user via direct DB insert (see detailed setup below),
# then log in and create a project.The fastest path for IONOS, Plesk, or any cPanel host — zero Composer, zero SSH required. Drop-in flat layout that bundles a static dashboard.
# 1. Edit php-hosting/config.php — fill in DB creds, ADMIN_EMAIL/PASSWORD,
# JWT_SECRET (openssl rand -hex 32). Keep DEBUG=false in production.
# 2. Upload the contents of php-hosting/ to your IONOS webspace
# (FTP/SFTP/File Manager — whatever you already use).
# 3. Open https://your-domain.com/api/v1/health in a browser.
# The first request creates the schema and the admin user automatically.
# Expected: {"ok":true,"version":"0.4.0","db":"connected"}
# 4. Visit https://your-domain.com/dashboard/ → sign in with the
# ADMIN_EMAIL / ADMIN_PASSWORD from config.php → create a project →
# copy the API key once (shown only at creation).Full IONOS-specific instructions (panel screenshots, mod_rewrite checks,
demo-mode walkthrough, troubleshooting): php-hosting/SETUP.md.
QuietMetrix/
├── quietmetrix-sdk/ # KMP SDK (7 targets)
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/ # Shared business logic
│ ├── commonTest/ # Shared unit tests
│ ├── androidMain/ # Android-specific (foreground/background callbacks, etc.)
│ ├── jvmMain/ # JVM desktop
│ ├── iosMain/ # iOS (Darwin)
│ ├── macosMain/ # macOS (Darwin)
│ ├── linuxMain/ # Linux (curl)
│ ├── mingwMain/ # Windows (WinHttp)
│ └── wasmJsMain/ # Web / Wasm
│
├── servers/
│ └── ktor/ # Ktor + PostgreSQL server
│ ├── build.gradle.kts
│ ├── Dockerfile
│ ├── migrations/ # Flyway SQL migrations
│ └── src/main/kotlin/.../
│ ├── Application.kt # Entry point
│ ├── config/ # AppConfig, DiModule (Koin)
│ ├── domain/ # Project, User, Plan, ProjectMember, counter DTOs
│ ├── counters/ # CounterRegistry, CounterIngestProcessor, analyzers
│ ├── persistence/ # Exposed tables + repositories
│ ├── plugins/ # CORS, Monitoring, Security, RateLimiting
│ ├── ratelimit/ # RateLimiter, QuotaEnforcer (unlimited self-host)
│ └── routes/ # Counters, Auth, Project, Dashboard, Funnels
│
├── php-hosting/ # PHP + MySQL server (flat IONOS / shared-host variant)
│ ├── index.php # Front controller
│ ├── .htaccess # Apache rewrites + Authorization preservation
│ ├── config.example.php # Template config (commit-safe)
│ ├── config.php # Local config — gitignored
│ ├── schema.sql # MySQL schema, applied on first run
│ ├── SETUP.md # IONOS step-by-step install guide
│ ├── src/
│ │ ├── helpers.php
│ │ ├── db.php
│ │ ├── jwt.php # Pure-PHP HS256 JWT
│ │ ├── auth.php # Bearer + api-key validators
│ │ ├── bootstrap.php # First-run schema + admin auto-create, in-place upgrades
│ │ └── routes/ # auth, projects, track, dashboard, demo, funnels
│ ├── tests/ # Plain PHP assertion scripts, run individually (no framework)
│ └── dashboard/ # Static HTML+JS+CSS dashboard (no build step)
│ ├── index.html
│ ├── css/style.css
│ └── js/{api,app}.js
│
├── docker-compose.yml # One-command stack: Postgres + migrate + Ktor
├── docker/
│ ├── .env.example # Copy to repo-root .env
│ └── caddy/Caddyfile # Optional TLS reverse proxy for production
│
├── docs/ # MkDocs documentation site
│ ├── mkdocs.yml
│ ├── index.md
│ ├── api-reference.md
│ ├── openapi.yaml # Shared API contract (OpenAPI 3.1)
│ ├── sdk/ # Platform-specific SDK guides
│ ├── self-hosting/ # Docker, PHP, upgrade, backup
│ └── operations/ # Rate limits, GDPR, security
│
├── samples/
│ ├── android/ # Android sample app
│ ├── desktop-jvm/ # JVM desktop sample
│ ├── ios/ # iOS sample (Swift)
│ └── web/index.html # Web sample
│
├── tools/
│ ├── contract-tests/ # Postman collection (Newman)
│ ├── load/ # k6 load test script
│ └── e2e/ # End-to-end test runner
│
├── .github/workflows/ # CI workflows
│ ├── ci.yml # Build + test
│ ├── docker.yml # Docker image publish
│ ├── publish-sdk.yml # SDK publish
│ └── docs.yml # Docs deploy
│
├── gradle/
│ └── libs.versions.toml # Version catalog (single source of truth)
├── build.gradle.kts # Root Gradle config
├── settings.gradle.kts # Module includes
├── gradle.properties
├── LICENSE
└── README.md
HTTPS (X-QM-Api-Key)
┌──────────────────┐ ───────────────────────────► ┌──────────────────────────┐
│ KMP SDK │ │ Ktor Server (JVM) │
│ • Android │ │ • POST /api/v1/counters │
│ • iOS │ │ • JWT auth + rate limit │
│ • macOS │ │ • Postgres 16 │
│ • Windows │ │ • Flyway migrations │
│ • Linux │ ◄─────────────────────────── │ • Serves the dashboard │
│ • Web (Wasm) │ 202 Accepted {ok, accepted, └──────────────────────────┘
│ • JVM Desktop │ quarantined}
│ │ ▲
│ • On-device │ │ same API contract
│ counter │ ▼
│ recorder │ ┌──────────────────────────┐
│ • Consent gate │ │ PHP Server (php-hosting)│
│ • Periodic flush │ │ • Same routes & schemas │
└──────────────────┘ │ • MySQL 8 │
│ • schema.sql auto-apply │
│ • Synchronous upserts │
└──────────────────────────┘
Counter flow — the device does the analysis; the server only ever receives counters:
trackEvent/trackScreen/on-device funnel and session tracking each record a
(metric, dims) -> n delta locally (see internal/counters/MetricRecorder) — no raw event
or per-session trail is built anywhere.CounterFlusher drains the recorder every flushIntervalMs (default 30s) and POSTs the
batch to /api/v1/counters. Fire-and-forget: a failed send simply drops that batch rather
than queuing for retry (unlike the old event queue, pending counters are in-memory only).CounterRegistry.kt / counterRegistry.php) and upserts it into the counters table —
n = n + delta, devices = devices + (1 if first flush of this cell today else 0).
Anything that fails validation or a per-metric cardinality cap is quarantined instead.k distinct devices have contributed to it (k-anonymity; default 5).# Clone
git clone https://github.com/SobuuMedia/QuietMetrix.git
cd QuietMetrix
# Prepare environment variables
cp docker/.env.example .env
# Edit .env and set strong values for QM_DB_PASSWORD and QM_JWT_SECRET
# Start everything: Postgres, Flyway migrations, dashboard build, Ktor server
docker compose up --build
# Check health
curl http://localhost:8080/api/v1/health
# → {"ok":true,"version":"0.4.0"}
# Create admin user (bcrypt hash cost 12)
# Generate hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'password', bcrypt.gensalt(12)).decode())"
docker compose exec postgres psql -U quietmetrix -d quietmetrix -c \
"INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '\$2a\$12\$HASHED_VALUE');"
# Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"your-password"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Create a project
curl -X POST http://localhost:8080/api/v1/projects \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name":"My First Project"}'
# Response: {"api_key":"qm_ak_abc123...",
# "message":"Project created. Store it securely — it will not be shown again."}
# Send a test counter batch
curl -X POST http://localhost:8080/api/v1/counters \
-H "Content-Type: application/json" \
-H "X-QM-Api-Key: qm_ak_abc123..." \
-d '{"day":"2026-09-04","counters":[{"m":"event","d":{"name":"page_view"},"n":1,"u":1}]}'
# → 202 {"ok":true,"accepted":1,"quarantined":0}| Service | Image | Port | Purpose |
|---|---|---|---|
ktor |
Built from servers/ktor/Dockerfile
|
8080 | Analytics API server |
postgres |
postgres:16-alpine |
5432 | Database |
caddy |
caddy:2-alpine |
80, 443 | Reverse proxy + TLS |
docker/.env.example to docker/.env and fill in strong passwordsQM_JWT_SECRET to a random 64-character stringQM_DB_PASSWORD
QM_TRUSTED_PROXIES if running behind a reverse proxy / CDNdocker/caddy/Caddyfile for TLSdocker compose exec postgres pg_dump ...)latest with specific versions)The flat, no-framework variant in php-hosting/ — no Composer, no Docker, no shell access
required. Everything (API + static dashboard) uploads as one directory to your web root.
pdo_mysql, json, mbstring, ctype
mod_rewrite (or Nginx with equivalent config)# 1. Configure locally
cp php-hosting/config.example.php php-hosting/config.php
# Edit config.php (plain PHP define()s, not env vars):
# DB_HOST, DB_NAME, DB_USER, DB_PASS
# ADMIN_EMAIL, ADMIN_PASSWORD — creates the first admin user automatically
# JWT_SECRET — generate with: openssl rand -hex 32
# Keep DEBUG=false in production.
# 2. Upload the entire contents of php-hosting/ to your web root
# (FTP/SFTP/File Manager — whatever your host provides). config.php,
# schema.sql, and *.md are blocked from direct web access by .htaccess.
# 3. Open https://your-domain.com/api/v1/health in a browser.
# The first request applies schema.sql and creates the admin user —
# no migration tool, no cron step, no manual SQL required.
# 4. Visit https://your-domain.com/dashboard/ and sign in with
# ADMIN_EMAIL / ADMIN_PASSWORD from config.php, then create a project.Upgrading later is the same: re-upload the changed files. schema.sql is idempotent, and
bootstrap.php's addColumnIfMissing/createTableIfMissing apply any new columns/tables to
an already-installed database automatically on the next request — no migration command to run.
config.php, schema.sql, and *.md are denied direct web access via .htaccess — verify
this is in effect on your host after upload (curl https://your-domain.com/config.php should
403/404, not return PHP source).DEBUG=false in production — this disables the /api/v1/_demo/* endpoints and the
dashboard's "Use demo data" toggle.Full IONOS-specific instructions (panel screenshots, mod_rewrite checks, troubleshooting):
php-hosting/SETUP.md.
| Variable | Default | Description |
|---|---|---|
QM_PROFILE |
selfhost |
Deployment profile (self-hosted, unlimited) |
QM_DB_URL |
jdbc:postgresql://localhost:5432/quietmetrix |
JDBC URL |
QM_DB_USER |
quietmetrix |
DB username |
QM_DB_PASSWORD |
quietmetrix |
DB password |
QM_DB_POOL_SIZE |
10 |
HikariCP connection pool size |
QM_CORS_ALLOWED_ORIGINS |
(required) | Comma-separated origins allowed to call the API from a browser (e.g. the dashboard origin) |
QM_JWT_SECRET |
change-me... |
HMAC256 signing secret |
QM_JWT_ISSUER |
quietmetrix |
JWT issuer claim |
QM_JWT_AUDIENCE |
quietmetrix-api |
JWT audience claim |
QM_SESSION_TTL_HOURS |
2 |
Access token lifetime |
QM_TRUSTED_PROXIES |
(empty) | Comma-separated trusted proxy IPs for X-Forwarded-For
|
QM_RATE_LIMIT_ENABLED |
false (selfhost) / true (cloud) |
Enable per-project rate limiting |
QM_RATE_LIMIT_RPS |
10 |
Request per second limit |
QM_RATE_LIMIT_BURST |
60 |
Burst per minute |
QM_INGEST_IP_ENABLED |
true |
Per-IP ingest throttling (abuse defense for the publishable key) |
QM_INGEST_IP_RPS |
5 |
Per-IP requests/second on /counters
|
QM_INGEST_IP_BURST |
60 |
Per-IP burst tokens |
Not environment variables — plain PHP define() constants in config.php (copied from
config.example.php, gitignored, blocked from direct web access):
| Constant | Default | Description |
|---|---|---|
DB_HOST / DB_NAME / DB_USER / DB_PASS
|
localhost / quietmetrix / — / — |
MySQL connection |
ADMIN_EMAIL / ADMIN_PASSWORD
|
— | First admin user, auto-created on first request; no effect once it already exists |
JWT_SECRET |
— | HMAC256 signing secret — generate with openssl rand -hex 32
|
JWT_EXPIRY_HOURS |
2 |
Access token lifetime |
JWT_REFRESH_EXPIRY_DAYS |
2 |
Refresh token lifetime — must match the Ktor backend |
ALLOWED_ORIGIN |
* |
CORS origin allowed to call the API from a browser |
CSP_CONNECT_SRC |
'self' |
CSP connect-src directive |
DEBUG |
false |
true enables verbose errors + demo-data endpoints; always false in production |
RATE_LIMIT_ENABLED / RATE_LIMIT_RPS / RATE_LIMIT_BURST
|
true / 10 / 60
|
Per-API-key rate limiting on /counters
|
INGEST_IP_ENABLED / INGEST_IP_RPS / INGEST_IP_BURST
|
true / 5 / 60
|
Per-IP ingest throttling (abuse defense) |
TRUSTED_PROXIES |
[] |
IPs whose X-Forwarded-For is trusted |
See php-hosting/config.example.php for the full, commented list.
Migrations run automatically on Ktor server startup. Files live in servers/ktor/migrations/:
V1__init.sql — users, projects, events_inbox, events, event_counts_daily, usage_counters
V2__project_members.sql — project_members table, deleted_at on projects, plan_id on users
...
V18__counters.sql — counters, counters_quarantine (aggregate-only ingest)
V19__drop_events.sql — drops events, events_inbox, events_quarantine, ingest_audit,
install_meta, sessions, and the
install_salt/analytics_salt/strict_schema/allowed_events columns on
projects — the raw event-stream ingest path, /track, is gone
To run manually:
cd servers/ktor
./gradlew flywayMigrateNo migration tool: schema.sql runs automatically on the first request after install (via
src/bootstrap.php), and is idempotent — safe to leave in place on every request. Later schema
changes (new columns/tables) are applied the same way via addColumnIfMissing/
createTableIfMissing in bootstrap.php, so upgrading is just re-uploading changed files.
Create the admin user:
users table with a bcrypt hash:
INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '$2a$12$...');python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(12)).decode())"ADMIN_EMAIL / ADMIN_PASSWORD in config.php before the first
request — the admin user is created automatically; no SQL needed.Login — POST /api/v1/auth/login with email/password to get a JWT token
Create a project — POST /api/v1/projects with the JWT token
Save the API key — The response includes api_key. Store it securely — it is shown only once.
Configure the SDK — Set apiKey in QuietMetrixConfig to the API key from step 4
// settings.gradle.kts
repositories { mavenCentral() }
// module build.gradle.kts
dependencies {
implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
}Add the QuietMetrix.xcframework produced by ./gradlew :quietmetrix-sdk:assembleXCFramework to your Xcode project, or include the KMP shared module directly.
import QuietMetrix
let config = QuietMetrixConfig(
storageKeyPrefix: "myapp_",
trackingEndpoint: "https://your-server.com/api/v1",
apiKey: "qm_ak_..."
)
QuietMetrix.shared.initialize(config: config)
QuietMetrix.shared.trackEvent(event: "page_view", screen: "home")// build.gradle.kts
dependencies {
implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
}// Main.kt
import com.quietmetrix.analytics.*
fun main() {
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_..."
))
trackEvent("app_start", screen = "main")
}Published to npm as @sobuumedia/quietmetrix-sdk — framework-agnostic (Vue, React, Svelte, plain JS),
with bundled TypeScript types. See docs/sdk/web.md for the full guide.
npm install @sobuumedia/quietmetrix-sdkimport { init, trackEvent } from "@sobuumedia/quietmetrix-sdk";
init({
storageKeyPrefix: "myapp_",
trackingEndpoint: "https://your-server.com/api/v1",
apiKey: "qm_ak_...",
});
trackEvent("page_view", { screen: "home" });A CDN /
window.QuietMetrixglobal build for<script>-tag usage is planned for a future release.
For Kotlin Multiplatform consumers, the wasmJs browser target is also available via the Gradle
dependency io.github.sobuumedia:quietmetrix-sdk.
Use the Ktor Client engine for each platform. The SDK auto-selects:
Add the SDK as a Gradle dependency — no platform-specific config needed.
data class QuietMetrixConfig(
val storageKeyPrefix: String, // Unique prefix per app (e.g. "myapp_")
val trackingEndpoint: String? = null, // Server URL (null = no network sending)
val apiKey: String? = null, // Project API key
val flushIntervalMs: Long = 30_000L, // Counter-flush interval (ms)
val autoTrackInitialPageView: Boolean = true, // Fire page_view on init?
val trackingAllowedByDefault: Boolean = false, // Track before consent?
val userAgent: String? = null, // Custom User-Agent header
val funnels: List<Funnel> = emptyList(), // Auto-registered funnel definitions
)// Simple event
trackEvent("page_view")
// With screen context
trackEvent("page_view", screen = "home")
// With custom properties
trackEvent("button_click", screen = "settings", props = mapOf(
"button_id" to "save_btn",
"section" to "profile",
"duration_ms" to 150
))
// With session ID (useful for session-based analytics)
// The SDK auto-generates SIDs on web; on other platforms you can provide one
trackEvent("page_view", screen = "home")
// Events are auto-flushed every flushIntervalMs (default 30s)
// and on connectivity restore (from offline to online)QuietMetrix has three consent layers:
// Layer 1: Cookie consent (GDPR)
setCookieConsent(true) // User accepted cookies
setCookieConsent(false) // User declined cookies
val hasConsent = hasCookieConsent()
// Layer 2: Analytics kill switch
setAnalyticsEnabled(true) // Analytics on
setAnalyticsEnabled(false) // Analytics off (purges queue, stops all tracking)
val enabled = isAnalyticsEnabled
// Layer 3: Tracking allowed (combines consent + enabled)
val allowed = isTrackingAllowed()How it works:
trackingAllowedByDefault = true (default): Tracks until user declinestrackingAllowedByDefault = false: Blocks tracking until user opts inhasCookieConsent() == false && trackingAllowedByDefault == true → Tracks anyway (implied consent)hasCookieConsent() == false && trackingAllowedByDefault == false → BlocksisAnalyticsEnabled == false → Blocks regardless of consentGate.shouldTrack() before any event is enqueuedDeclare an ordered list of steps — each one an event you already track — and QuietMetrix analyzes drop-off, breakdown, and time-to-convert automatically, with no dashboard setup:
val signupFunnel = Funnel(
key = "signup",
name = "Signup",
steps = listOf(
FunnelStep(key = "view", event = "screen_view", screen = "signup"),
FunnelStep(key = "submit", event = "signup_submitted"),
),
windowSeconds = 7L * 24 * 3600, // default: 7 days
)
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_your_api_key",
funnels = listOf(signupFunnel),
))The SDK auto-registers the funnel with the server on init (fingerprint-gated, so a
launch registers nothing when the definition hasn't changed) and matches it against
your existing event stream — no code change needed at tracking call sites. Funnels are
counted per install (a per-project, salted, non-reversible hash of a device-local id)
— never by user ID, matching QuietMetrix's no-PII design.
See the Funnels developer guide for matching rules, the dashboard editing/locking workflow, and a worked example of the results payload.
trackEvent/trackScreen and on-device funnel/session tracking each record a
(metric, dims) -> n delta locally, in memory (internal/counters/MetricRecorder) — no
network call happens inline, and no raw event or per-session trail is ever built.
// Recorded locally — no network call happens inline
trackEvent("page_view")
// Force an immediate flush of the pending counters (suspend function)
QuietMetrix.flush()
// Flush interval is configurable
QuietMetrixConfig(flushIntervalMs = 10_000L) // Flush every 10 secondsPending counters are in-memory only — there is no offline queue, no persistence across a
process restart, and no connectivity monitoring or retry backoff. CounterFlusher simply
POSTs whatever is pending to /api/v1/counters every flushIntervalMs; a failed send (offline,
5xx, timeout) drops that batch rather than queuing it for retry. An app killed between flushes
loses whatever was recorded since the last successful one.
The SDK auto-detects device context per platform:
| Field | Android | iOS | JVM | Web |
|---|---|---|---|---|
platform |
android |
ios |
jvm |
wasmJs |
language |
Locale.getDefault() |
Locale.current |
user.language |
navigator.language |
screenWidth / screenHeight
|
DisplayMetrics | UIScreen | null | window.innerWidth/Height |
userAgent |
null | null | os.name |
navigator.userAgent |
The authoritative API spec is docs/openapi.yaml (OpenAPI 3.1). Both backends implement it identically.
API key — For tracking endpoints. Passed as X-QM-Api-Key header. The API key is publishable: write-only and project-scoped, so it is safe to embed in client code (browser bundles, mobile apps, F-Droid builds). It cannot read analytics or access admin routes — those require a Bearer token. See Threat model & abuse defense. Rotate it via POST /api/v1/projects/{id}/regenerate-key (Bearer-auth) when a published key is abused.
X-QM-Api-Key: qm_ak_abc123def456ghi789
Bearer Token — For admin/dashboard endpoints. Obtained via POST /api/v1/auth/login.
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Auth: API key
Request:
{
"sdk": { "platform": "android", "version": "0.5.0" },
"app": { "version": "2.4.0", "country": "US" },
"day": "2026-09-04",
"counters": [
{ "m": "screen_transition", "d": { "from": "Library", "to": "BookDetail" }, "n": 3, "u": 1 },
{ "m": "event", "d": { "name": "page_view" }, "n": 5, "u": 0 }
]
}Response: 202 Accepted
{ "ok": true, "accepted": 2, "quarantined": 0 }day is the device's local date; the server clamps it to [today-2, today] UTC. Each item's
m (metric) and d (dims) are validated against a fixed per-metric registry — an unknown
metric or undeclared dimension is quarantined rather than rejecting the whole batch.
No auth required.
Response: 200 OK
{ "ok": true, "version": "0.1.0" }Request:
{ "email": "admin@example.com", "password": "your-password" }Response: 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_1",
"email": "admin@example.com",
"createdAt": "2026-04-30T12:00:00Z"
}
}Auth: Bearer Token or a PAT with analytics:read
Query params: from, to (required, ISO 8601), granularity (day, default)
Auth: Bearer Token or a PAT with analytics:read
Screen-to-screen navigation flow, session counts/average duration, and cohort retention —
all read from counters. Query params: days (1–90, default 30).
Auth: Bearer Token
Request: { "name": "My App" } (required, non-blank, max 255 chars)
Response: 201 Created
{
"api_key": "qm_ak_abc123...",
"message": "Project created. Store it securely — it will not be shown again."
}Auth: Bearer Token
Query params: limit, offset, owner_only (bool)
Returns projects where the authenticated user is owner or member. Keys are masked as "***".
Auth: Bearer Token, must be owner or member. Keys are masked.
Auth: Bearer Token, must be owner or admin
Request: { "name": "New Name" }
Auth: Bearer Token, must be owner
Response: { "deleted": true, "project_id": "proj_42" }
Events are preserved. Project is hidden from listings.
Auth: Bearer Token, must be a member
Response:
{
"members": [
{ "id": "1", "project_id": "proj_42", "user_id": "3", "email": "viewer@example.com", "role": "viewer", "created_at": "..." }
],
"total": 1
}Auth: Bearer Token, must be owner or admin
Request: { "email": "colleague@example.com", "role": "viewer" }
Role must be viewer or admin (not owner). Returns 201 Created.
Auth: Bearer Token, must be owner or admin. Cannot remove self if last admin.
See the Funnels developer guide for concepts, matching rules, and a worked example of the results payload.
GET /api/v1/projects/:id/funnels — Bearer auth. List active funnels.POST /api/v1/projects/:id/funnels — Bearer auth, admin/developer role. Create a funnel.PATCH /api/v1/projects/:id/funnels/:funnelKey — Bearer auth, admin/developer role. Update a funnel; locks it against further SDK auto-registration.DELETE /api/v1/projects/:id/funnels/:funnelKey — Bearer auth, admin/developer role. Archive a funnel; unlocks the key for SDK re-registration.GET /api/v1/projects/:id/funnels/:funnelKey/results — Bearer auth. Query params: range (seconds, default 7 days), breakdown (country|platform|device_class|language), trend (1 to include a daily trend series).POST /api/v1/funnels/register — API key auth. Upserts funnel definitions declared by the SDK; a no-op for any funnel already locked by a dashboard edit.QuietMetrix is self-hosted and unlimited: create as many projects and ingest as many events as your database can hold. No plans, no quotas, no billing.
Each project has its own API key. You can create multiple projects and use different API keys for different apps:
// App 1
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "app1_",
apiKey = "qm_ak_app1_key..."
))
// App 2 — same SDK, different key
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "app2_",
apiKey = "qm_ak_app2_key..."
))Each project appears separately in the dashboard with its own events, aggregates, and members.
You can also have an AI coding agent do the above for you — tell it "create a QuietMetrix
project at https://your-server.com and wire it into this app" and it creates the project via
the CLI or MCP server and writes the key into your SDK config. Requires a one-time personal
access token (qm_pat_…, not the publishable qm_ak_… API key above — see why they're
handled so differently). Full walkthrough: Agent-driven setup.
| Role | Permissions |
|---|---|
| Owner | Full control. Can delete project, manage members, update settings. Always the original creator. |
| Admin | Can invite/remove members (except other admins/owners), update project name/settings. Cannot delete the project. |
| Viewer | Read-only. Can view dashboard data but cannot modify anything. |
Only owners and admins can manage members. Viewers can only see data.
Projects are soft-deleted — deleted_at is set, events are preserved. The project disappears from listings and API keys stop working. Only the owner can delete a project.
gradlew)ANDROID_HOME setnpm install -g newman) for contract tests# Compile all targets
./gradlew :quietmetrix-sdk:compileKotlinJvm
./gradlew :quietmetrix-sdk:compileDebugKotlinAndroid
# Run SDK tests (JVM)
./gradlew :quietmetrix-sdk:jvmTest
# Publish to local Maven (~/.m2)
./gradlew :quietmetrix-sdk:publishToMavenLocal
# Build XCFramework for iOS
./gradlew :quietmetrix-sdk:assembleXCFramework# Start Postgres (or use Docker)
docker run -d --name qm-postgres \
-e POSTGRES_DB=quietmetrix -e POSTGRES_USER=quietmetrix -e POSTGRES_PASSWORD=changeme \
-p 5432:5432 postgres:16-alpine
# Run the server
export QM_DB_PASSWORD=changeme
export QM_JWT_SECRET=$(openssl rand -hex 32)
export QM_CORS_ALLOWED_ORIGINS=http://localhost:8080
./gradlew :servers:ktor:run
# Or from IntelliJ: run ApplicationKt.main()The server starts on http://localhost:8080. Flyway migrations run automatically on first boot.
cd php-hosting
# Start MySQL (or use Docker)
docker run -d --name qm-mysql \
-e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=quietmetrix \
-p 3306:3306 mysql:8.0
# Configure
cp config.example.php config.php
# Edit config.php: DB_HOST/DB_NAME/DB_USER/DB_PASS, ADMIN_EMAIL, ADMIN_PASSWORD, JWT_SECRET
# Start PHP's built-in server — schema.sql and the admin user are applied
# automatically on the first request, no separate migration step
php -S localhost:8081# All Ktor server tests (JUnit 5 + Kotest)
./gradlew :servers:ktor:test
# All SDK tests (JVM target)
./gradlew :quietmetrix-sdk:jvmTest
# Specific test class
./gradlew :servers:ktor:test --tests "*RouteIntegrationTest*"
./gradlew :servers:ktor:test --tests "*ProjectRoutesIntegrationTest*"
# PHP tests — plain scripts, no framework; each is self-contained and exits
# 0 (pass) or 1 (fail)
cd php-hosting
php -l index.php && php -l src/*.php && php -l src/routes/*.php # syntax check
for f in tests/*.php; do php "$f" || echo "FAILED: $f"; donecp docker/.env.example .env # set QM_DB_PASSWORD, QM_JWT_SECRET
docker compose up --buildThree services run in order: postgres (16-alpine, healthchecked) →
migrate (Flyway applies servers/ktor/migrations/) → ktor (builds the
Wasm dashboard and fat jar, then serves the API + dashboard on port 8080).
For production TLS, put the Ktor service behind the reverse proxy in
docker/caddy/Caddyfile.
# Ktor server
docker build -f servers/ktor/Dockerfile -t quietmetrix-ktor:latest .php-hosting/ has no Dockerfile — it's designed for shared hosting (upload the directory as
static files + PHP), not a container. See PHP backend, IONOS / shared hosting above.
| Module | Framework | Count | Location |
|---|---|---|---|
| SDK | kotlin.test | 20+ | quietmetrix-sdk/src/commonTest/ |
| Ktor server | Kotest + JUnit 5 | 36 | servers/ktor/src/test/ |
| PHP server (php-hosting) | Plain PHP assertion scripts, no framework | 6 |
php-hosting/tests/, run via php <file>.php
|
Ktor integration tests use H2 in-memory database and testApplication:
RouteIntegrationTest — Health, track, auth endpointsProjectRoutesIntegrationTest — CRUD, limits, membersUsing Newman (Postman CLI):
cd tools/contract-tests
newman run quietmetrix.postman_collection.json --env-var base_url=http://localhost:8080Run against both backends to verify API contract compliance.
Using k6:
cd tools/load
k6 run track.k6.jsSimulates 500 RPS against the track endpoint.
./gradlew e2eTest
# or
cd tools/e2e && bash run-e2e.shSpins up both backends in Docker, runs contract tests, tears down.
GitHub Actions workflows in .github/workflows/:
| Workflow | Trigger | Actions |
|---|---|---|
ci.yml |
Push to main, PRs | Repo-hygiene check, SDK tests (iOS simulator target), Ktor tests, PHP syntax lint + health-check smoke test (php-hosting/) |
docker.yml |
Tags (v*) |
Build & push the Ktor Docker image to GHCR |
publish-sdk.yml |
Tags (v*) |
Publish SDK to Maven Local/Central, create GitHub Release |
docs.yml |
Push to main | Build and deploy MkDocs site |
docker.yml currently also has a build-php job targeting servers/php/Dockerfile, which no
longer exists in the repo — that job is broken (see Project Structure).
docs/operations/rate-limits.md.docs/operations/gdpr.md.docs/operations/security.md.pg_dump for Postgres, mysqldump for MySQL. See docs/self-hosting/backup.md.docker compose pull && up -d (Ktor), or re-upload changed files to php-hosting/ — schema.sql and in-place column/table additions apply automatically on the next request, no migration command needed. See docs/self-hosting/upgrade.md.MIT — see LICENSE.
All code is MIT-licensed. No feature gating, no closed-source components. You can fork, modify, and redistribute freely. Commercial use is explicitly permitted.
Get started: docker compose up --build → curl localhost:8080/api/v1/health
Privacy-respecting analytics platform — open source, self-hostable. A Ktor + PostgreSQL server (Docker), a PHP + MySQL server (shared hosting), and a Kotlin Multiplatform SDK for 7 targets.
Everything is MIT-licensed. No vendor lock-in.
QuietMetrix is a full-stack analytics platform. It gives you:
| Component | Technology | Purpose |
|---|---|---|
| SDK | Kotlin Multiplatform | Client library for tracking events |
| Ktor server | Kotlin + Ktor 3 + PostgreSQL | Docker-native backend |
| PHP server | PHP 8.2+ + MySQL | Shared-hosting compatible backend |
SDK targets: Android, iOS, macOS, Windows (MinGW), Linux, Web (Wasm/JS), JVM
Both backends implement the same OpenAPI 3.1 contract (docs/openapi.yaml). Any SDK or HTTP client works against either backend — swap backends without changing a single line of SDK code.
Key design decisions:
(metric, dims) -> n counters itself and sends
only those — no raw event or per-session trail ever leaves it, and none is stored// 1. Add dependency (KMP project, build.gradle.kts)
implementation(project(":quietmetrix-sdk"))
// or from Maven Central when published:
// implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
// 2. Initialize once at app startup
import com.quietmetrix.analytics.*
QuietMetrix.init(
QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_your_api_key_here",
)
)
// 3. Track events
trackEvent("page_view", screen = "home")
trackEvent("button_click", screen = "settings", props = mapOf("button_id" to "save"))
// 4. Consent (GDPR / cookie law)
setCookieConsent(true)
setAnalyticsEnabled(true)
// 5. Force flush (e.g., before app goes to background)
suspend fun onPause() { QuietMetrix.flush() }Want to track conversion through a multi-step flow (signup, checkout, onboarding)? See Funnels below — declare the steps once and the dashboard analyzes drop-off, breakdown, and time-to-convert automatically.
One command brings up Postgres, runs migrations, builds the dashboard, and starts the Ktor server:
git clone https://github.com/SobuuMedia/QuietMetrix.git
cd QuietMetrix
cp docker/.env.example .env # set QM_DB_PASSWORD, QM_JWT_SECRET
docker compose up --build
# API: http://localhost:8080/api/v1/health → {"ok":true}
# Dashboard: http://localhost:8080/dashboard/
# Create the admin user via direct DB insert (see detailed setup below),
# then log in and create a project.The fastest path for IONOS, Plesk, or any cPanel host — zero Composer, zero SSH required. Drop-in flat layout that bundles a static dashboard.
# 1. Edit php-hosting/config.php — fill in DB creds, ADMIN_EMAIL/PASSWORD,
# JWT_SECRET (openssl rand -hex 32). Keep DEBUG=false in production.
# 2. Upload the contents of php-hosting/ to your IONOS webspace
# (FTP/SFTP/File Manager — whatever you already use).
# 3. Open https://your-domain.com/api/v1/health in a browser.
# The first request creates the schema and the admin user automatically.
# Expected: {"ok":true,"version":"0.4.0","db":"connected"}
# 4. Visit https://your-domain.com/dashboard/ → sign in with the
# ADMIN_EMAIL / ADMIN_PASSWORD from config.php → create a project →
# copy the API key once (shown only at creation).Full IONOS-specific instructions (panel screenshots, mod_rewrite checks,
demo-mode walkthrough, troubleshooting): php-hosting/SETUP.md.
QuietMetrix/
├── quietmetrix-sdk/ # KMP SDK (7 targets)
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/ # Shared business logic
│ ├── commonTest/ # Shared unit tests
│ ├── androidMain/ # Android-specific (foreground/background callbacks, etc.)
│ ├── jvmMain/ # JVM desktop
│ ├── iosMain/ # iOS (Darwin)
│ ├── macosMain/ # macOS (Darwin)
│ ├── linuxMain/ # Linux (curl)
│ ├── mingwMain/ # Windows (WinHttp)
│ └── wasmJsMain/ # Web / Wasm
│
├── servers/
│ └── ktor/ # Ktor + PostgreSQL server
│ ├── build.gradle.kts
│ ├── Dockerfile
│ ├── migrations/ # Flyway SQL migrations
│ └── src/main/kotlin/.../
│ ├── Application.kt # Entry point
│ ├── config/ # AppConfig, DiModule (Koin)
│ ├── domain/ # Project, User, Plan, ProjectMember, counter DTOs
│ ├── counters/ # CounterRegistry, CounterIngestProcessor, analyzers
│ ├── persistence/ # Exposed tables + repositories
│ ├── plugins/ # CORS, Monitoring, Security, RateLimiting
│ ├── ratelimit/ # RateLimiter, QuotaEnforcer (unlimited self-host)
│ └── routes/ # Counters, Auth, Project, Dashboard, Funnels
│
├── php-hosting/ # PHP + MySQL server (flat IONOS / shared-host variant)
│ ├── index.php # Front controller
│ ├── .htaccess # Apache rewrites + Authorization preservation
│ ├── config.example.php # Template config (commit-safe)
│ ├── config.php # Local config — gitignored
│ ├── schema.sql # MySQL schema, applied on first run
│ ├── SETUP.md # IONOS step-by-step install guide
│ ├── src/
│ │ ├── helpers.php
│ │ ├── db.php
│ │ ├── jwt.php # Pure-PHP HS256 JWT
│ │ ├── auth.php # Bearer + api-key validators
│ │ ├── bootstrap.php # First-run schema + admin auto-create, in-place upgrades
│ │ └── routes/ # auth, projects, track, dashboard, demo, funnels
│ ├── tests/ # Plain PHP assertion scripts, run individually (no framework)
│ └── dashboard/ # Static HTML+JS+CSS dashboard (no build step)
│ ├── index.html
│ ├── css/style.css
│ └── js/{api,app}.js
│
├── docker-compose.yml # One-command stack: Postgres + migrate + Ktor
├── docker/
│ ├── .env.example # Copy to repo-root .env
│ └── caddy/Caddyfile # Optional TLS reverse proxy for production
│
├── docs/ # MkDocs documentation site
│ ├── mkdocs.yml
│ ├── index.md
│ ├── api-reference.md
│ ├── openapi.yaml # Shared API contract (OpenAPI 3.1)
│ ├── sdk/ # Platform-specific SDK guides
│ ├── self-hosting/ # Docker, PHP, upgrade, backup
│ └── operations/ # Rate limits, GDPR, security
│
├── samples/
│ ├── android/ # Android sample app
│ ├── desktop-jvm/ # JVM desktop sample
│ ├── ios/ # iOS sample (Swift)
│ └── web/index.html # Web sample
│
├── tools/
│ ├── contract-tests/ # Postman collection (Newman)
│ ├── load/ # k6 load test script
│ └── e2e/ # End-to-end test runner
│
├── .github/workflows/ # CI workflows
│ ├── ci.yml # Build + test
│ ├── docker.yml # Docker image publish
│ ├── publish-sdk.yml # SDK publish
│ └── docs.yml # Docs deploy
│
├── gradle/
│ └── libs.versions.toml # Version catalog (single source of truth)
├── build.gradle.kts # Root Gradle config
├── settings.gradle.kts # Module includes
├── gradle.properties
├── LICENSE
└── README.md
HTTPS (X-QM-Api-Key)
┌──────────────────┐ ───────────────────────────► ┌──────────────────────────┐
│ KMP SDK │ │ Ktor Server (JVM) │
│ • Android │ │ • POST /api/v1/counters │
│ • iOS │ │ • JWT auth + rate limit │
│ • macOS │ │ • Postgres 16 │
│ • Windows │ │ • Flyway migrations │
│ • Linux │ ◄─────────────────────────── │ • Serves the dashboard │
│ • Web (Wasm) │ 202 Accepted {ok, accepted, └──────────────────────────┘
│ • JVM Desktop │ quarantined}
│ │ ▲
│ • On-device │ │ same API contract
│ counter │ ▼
│ recorder │ ┌──────────────────────────┐
│ • Consent gate │ │ PHP Server (php-hosting)│
│ • Periodic flush │ │ • Same routes & schemas │
└──────────────────┘ │ • MySQL 8 │
│ • schema.sql auto-apply │
│ • Synchronous upserts │
└──────────────────────────┘
Counter flow — the device does the analysis; the server only ever receives counters:
trackEvent/trackScreen/on-device funnel and session tracking each record a
(metric, dims) -> n delta locally (see internal/counters/MetricRecorder) — no raw event
or per-session trail is built anywhere.CounterFlusher drains the recorder every flushIntervalMs (default 30s) and POSTs the
batch to /api/v1/counters. Fire-and-forget: a failed send simply drops that batch rather
than queuing for retry (unlike the old event queue, pending counters are in-memory only).CounterRegistry.kt / counterRegistry.php) and upserts it into the counters table —
n = n + delta, devices = devices + (1 if first flush of this cell today else 0).
Anything that fails validation or a per-metric cardinality cap is quarantined instead.k distinct devices have contributed to it (k-anonymity; default 5).# Clone
git clone https://github.com/SobuuMedia/QuietMetrix.git
cd QuietMetrix
# Prepare environment variables
cp docker/.env.example .env
# Edit .env and set strong values for QM_DB_PASSWORD and QM_JWT_SECRET
# Start everything: Postgres, Flyway migrations, dashboard build, Ktor server
docker compose up --build
# Check health
curl http://localhost:8080/api/v1/health
# → {"ok":true,"version":"0.4.0"}
# Create admin user (bcrypt hash cost 12)
# Generate hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'password', bcrypt.gensalt(12)).decode())"
docker compose exec postgres psql -U quietmetrix -d quietmetrix -c \
"INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '\$2a\$12\$HASHED_VALUE');"
# Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"your-password"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Create a project
curl -X POST http://localhost:8080/api/v1/projects \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name":"My First Project"}'
# Response: {"api_key":"qm_ak_abc123...",
# "message":"Project created. Store it securely — it will not be shown again."}
# Send a test counter batch
curl -X POST http://localhost:8080/api/v1/counters \
-H "Content-Type: application/json" \
-H "X-QM-Api-Key: qm_ak_abc123..." \
-d '{"day":"2026-09-04","counters":[{"m":"event","d":{"name":"page_view"},"n":1,"u":1}]}'
# → 202 {"ok":true,"accepted":1,"quarantined":0}| Service | Image | Port | Purpose |
|---|---|---|---|
ktor |
Built from servers/ktor/Dockerfile
|
8080 | Analytics API server |
postgres |
postgres:16-alpine |
5432 | Database |
caddy |
caddy:2-alpine |
80, 443 | Reverse proxy + TLS |
docker/.env.example to docker/.env and fill in strong passwordsQM_JWT_SECRET to a random 64-character stringQM_DB_PASSWORD
QM_TRUSTED_PROXIES if running behind a reverse proxy / CDNdocker/caddy/Caddyfile for TLSdocker compose exec postgres pg_dump ...)latest with specific versions)The flat, no-framework variant in php-hosting/ — no Composer, no Docker, no shell access
required. Everything (API + static dashboard) uploads as one directory to your web root.
pdo_mysql, json, mbstring, ctype
mod_rewrite (or Nginx with equivalent config)# 1. Configure locally
cp php-hosting/config.example.php php-hosting/config.php
# Edit config.php (plain PHP define()s, not env vars):
# DB_HOST, DB_NAME, DB_USER, DB_PASS
# ADMIN_EMAIL, ADMIN_PASSWORD — creates the first admin user automatically
# JWT_SECRET — generate with: openssl rand -hex 32
# Keep DEBUG=false in production.
# 2. Upload the entire contents of php-hosting/ to your web root
# (FTP/SFTP/File Manager — whatever your host provides). config.php,
# schema.sql, and *.md are blocked from direct web access by .htaccess.
# 3. Open https://your-domain.com/api/v1/health in a browser.
# The first request applies schema.sql and creates the admin user —
# no migration tool, no cron step, no manual SQL required.
# 4. Visit https://your-domain.com/dashboard/ and sign in with
# ADMIN_EMAIL / ADMIN_PASSWORD from config.php, then create a project.Upgrading later is the same: re-upload the changed files. schema.sql is idempotent, and
bootstrap.php's addColumnIfMissing/createTableIfMissing apply any new columns/tables to
an already-installed database automatically on the next request — no migration command to run.
config.php, schema.sql, and *.md are denied direct web access via .htaccess — verify
this is in effect on your host after upload (curl https://your-domain.com/config.php should
403/404, not return PHP source).DEBUG=false in production — this disables the /api/v1/_demo/* endpoints and the
dashboard's "Use demo data" toggle.Full IONOS-specific instructions (panel screenshots, mod_rewrite checks, troubleshooting):
php-hosting/SETUP.md.
| Variable | Default | Description |
|---|---|---|
QM_PROFILE |
selfhost |
Deployment profile (self-hosted, unlimited) |
QM_DB_URL |
jdbc:postgresql://localhost:5432/quietmetrix |
JDBC URL |
QM_DB_USER |
quietmetrix |
DB username |
QM_DB_PASSWORD |
quietmetrix |
DB password |
QM_DB_POOL_SIZE |
10 |
HikariCP connection pool size |
QM_CORS_ALLOWED_ORIGINS |
(required) | Comma-separated origins allowed to call the API from a browser (e.g. the dashboard origin) |
QM_JWT_SECRET |
change-me... |
HMAC256 signing secret |
QM_JWT_ISSUER |
quietmetrix |
JWT issuer claim |
QM_JWT_AUDIENCE |
quietmetrix-api |
JWT audience claim |
QM_SESSION_TTL_HOURS |
2 |
Access token lifetime |
QM_TRUSTED_PROXIES |
(empty) | Comma-separated trusted proxy IPs for X-Forwarded-For
|
QM_RATE_LIMIT_ENABLED |
false (selfhost) / true (cloud) |
Enable per-project rate limiting |
QM_RATE_LIMIT_RPS |
10 |
Request per second limit |
QM_RATE_LIMIT_BURST |
60 |
Burst per minute |
QM_INGEST_IP_ENABLED |
true |
Per-IP ingest throttling (abuse defense for the publishable key) |
QM_INGEST_IP_RPS |
5 |
Per-IP requests/second on /counters
|
QM_INGEST_IP_BURST |
60 |
Per-IP burst tokens |
Not environment variables — plain PHP define() constants in config.php (copied from
config.example.php, gitignored, blocked from direct web access):
| Constant | Default | Description |
|---|---|---|
DB_HOST / DB_NAME / DB_USER / DB_PASS
|
localhost / quietmetrix / — / — |
MySQL connection |
ADMIN_EMAIL / ADMIN_PASSWORD
|
— | First admin user, auto-created on first request; no effect once it already exists |
JWT_SECRET |
— | HMAC256 signing secret — generate with openssl rand -hex 32
|
JWT_EXPIRY_HOURS |
2 |
Access token lifetime |
JWT_REFRESH_EXPIRY_DAYS |
2 |
Refresh token lifetime — must match the Ktor backend |
ALLOWED_ORIGIN |
* |
CORS origin allowed to call the API from a browser |
CSP_CONNECT_SRC |
'self' |
CSP connect-src directive |
DEBUG |
false |
true enables verbose errors + demo-data endpoints; always false in production |
RATE_LIMIT_ENABLED / RATE_LIMIT_RPS / RATE_LIMIT_BURST
|
true / 10 / 60
|
Per-API-key rate limiting on /counters
|
INGEST_IP_ENABLED / INGEST_IP_RPS / INGEST_IP_BURST
|
true / 5 / 60
|
Per-IP ingest throttling (abuse defense) |
TRUSTED_PROXIES |
[] |
IPs whose X-Forwarded-For is trusted |
See php-hosting/config.example.php for the full, commented list.
Migrations run automatically on Ktor server startup. Files live in servers/ktor/migrations/:
V1__init.sql — users, projects, events_inbox, events, event_counts_daily, usage_counters
V2__project_members.sql — project_members table, deleted_at on projects, plan_id on users
...
V18__counters.sql — counters, counters_quarantine (aggregate-only ingest)
V19__drop_events.sql — drops events, events_inbox, events_quarantine, ingest_audit,
install_meta, sessions, and the
install_salt/analytics_salt/strict_schema/allowed_events columns on
projects — the raw event-stream ingest path, /track, is gone
To run manually:
cd servers/ktor
./gradlew flywayMigrateNo migration tool: schema.sql runs automatically on the first request after install (via
src/bootstrap.php), and is idempotent — safe to leave in place on every request. Later schema
changes (new columns/tables) are applied the same way via addColumnIfMissing/
createTableIfMissing in bootstrap.php, so upgrading is just re-uploading changed files.
Create the admin user:
users table with a bcrypt hash:
INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '$2a$12$...');python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(12)).decode())"ADMIN_EMAIL / ADMIN_PASSWORD in config.php before the first
request — the admin user is created automatically; no SQL needed.Login — POST /api/v1/auth/login with email/password to get a JWT token
Create a project — POST /api/v1/projects with the JWT token
Save the API key — The response includes api_key. Store it securely — it is shown only once.
Configure the SDK — Set apiKey in QuietMetrixConfig to the API key from step 4
// settings.gradle.kts
repositories { mavenCentral() }
// module build.gradle.kts
dependencies {
implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
}Add the QuietMetrix.xcframework produced by ./gradlew :quietmetrix-sdk:assembleXCFramework to your Xcode project, or include the KMP shared module directly.
import QuietMetrix
let config = QuietMetrixConfig(
storageKeyPrefix: "myapp_",
trackingEndpoint: "https://your-server.com/api/v1",
apiKey: "qm_ak_..."
)
QuietMetrix.shared.initialize(config: config)
QuietMetrix.shared.trackEvent(event: "page_view", screen: "home")// build.gradle.kts
dependencies {
implementation("com.quietmetrix:quietmetrix-sdk:0.5.0")
}// Main.kt
import com.quietmetrix.analytics.*
fun main() {
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_..."
))
trackEvent("app_start", screen = "main")
}Published to npm as @sobuumedia/quietmetrix-sdk — framework-agnostic (Vue, React, Svelte, plain JS),
with bundled TypeScript types. See docs/sdk/web.md for the full guide.
npm install @sobuumedia/quietmetrix-sdkimport { init, trackEvent } from "@sobuumedia/quietmetrix-sdk";
init({
storageKeyPrefix: "myapp_",
trackingEndpoint: "https://your-server.com/api/v1",
apiKey: "qm_ak_...",
});
trackEvent("page_view", { screen: "home" });A CDN /
window.QuietMetrixglobal build for<script>-tag usage is planned for a future release.
For Kotlin Multiplatform consumers, the wasmJs browser target is also available via the Gradle
dependency io.github.sobuumedia:quietmetrix-sdk.
Use the Ktor Client engine for each platform. The SDK auto-selects:
Add the SDK as a Gradle dependency — no platform-specific config needed.
data class QuietMetrixConfig(
val storageKeyPrefix: String, // Unique prefix per app (e.g. "myapp_")
val trackingEndpoint: String? = null, // Server URL (null = no network sending)
val apiKey: String? = null, // Project API key
val flushIntervalMs: Long = 30_000L, // Counter-flush interval (ms)
val autoTrackInitialPageView: Boolean = true, // Fire page_view on init?
val trackingAllowedByDefault: Boolean = false, // Track before consent?
val userAgent: String? = null, // Custom User-Agent header
val funnels: List<Funnel> = emptyList(), // Auto-registered funnel definitions
)// Simple event
trackEvent("page_view")
// With screen context
trackEvent("page_view", screen = "home")
// With custom properties
trackEvent("button_click", screen = "settings", props = mapOf(
"button_id" to "save_btn",
"section" to "profile",
"duration_ms" to 150
))
// With session ID (useful for session-based analytics)
// The SDK auto-generates SIDs on web; on other platforms you can provide one
trackEvent("page_view", screen = "home")
// Events are auto-flushed every flushIntervalMs (default 30s)
// and on connectivity restore (from offline to online)QuietMetrix has three consent layers:
// Layer 1: Cookie consent (GDPR)
setCookieConsent(true) // User accepted cookies
setCookieConsent(false) // User declined cookies
val hasConsent = hasCookieConsent()
// Layer 2: Analytics kill switch
setAnalyticsEnabled(true) // Analytics on
setAnalyticsEnabled(false) // Analytics off (purges queue, stops all tracking)
val enabled = isAnalyticsEnabled
// Layer 3: Tracking allowed (combines consent + enabled)
val allowed = isTrackingAllowed()How it works:
trackingAllowedByDefault = true (default): Tracks until user declinestrackingAllowedByDefault = false: Blocks tracking until user opts inhasCookieConsent() == false && trackingAllowedByDefault == true → Tracks anyway (implied consent)hasCookieConsent() == false && trackingAllowedByDefault == false → BlocksisAnalyticsEnabled == false → Blocks regardless of consentGate.shouldTrack() before any event is enqueuedDeclare an ordered list of steps — each one an event you already track — and QuietMetrix analyzes drop-off, breakdown, and time-to-convert automatically, with no dashboard setup:
val signupFunnel = Funnel(
key = "signup",
name = "Signup",
steps = listOf(
FunnelStep(key = "view", event = "screen_view", screen = "signup"),
FunnelStep(key = "submit", event = "signup_submitted"),
),
windowSeconds = 7L * 24 * 3600, // default: 7 days
)
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1",
apiKey = "qm_ak_your_api_key",
funnels = listOf(signupFunnel),
))The SDK auto-registers the funnel with the server on init (fingerprint-gated, so a
launch registers nothing when the definition hasn't changed) and matches it against
your existing event stream — no code change needed at tracking call sites. Funnels are
counted per install (a per-project, salted, non-reversible hash of a device-local id)
— never by user ID, matching QuietMetrix's no-PII design.
See the Funnels developer guide for matching rules, the dashboard editing/locking workflow, and a worked example of the results payload.
trackEvent/trackScreen and on-device funnel/session tracking each record a
(metric, dims) -> n delta locally, in memory (internal/counters/MetricRecorder) — no
network call happens inline, and no raw event or per-session trail is ever built.
// Recorded locally — no network call happens inline
trackEvent("page_view")
// Force an immediate flush of the pending counters (suspend function)
QuietMetrix.flush()
// Flush interval is configurable
QuietMetrixConfig(flushIntervalMs = 10_000L) // Flush every 10 secondsPending counters are in-memory only — there is no offline queue, no persistence across a
process restart, and no connectivity monitoring or retry backoff. CounterFlusher simply
POSTs whatever is pending to /api/v1/counters every flushIntervalMs; a failed send (offline,
5xx, timeout) drops that batch rather than queuing it for retry. An app killed between flushes
loses whatever was recorded since the last successful one.
The SDK auto-detects device context per platform:
| Field | Android | iOS | JVM | Web |
|---|---|---|---|---|
platform |
android |
ios |
jvm |
wasmJs |
language |
Locale.getDefault() |
Locale.current |
user.language |
navigator.language |
screenWidth / screenHeight
|
DisplayMetrics | UIScreen | null | window.innerWidth/Height |
userAgent |
null | null | os.name |
navigator.userAgent |
The authoritative API spec is docs/openapi.yaml (OpenAPI 3.1). Both backends implement it identically.
API key — For tracking endpoints. Passed as X-QM-Api-Key header. The API key is publishable: write-only and project-scoped, so it is safe to embed in client code (browser bundles, mobile apps, F-Droid builds). It cannot read analytics or access admin routes — those require a Bearer token. See Threat model & abuse defense. Rotate it via POST /api/v1/projects/{id}/regenerate-key (Bearer-auth) when a published key is abused.
X-QM-Api-Key: qm_ak_abc123def456ghi789
Bearer Token — For admin/dashboard endpoints. Obtained via POST /api/v1/auth/login.
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Auth: API key
Request:
{
"sdk": { "platform": "android", "version": "0.5.0" },
"app": { "version": "2.4.0", "country": "US" },
"day": "2026-09-04",
"counters": [
{ "m": "screen_transition", "d": { "from": "Library", "to": "BookDetail" }, "n": 3, "u": 1 },
{ "m": "event", "d": { "name": "page_view" }, "n": 5, "u": 0 }
]
}Response: 202 Accepted
{ "ok": true, "accepted": 2, "quarantined": 0 }day is the device's local date; the server clamps it to [today-2, today] UTC. Each item's
m (metric) and d (dims) are validated against a fixed per-metric registry — an unknown
metric or undeclared dimension is quarantined rather than rejecting the whole batch.
No auth required.
Response: 200 OK
{ "ok": true, "version": "0.1.0" }Request:
{ "email": "admin@example.com", "password": "your-password" }Response: 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_1",
"email": "admin@example.com",
"createdAt": "2026-04-30T12:00:00Z"
}
}Auth: Bearer Token or a PAT with analytics:read
Query params: from, to (required, ISO 8601), granularity (day, default)
Auth: Bearer Token or a PAT with analytics:read
Screen-to-screen navigation flow, session counts/average duration, and cohort retention —
all read from counters. Query params: days (1–90, default 30).
Auth: Bearer Token
Request: { "name": "My App" } (required, non-blank, max 255 chars)
Response: 201 Created
{
"api_key": "qm_ak_abc123...",
"message": "Project created. Store it securely — it will not be shown again."
}Auth: Bearer Token
Query params: limit, offset, owner_only (bool)
Returns projects where the authenticated user is owner or member. Keys are masked as "***".
Auth: Bearer Token, must be owner or member. Keys are masked.
Auth: Bearer Token, must be owner or admin
Request: { "name": "New Name" }
Auth: Bearer Token, must be owner
Response: { "deleted": true, "project_id": "proj_42" }
Events are preserved. Project is hidden from listings.
Auth: Bearer Token, must be a member
Response:
{
"members": [
{ "id": "1", "project_id": "proj_42", "user_id": "3", "email": "viewer@example.com", "role": "viewer", "created_at": "..." }
],
"total": 1
}Auth: Bearer Token, must be owner or admin
Request: { "email": "colleague@example.com", "role": "viewer" }
Role must be viewer or admin (not owner). Returns 201 Created.
Auth: Bearer Token, must be owner or admin. Cannot remove self if last admin.
See the Funnels developer guide for concepts, matching rules, and a worked example of the results payload.
GET /api/v1/projects/:id/funnels — Bearer auth. List active funnels.POST /api/v1/projects/:id/funnels — Bearer auth, admin/developer role. Create a funnel.PATCH /api/v1/projects/:id/funnels/:funnelKey — Bearer auth, admin/developer role. Update a funnel; locks it against further SDK auto-registration.DELETE /api/v1/projects/:id/funnels/:funnelKey — Bearer auth, admin/developer role. Archive a funnel; unlocks the key for SDK re-registration.GET /api/v1/projects/:id/funnels/:funnelKey/results — Bearer auth. Query params: range (seconds, default 7 days), breakdown (country|platform|device_class|language), trend (1 to include a daily trend series).POST /api/v1/funnels/register — API key auth. Upserts funnel definitions declared by the SDK; a no-op for any funnel already locked by a dashboard edit.QuietMetrix is self-hosted and unlimited: create as many projects and ingest as many events as your database can hold. No plans, no quotas, no billing.
Each project has its own API key. You can create multiple projects and use different API keys for different apps:
// App 1
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "app1_",
apiKey = "qm_ak_app1_key..."
))
// App 2 — same SDK, different key
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "app2_",
apiKey = "qm_ak_app2_key..."
))Each project appears separately in the dashboard with its own events, aggregates, and members.
You can also have an AI coding agent do the above for you — tell it "create a QuietMetrix
project at https://your-server.com and wire it into this app" and it creates the project via
the CLI or MCP server and writes the key into your SDK config. Requires a one-time personal
access token (qm_pat_…, not the publishable qm_ak_… API key above — see why they're
handled so differently). Full walkthrough: Agent-driven setup.
| Role | Permissions |
|---|---|
| Owner | Full control. Can delete project, manage members, update settings. Always the original creator. |
| Admin | Can invite/remove members (except other admins/owners), update project name/settings. Cannot delete the project. |
| Viewer | Read-only. Can view dashboard data but cannot modify anything. |
Only owners and admins can manage members. Viewers can only see data.
Projects are soft-deleted — deleted_at is set, events are preserved. The project disappears from listings and API keys stop working. Only the owner can delete a project.
gradlew)ANDROID_HOME setnpm install -g newman) for contract tests# Compile all targets
./gradlew :quietmetrix-sdk:compileKotlinJvm
./gradlew :quietmetrix-sdk:compileDebugKotlinAndroid
# Run SDK tests (JVM)
./gradlew :quietmetrix-sdk:jvmTest
# Publish to local Maven (~/.m2)
./gradlew :quietmetrix-sdk:publishToMavenLocal
# Build XCFramework for iOS
./gradlew :quietmetrix-sdk:assembleXCFramework# Start Postgres (or use Docker)
docker run -d --name qm-postgres \
-e POSTGRES_DB=quietmetrix -e POSTGRES_USER=quietmetrix -e POSTGRES_PASSWORD=changeme \
-p 5432:5432 postgres:16-alpine
# Run the server
export QM_DB_PASSWORD=changeme
export QM_JWT_SECRET=$(openssl rand -hex 32)
export QM_CORS_ALLOWED_ORIGINS=http://localhost:8080
./gradlew :servers:ktor:run
# Or from IntelliJ: run ApplicationKt.main()The server starts on http://localhost:8080. Flyway migrations run automatically on first boot.
cd php-hosting
# Start MySQL (or use Docker)
docker run -d --name qm-mysql \
-e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=quietmetrix \
-p 3306:3306 mysql:8.0
# Configure
cp config.example.php config.php
# Edit config.php: DB_HOST/DB_NAME/DB_USER/DB_PASS, ADMIN_EMAIL, ADMIN_PASSWORD, JWT_SECRET
# Start PHP's built-in server — schema.sql and the admin user are applied
# automatically on the first request, no separate migration step
php -S localhost:8081# All Ktor server tests (JUnit 5 + Kotest)
./gradlew :servers:ktor:test
# All SDK tests (JVM target)
./gradlew :quietmetrix-sdk:jvmTest
# Specific test class
./gradlew :servers:ktor:test --tests "*RouteIntegrationTest*"
./gradlew :servers:ktor:test --tests "*ProjectRoutesIntegrationTest*"
# PHP tests — plain scripts, no framework; each is self-contained and exits
# 0 (pass) or 1 (fail)
cd php-hosting
php -l index.php && php -l src/*.php && php -l src/routes/*.php # syntax check
for f in tests/*.php; do php "$f" || echo "FAILED: $f"; donecp docker/.env.example .env # set QM_DB_PASSWORD, QM_JWT_SECRET
docker compose up --buildThree services run in order: postgres (16-alpine, healthchecked) →
migrate (Flyway applies servers/ktor/migrations/) → ktor (builds the
Wasm dashboard and fat jar, then serves the API + dashboard on port 8080).
For production TLS, put the Ktor service behind the reverse proxy in
docker/caddy/Caddyfile.
# Ktor server
docker build -f servers/ktor/Dockerfile -t quietmetrix-ktor:latest .php-hosting/ has no Dockerfile — it's designed for shared hosting (upload the directory as
static files + PHP), not a container. See PHP backend, IONOS / shared hosting above.
| Module | Framework | Count | Location |
|---|---|---|---|
| SDK | kotlin.test | 20+ | quietmetrix-sdk/src/commonTest/ |
| Ktor server | Kotest + JUnit 5 | 36 | servers/ktor/src/test/ |
| PHP server (php-hosting) | Plain PHP assertion scripts, no framework | 6 |
php-hosting/tests/, run via php <file>.php
|
Ktor integration tests use H2 in-memory database and testApplication:
RouteIntegrationTest — Health, track, auth endpointsProjectRoutesIntegrationTest — CRUD, limits, membersUsing Newman (Postman CLI):
cd tools/contract-tests
newman run quietmetrix.postman_collection.json --env-var base_url=http://localhost:8080Run against both backends to verify API contract compliance.
Using k6:
cd tools/load
k6 run track.k6.jsSimulates 500 RPS against the track endpoint.
./gradlew e2eTest
# or
cd tools/e2e && bash run-e2e.shSpins up both backends in Docker, runs contract tests, tears down.
GitHub Actions workflows in .github/workflows/:
| Workflow | Trigger | Actions |
|---|---|---|
ci.yml |
Push to main, PRs | Repo-hygiene check, SDK tests (iOS simulator target), Ktor tests, PHP syntax lint + health-check smoke test (php-hosting/) |
docker.yml |
Tags (v*) |
Build & push the Ktor Docker image to GHCR |
publish-sdk.yml |
Tags (v*) |
Publish SDK to Maven Local/Central, create GitHub Release |
docs.yml |
Push to main | Build and deploy MkDocs site |
docker.yml currently also has a build-php job targeting servers/php/Dockerfile, which no
longer exists in the repo — that job is broken (see Project Structure).
docs/operations/rate-limits.md.docs/operations/gdpr.md.docs/operations/security.md.pg_dump for Postgres, mysqldump for MySQL. See docs/self-hosting/backup.md.docker compose pull && up -d (Ktor), or re-upload changed files to php-hosting/ — schema.sql and in-place column/table additions apply automatically on the next request, no migration command needed. See docs/self-hosting/upgrade.md.MIT — see LICENSE.
All code is MIT-licensed. No feature gating, no closed-source components. You can fork, modify, and redistribute freely. Commercial use is explicitly permitted.
Get started: docker compose up --build → curl localhost:8080/api/v1/health