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.
- What is QuietMetrix?
- Quick Starts
- Project Structure
- Architecture
- Backend Setup — Detailed
- SDK — Detailed
- API Reference
- Multi-Project Support
- Development
- Docker
- Testing
- CI / CD
- Operations
- License
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:
- API keys are bcrypt-hashed in the database — never stored in plaintext
- Events go through an async inbox → processing → rollup pipeline
- Fully self-hosted and unlimited: no quotas, no per-plan caps, no telemetry
// 1. Add dependency (KMP project, build.gradle.kts)
implementation(project(":quietmetrix-sdk"))
// or from Maven Central when published:
// implementation("com.quietmetrix:quietmetrix-sdk:0.2.0")
// 2. Initialize once at app startup
import com.quietmetrix.analytics.*
QuietMetrix.init(
QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1/track",
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() }
// 6. Identify a user
QuietMetrix.identify("user_123")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.2.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.
The Slim 4 + Composer + Phinx variant in servers/php/, used when you want
to run the PHP server in a container or on infrastructure where you control
the runtime.
# 1. Upload servers/php/ to your web host
# - public/ → your web root (public_html/)
# - src/, bin/, migrations/, vendor/ → ABOVE web root
# 2. Install dependencies
cd /path/above/webroot
cp .env.example .env
# Edit .env with your MySQL credentials + JWT secret
composer install --no-dev
# 3. Run Phinx migrations
vendor/bin/phinx migrate
# 4. Create first admin user (direct DB insert — see detailed setup)
# 5. Add cron worker (processes events every minute)
# * * * * * php /path/to/bin/qm-worker.php
# 6. Login via POST /api/v1/auth/login and create a projectQuietMetrix/
├── quietmetrix-sdk/ # KMP SDK (7 targets)
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/ # Shared business logic
│ ├── commonTest/ # Shared unit tests
│ ├── androidMain/ # Android-specific (ConnectivityMonitor, 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/ # Event, Project, User, Plan, ProjectMember
│ │ ├── ingest/ # EventValidator, EventNormalizer, IngestChannel
│ │ ├── persistence/ # Exposed tables + repositories
│ │ ├── plugins/ # CORS, Monitoring, Security, RateLimiting
│ │ ├── ratelimit/ # RateLimiter, QuotaEnforcer (unlimited self-host)
│ │ └── routes/ # Track, Auth, Project, Dashboard
│ │
│ └── php/ # PHP + MySQL server (Docker / Slim 4 variant)
│ ├── composer.json
│ ├── .env.example
│ ├── public/index.php # Web root entry
│ ├── bin/qm-worker.php # Cron job for inbox processing
│ ├── migrations/ # Phinx migrations
│ └── src/
│ ├── Config/
│ ├── Controller/
│ ├── Domain/
│ ├── Http/ # App router, Middleware
│ └── Persistence/ # PDO repositories
│
├── 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
│ │ └── routes/ # auth, projects, track, dashboard, demo
│ └── 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/track │
│ • iOS │ │ • POST /track/batch │
│ • macOS │ │ • JWT auth + rate limit │
│ • Windows │ │ • Postgres 16 │
│ • Linux │ ◄─────────────────────────── │ • Flyway migrations │
│ • Web (Wasm) │ 202 Accepted {ok, queued} │ • Serves the dashboard │
│ • JVM Desktop │ └──────────────────────────┘
│ │
│ • Event Queue │ ▲
│ • Offline buffer │ │ same API contract
│ • Consent gate │ ▼
│ • Auto-flush │ ┌──────────────────────────┐
│ • Backoff retry │ │ PHP Server │
└──────────────────┘ │ • Same routes & schemas │
│ • MySQL 8 │
│ • Phinx migrations │
│ • Cron event processor │
└──────────────────────────┘
Event flow:
- SDK enqueues event → local
EventQueue(memory + connectivity check) FlushManagerfires everyflushIntervalMs(default 30s) or when connectivity returns- Event batch POSTed to
/api/v1/trackor/api/v1/track/batch - Server validates API key → validates schema → enqueues to
events_inbox - Background worker processes inbox → inserts to
eventstable → updatesevent_counts_daily - Dashboard queries read from
events,event_counts_daily,usage_counters
- Docker Engine 24+ and Docker Compose v2+
- 1 GB RAM minimum (Postgres uses ~256 MB, Ktor ~128 MB)
- Ports 80/443 (or custom) for the Caddy reverse proxy
# 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.2.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 event
curl -X POST http://localhost:8080/api/v1/track \
-H "Content-Type: application/json" \
-H "X-QM-Api-Key: qm_ak_abc123..." \
-d '{"event":"page_view","screen":"home"}'
# → 202 {"ok":true,"queued":1}| 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 |
- Copy
docker/.env.exampletodocker/.envand fill in strong passwords - Change
QM_JWT_SECRETto a random 64-character string - Set a strong
QM_DB_PASSWORD - Configure
QM_TRUSTED_PROXIESif running behind a reverse proxy / CDN - Configure a real domain in
docker/caddy/Caddyfilefor TLS - Set up backups (
docker compose exec postgres pg_dump ...) - Pin image tags in compose (replace
latestwith specific versions)
- PHP 8.2 or 8.3 with extensions:
pdo_mysql,json,mbstring,ctype - MySQL 8.0+ (or MariaDB 10.11+)
- Apache with
mod_rewrite(or Nginx with equivalent config) - Composer (local or server-side)
# 1. Prepare files locally
cd servers/php
cp .env.example .env
# Edit .env:
# QM_DB_HOST=localhost
# QM_DB_NAME=quietmetrix
# QM_DB_USER=your_db_user
# QM_DB_PASSWORD=your_db_password
# QM_JWT_SECRET=<generate a random 64-char string>
composer install --no-dev
# 2. Upload to server
# public/ → public_html/ or your web root
# src/ → /home/user/quietmetrix/src/
# bin/ → /home/user/quietmetrix/bin/
# migrations/ → /home/user/quietmetrix/migrations/
# vendor/ → /home/user/quietmetrix/vendor/
# .env → /home/user/quietmetrix/.env
# 3. Run Phinx migrations
vendor/bin/phinx migrate
# 4. Create first admin user (insert into MySQL with bcrypt hash)
# Generate hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'password', bcrypt.gensalt(12)).decode())"
mysql> INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '$2a$12$HASH');
# 4. Add cron
# * * * * * php /home/user/quietmetrix/bin/qm-worker.php >> /home/user/quietmetrix/logs/worker.log 2>&1
# 5. Secure the installation
# - Ensure src/, bin/, migrations/, vendor/ are not web-accessible
# - Set restrictive file permissions (640 for .env, 755 for directories)- The
src/,bin/,migrations/, andvendor/directories must not be web-accessible. - The
.envfile contains secrets — ensure it's above the web root. - Use HTTPS. Let's Encrypt with certbot or your hosting panel's SSL.
| 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 /track + /track/batch |
QM_INGEST_IP_BURST |
60 |
Per-IP burst tokens |
QM_INGEST_INSTALL_ENABLED |
true |
Per-install (anonymousId) ingest throttling |
QM_INGEST_INSTALL_RPS |
1 |
Per-install requests/second |
QM_INGEST_INSTALL_BURST |
30 |
Per-install burst tokens |
QM_INGEST_INSTALL_RAMP_EVENTS |
500 |
Auto-revoke an install that sends this many events in the ramp window |
QM_INGEST_INSTALL_RAMP_MINUTES |
10 |
Ramp-up window length (minutes) |
| Variable | Default | Description |
|---|---|---|
QM_PROFILE |
selfhost |
selfhost or cloud |
QM_DB_HOST |
127.0.0.1 |
MySQL host |
QM_DB_PORT |
3306 |
MySQL port |
QM_DB_NAME |
quietmetrix |
Database name |
QM_DB_USER |
quietmetrix |
Database user |
QM_DB_PASSWORD |
quietmetrix |
Database password |
QM_JWT_SECRET |
change-me... |
HMAC256 signing secret |
QM_JWT_ISSUER |
quietmetrix |
JWT issuer |
QM_JWT_AUDIENCE |
quietmetrix-api |
JWT audience |
QM_SESSION_TTL_HOURS |
2 |
Access token lifetime |
JWT_REFRESH_EXPIRY_DAYS |
2 |
Refresh token lifetime |
CSP_CONNECT_SRC |
'self' |
CSP connect-src directive |
QM_RATE_LIMIT_ENABLED |
false |
Enable rate limiting |
QM_RATE_LIMIT_RPS |
10 |
Requests per second |
QM_RATE_LIMIT_BURST |
60 |
Burst per minute |
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
To run manually:
cd servers/ktor
./gradlew flywayMigrateRun the Phinx migrations:
cd servers/php
vendor/bin/phinx migrateAfter starting either backend:
-
Create admin user — Insert directly into the
userstable with a bcrypt hash:-- Postgres (Ktor) INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '$2a$12$...'); -- MySQL (PHP) INSERT INTO users (email, password_hash) VALUES ('admin@example.com', '$2a$12$...');
Generate a bcrypt hash (cost 12) using:
python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(12)).decode())" -
Login —
POST /api/v1/auth/loginwith email/password to get a JWT token -
Create a project —
POST /api/v1/projectswith the JWT token -
Save the API key — The response includes
api_key. Store it securely — it is shown only once. -
Configure the SDK — Set
apiKeyinQuietMetrixConfigto the API key from step 4
// settings.gradle.kts
repositories { mavenCentral() }
// module build.gradle.kts
dependencies {
implementation("com.quietmetrix:quietmetrix-sdk:0.2.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/track",
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.2.0")
}// Main.kt
import com.quietmetrix.analytics.*
fun main() {
QuietMetrix.init(QuietMetrixConfig(
storageKeyPrefix = "myapp_",
trackingEndpoint = "https://your-server.com/api/v1/track",
apiKey = "qm_ak_..."
))
trackEvent("app_start", screen = "main")
}<!-- Option A: CDN -->
<script src="https://cdn.quietmetrix.com/quietmetrix.js"></script>
<!-- Option B: Local bundle -->
<script src="quietmetrix.js"></script>
<script>
QuietMetrix.init({
storageKeyPrefix: 'myapp_',
trackingEndpoint: 'https://your-server.com/api/v1/track',
apiKey: 'qm_ak_...'
});
QuietMetrix.trackEvent('page_view', { screen: 'home' });
</script>Use the Ktor Client engine for each platform. The SDK auto-selects:
- macOS/iOS: Darwin engine
- Linux: Curl engine
- Windows: WinHttp engine
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, // Auto-flush interval (ms)
val maxQueueSize: Int = 1000, // Max buffered events (oldest dropped when full)
val autoTrackInitialPageView: Boolean = true, // Fire page_view on init?
val trackingAllowedByDefault: Boolean = true, // Track before consent?
val userAgent: String? = null, // Custom User-Agent header
)// 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 consent- All three gates are checked by
Gate.shouldTrack()before any event is enqueued
// Identify a user (stored locally, sent as uid field in events)
QuietMetrix.identify("user_123")
// Clear identity
QuietMetrix.identify(null)The uid is included in the TrackEventRequest payload and added to all subsequent events in the flush batch. It persists across app restarts (stored in InMemoryStore with the project's storageKeyPrefix).
The SDK buffers events locally when offline and flushes them when connectivity returns.
// Events are enqueued locally — no network call happens inline
trackEvent("page_view") // Enqueued, not sent yet
// Force flush all buffered events (suspend function)
QuietMetrix.flush()
// Flush interval is configurable
QuietMetrixConfig(flushIntervalMs = 10_000L) // Flush every 10 seconds
// Max queue size prevents unbounded memory growth
QuietMetrixConfig(maxQueueSize = 500) // Keep max 500 events in memory
// When offline, events are buffered with was_offline=true flag
// When connectivity returns, FlushManager triggers an immediate drainConnectivity monitoring is platform-specific:
- Android:
ConnectivityManagerAPI - iOS/macOS:
NWPathMonitor - JVM: Always assumes online
- Web:
navigator.onLine+online/offlineevents - Linux/Windows: Always assumes online
Exponential backoff: After failed flushes (5xx errors), the FlushManager accumulates a backoff delay — 1s → 2s → 4s → 8s → 16s → 32s → 60s (cap). Backoff resets on the first successful flush or when connectivity returns.
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:
{
"event": "page_view",
"screen": "home",
"props": { "language": "en", "theme": "dark" },
"sid": "abc123def456",
"ts": "2026-04-30T12:34:56Z",
"was_offline": false,
"uid": "hashed_user_id",
"sdk": { "platform": "android", "version": "0.2.0" },
"ctx": {
"language": "en",
"ua": "Mozilla/5.0 ...",
"viewport": "412x914",
"referrer": "https://google.com"
}
}Response: 202 Accepted
{ "ok": true, "queued": 1 }Only event is required. The server adjusts ts if >7 days from server time.
Auth: API key
Request:
{
"events": [
{ "event": "page_view", "screen": "home" },
{ "event": "click", "screen": "home", "props": { "target": "logo" } }
]
}Response: 202 Accepted
{ "ok": true, "queued": 2 }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
Query params: limit (1–1000, default 100), offset, event, screen, from, to (ISO 8601)
Auth: Bearer Token
Query params: from, to (required, ISO 8601), granularity (day, default)
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.
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.
| 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.
- JDK 21 (Amazon Corretto or OpenJDK)
- Gradle 8.11+ (the project includes
gradlew) - Android SDK (for SDK Android target compilation) —
ANDROID_HOMEset - Docker (for running the Ktor backend locally)
- PHP 8.2+ and Composer (for the PHP backend)
- Node.js + Newman (
npm 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 servers/php
# 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
# Install dependencies
composer install
# Run migrations
vendor/bin/phinx migrate
# Start PHP built-in server
php -S localhost:8081 -t public/
# Run cron worker manually (for testing)
php bin/qm-worker.php# 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
cd servers/php
vendor/bin/phpunitcp 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 server
docker build -f servers/php/Dockerfile -t quietmetrix-php:latest .| Module | Framework | Count | Location |
|---|---|---|---|
| SDK | kotlin.test | 20+ | quietmetrix-sdk/src/commonTest/ |
| Ktor server | Kotest + JUnit 5 | 36 | servers/ktor/src/test/ |
| PHP server | PHPUnit 11 | 5+ | servers/php/tests/ |
Ktor integration tests use H2 in-memory database and testApplication:
RouteIntegrationTest— Health, track, auth endpointsProjectRoutesIntegrationTest— CRUD, limits, members
Using 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 | SDK JVM tests, Ktor tests, PHP tests |
docker.yml |
Tags (v*) |
Build & push Ktor + PHP Docker images 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 |
- Rate limits: Configurable per-project, per-IP, and per-install throttling. See
docs/operations/rate-limits.md. - GDPR compliance: Built-in consent gates, data export/delete-friendly design. See
docs/operations/gdpr.md. - Security: API keys are bcrypt-hashed. JWT secrets must be strong. See
docs/operations/security.md. - Backups:
pg_dumpfor Postgres,mysqldumpfor MySQL. Seedocs/self-hosting/backup.md. - Upgrading:
docker compose pull && up -dorgit pull && vendor/bin/phinx migrate. Seedocs/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