Self-hosted, privacy-focused web analytics powered by DuckDB and the
behavioralextension. Single binary. Single process. Zero external dependencies.
A lightweight, privacy-respecting alternative to Plausible Analytics. Runs entirely on your infrastructure — no third-party services, no cookies, no persistent IP storage. See PRIVACY.md for the complete data-processing architecture and operator compliance guidance.
- Features
- Quick Start
- Tracking Script
- Configuration
- API Reference
- Architecture
- Dashboard
- Technology Stack
- Development
- Deployment
- Documentation
- License
- No cookies — Visitor identification is an HMAC-SHA256 of site + IP + User-Agent under a rotating salt; no cookies are set and no browser storage is written
- No persistent IP storage — IP addresses are used in RAM for the GeoIP lookup and the visitor-ID derivation, then discarded; no IP is ever written to the event store. The one exception is deliberate and not on the analytics path: authentication events log a truncated address (IPv4
/24, IPv6/48) so an operator can investigate an attack on the dashboard - Rotating salt — Visitor IDs change when the salt rotates (every 24 hours by default), so the same IP and User-Agent hash differently on different days
- Per-site scoping — The site ID is part of the hash input, so one person visiting two sites on the same instance is not correlatable across them
- Pseudonymous, not anonymous — Stored visitor IDs are hashes, and pseudonymous data is still personal data under GDPR Recital 26. Geographic data derived from IP is stored. See PRIVACY.md for the full analysis and operator obligations.
Salt rotation has an analytical cost. With the default daily rotation, a visitor seen on two days carries two unrelated IDs. "Unique visitors" over a multi-day range therefore counts visitor-days rather than people, and weekly retention cohorts cannot work at all. Raise
visitor_salt_rotation_daysif you need cross-day analysis, and read the trade-off in PRIVACY.md.
- One process handles ingestion, storage, querying, authentication, and the dashboard
- Zero external dependencies — DuckDB is embedded; no separate database to install or manage
FROM scratchDocker image — Static musl binary with no runtime dependencies- WAL durability — DuckDB disk-based storage survives crashes without data loss
- Core metrics — Unique visitors, pageviews, events, visits, bounce rate, visit duration
- Realtime — Current visitors, top pages and sources, with a per-minute series
- Twenty breakdown dimensions — Pages, entry and exit pages, referrers and sources, all five UTM parameters, countries, regions, cities, browsers and versions, operating systems and versions, device types, screen widths, and event names
- Segment filters —
filters=countries==DE;devices!=mobilenarrows every figure a request returns, not just the breakdown you were looking at. Dimension names are the breakdown slugs, so clicking a row in the dashboard filters everything to it - Period comparison —
compare=previous_periodorcompare=year_over_yearreturns the same headline figures for an equally long earlier window, so the dashboard can show whether a number went up or down and by how much - Goals and custom properties — Conversion rates for every custom event, and drill-down into the properties attached to them
- Revenue — Totals, order counts and average order value, reported per currency and never summed across them
- Time-series — Hourly and daily aggregation, gap-filled so a quiet day reads as zero rather than vanishing
- Funnel analysis — Cumulative multi-step funnels with drop-off, via
window_funnel(), including its six ordering modes - Retention cohorts — Weekly cohorts with retained counts and rates, via
retention() - Session analytics — Visits, duration and bounce rate, via
sessionize() - Sequence matching — Ordered pattern detection via
sequence_match()andsequence_count() - Flow analysis — Where visitors go next and where they came from, via
sequence_next_node() - Export — Daily summaries or raw events, as CSV or JSON
Mallard Metrics ships a first-class GDPR mode that reduces data collection to the minimum needed for aggregate analytics:
| Setting | Standard | GDPR Mode |
|---|---|---|
| Visitor ID | HMAC-SHA256 pseudonymous hash | HMAC-SHA256 (suppress with suppress_visitor_id) |
| Referrer | Full URL with query string | Path only — query and fragment stripped |
| Timestamps | Microsecond precision | Rounded down to the hour |
| Browser info | Name + version | Name only |
| OS info | Name + version | Name only |
| Screen / device | Stored | Omitted |
| GeoIP | City-level | Country-level at most |
Enable with a single environment variable (MALLARD_GDPR_MODE=true) or configure each flag independently. A DELETE /api/gdpr/erase endpoint supports GDPR Art. 17 right-to-erasure requests. See PRIVACY.md for the full compliance analysis and operator obligations.
- Argon2id authentication — Password-protected dashboard with cryptographic session tokens
- API key management — Programmatic access with SHA-256 hashed keys (
mm_prefix, disk-persisted) - Rate limiting — Per-site and per-client-IP token-bucket limiters, both bounded
- Query caching — TTL cache with LRU eviction, applied to every read endpoint
- Bot filtering — Automatic filtering of known bot User-Agents
- GeoIP resolution — MaxMind GeoLite2 integration with graceful fallback
- Data retention — Configurable automatic cleanup of old Parquet partitions
- Graceful shutdown — Buffered events are flushed before process exit
- Prometheus metrics —
GET /metricswith counters for ingestion, cache, auth and rate limiting, plus request counts by status class and a latency histogram - Persisted admin credential — a password set through the setup endpoint is stored as an Argon2id hash (mode 0600) and survives a restart, so an instance cannot be claimed by whoever reaches it first after a reboot
- Consistent reads — analytics queries never observe a flush, compaction or erasure part-way through, so a report cannot double-count events that are momentarily in both storage tiers
- Bounded DuckDB — configurable
memory_limitand thread count, so one expensive query cannot take the process down with it - Security headers — OWASP-recommended headers including HSTS, CSP, and Permissions-Policy
- CSRF protection — Origin/Referer validation on all state-mutating endpoints
- Brute-force protection — Per-IP lockout on both login and first-time setup
- Bounded memory — The event buffer, session store, rate-limiter buckets and login-attempt records all have configurable caps
- Atomic writes — Parquet files, the API-key store and the visitor-ID secret are written to a temporary file and renamed into place; the secret files are mode 0600
- Parquet compaction — Small per-flush files are merged so scan cost does not grow with uptime
- Read connection pool — Analytics queries run on their own DuckDB connections, so a slow dashboard query cannot block ingestion
docker run -d \
-p 127.0.0.1:8000:8000 \
-v mallard-data:/data \
-e MALLARD_SECRET=your-random-32-char-secret \
-e MALLARD_ADMIN_PASSWORD=your-dashboard-password \
ghcr.io/tomtom215/mallardmetricsdocker compose up -dThe default docker-compose.yml includes persistent storage, restart policy, and environment variable configuration. Set MALLARD_SECRET and MALLARD_ADMIN_PASSWORD in your environment for production.
# Requires Rust 1.98.0 (installed automatically via rust-toolchain.toml)
cargo build --release
./target/release/mallard-metricsVisit http://localhost:8000 to access the dashboard. Until an admin password is set, analytics are readable by anyone who can reach the port — a deliberate mode for a public dashboard, and one the dashboard warns about on every load, with a button to set a password. Set MALLARD_ADMIN_PASSWORD, or use that button, before exposing an instance to a network.
Add the following snippet to every page you want to track:
<script defer data-domain="yourdomain.com"
src="https://your-mallard-instance.com/mallard.js"></script>The tracking script exposes window.mallard() for custom event tracking:
// Track a custom event
window.mallard('signup', {
props: { plan: 'pro', source: 'landing-page' }
});
// Track revenue
window.mallard('purchase', {
revenue: 49.99,
currency: 'USD',
props: { product: 'annual-plan' },
callback: () => console.log('tracked')
});| Parameter | Type | Description |
|---|---|---|
props |
Object | Custom properties (max 4096 chars) |
revenue |
Number | Revenue amount |
currency |
String | ISO 4217 currency code |
callback |
Function | Called after the event is sent |
The tracking script is about 3.8 KB gzipped, has zero external dependencies, sets no cookies, and writes nothing to browser storage. It is served unminified so you can read exactly what runs on your visitors' browsers.
Mallard Metrics is configured via a TOML file, environment variables, or both. Environment variables override TOML values.
./mallard-metrics /path/to/mallard-metrics.tomlSee mallard-metrics.toml.example for a fully documented configuration template.
| Variable | Default | Description |
|---|---|---|
MALLARD_HOST |
0.0.0.0 |
Server bind address |
MALLARD_PORT |
8000 |
Server listen port |
MALLARD_DATA_DIR |
data |
Directory for Parquet data and DuckDB file |
MALLARD_SECRET |
(auto-generated) | HMAC key for visitor ID hashing. Auto-generated and persisted to data_dir/.secret on first run. Set explicitly for production |
MALLARD_ADMIN_PASSWORD |
(none) | Admin password for dashboard authentication |
MALLARD_SECURE_COOKIES |
false |
Enable Secure flag on session cookies (required when behind TLS) |
MALLARD_METRICS_TOKEN |
(none) | Bearer token protecting the /metrics endpoint |
MALLARD_FLUSH_COUNT |
1000 |
Events buffered before flushing to disk |
MALLARD_FLUSH_INTERVAL |
60 |
Seconds between periodic buffer flushes |
MALLARD_GEOIP_DB |
(none) | Path to MaxMind GeoLite2-City.mmdb |
MALLARD_DASHBOARD_ORIGIN |
(none) | Restrict dashboard CORS to this origin (enables CSRF protection) |
MALLARD_FILTER_BOTS |
true |
Filter known bot User-Agents |
MALLARD_RETENTION_DAYS |
0 |
Auto-delete data older than N days (0 = unlimited) |
MALLARD_RATE_LIMIT |
0 |
Max events/sec per site (0 = unlimited) |
MALLARD_CACHE_TTL |
60 |
Query cache TTL in seconds |
MALLARD_CACHE_MAX_ENTRIES |
10000 |
Maximum cached query results (0 = unlimited) |
MALLARD_MAX_CONCURRENT_QUERIES |
10 |
Maximum concurrent analytics queries (0 = unlimited); excess returns 429 |
MALLARD_SESSION_TTL |
86400 |
Dashboard session TTL in seconds (24 hours) |
MALLARD_SHUTDOWN_TIMEOUT |
30 |
Graceful shutdown timeout in seconds |
MALLARD_MAX_LOGIN_ATTEMPTS |
5 |
Failed login attempts per IP before lockout (0 = disabled) |
MALLARD_LOGIN_LOCKOUT |
300 |
Lockout duration in seconds after exceeding max login attempts |
MALLARD_LOG_FORMAT |
text |
Log format: text or json |
MALLARD_GDPR_MODE |
false |
Enable GDPR-friendly preset (see PRIVACY.md) |
MALLARD_STRIP_REFERRER_QUERY |
false |
Strip ?query and #fragment from stored referrers |
MALLARD_ROUND_TIMESTAMPS |
false |
Round event timestamps to the nearest hour |
MALLARD_SUPPRESS_VISITOR_ID |
false |
Replace HMAC visitor hash with per-request UUID (breaks unique-visitor counting) |
MALLARD_SUPPRESS_BROWSER_VERSION |
false |
Store browser name only, not version |
MALLARD_SUPPRESS_OS_VERSION |
false |
Store OS name only, not version |
MALLARD_SUPPRESS_SCREEN_SIZE |
false |
Omit screen width and device type |
MALLARD_GEOIP_PRECISION |
city |
GeoIP precision: city, region, country, or none |
MALLARD_SITE_IDS |
(none) | Comma-separated allowlist, enforced against the Origin header and the event payload |
MALLARD_TRUST_PROXY_HEADERS |
false |
Trust X-Forwarded-For / X-Real-IP. Only enable behind a proxy that overwrites them |
MALLARD_VISITOR_SALT_ROTATION_DAYS |
1 |
Days a visitor-ID salt stays valid. See the note under Privacy by Design |
MALLARD_SESSION_WINDOW_MINUTES |
30 |
Inactivity gap that ends a session |
MALLARD_REALTIME_WINDOW_MINUTES |
5 |
Window the realtime endpoint treats as "now" |
MALLARD_COMPACT_AFTER_FILES |
24 |
Merge a partition's Parquet files past this count (0 disables) |
MALLARD_READ_CONNECTIONS |
4 |
Read-only DuckDB connections for analytics queries |
MALLARD_MAX_BUFFERED_EVENTS |
100000 |
Hard cap on buffered events (0 = unbounded) |
MALLARD_MAX_TRACKED_KEYS |
10000 |
Cap on rate-limit buckets and login-attempt records |
MALLARD_MAX_SESSIONS |
10000 |
Cap on concurrent dashboard sessions |
MALLARD_RATE_LIMIT_PER_IP |
0 |
Ingest events per second per client IP (0 = unlimited) |
MALLARD_EXTENSION_DIR |
data_dir/extensions |
Where DuckDB installs community extensions. Kept on the data volume because the FROM scratch image has no HOME and a read-only root filesystem |
MALLARD_DUCKDB_MEMORY_LIMIT |
(DuckDB default: ~80% of RAM) | Cap on query memory, e.g. 1GB |
MALLARD_DUCKDB_THREADS |
(one per core) | DuckDB worker threads |
Every setting is also available in the TOML file; see
mallard-metrics.toml.example for the full list
with commentary. Unknown keys in that file are a startup error, so a typo is
reported rather than silently ignored.
All /api/stats/*, /api/keys/*, and /api/stats/export endpoints require authentication (session cookie or API key). The ingestion endpoint and health checks are unauthenticated.
| Parameter | Default | Description |
|---|---|---|
site_id |
(required) | Analytics property identifier |
period |
30d |
Time period: day, today, 7d, 30d, 90d, 12mo |
start_date |
(none) | Explicit start date, YYYY-MM-DD, inclusive |
end_date |
(none) | Explicit end date, YYYY-MM-DD, inclusive |
limit |
10 |
Row limit where the endpoint returns a list (max 1000) |
filters |
(none) | Segment, e.g. browsers==Chrome;countries!=US |
An explicit range may span at most 366 days. A limit above the endpoint's
maximum is an error rather than a silent clamp.
| Method | Endpoint | Description |
|---|---|---|
| GET | /health |
Liveness check (returns ok) |
| GET | /health/ready |
Readiness probe — queries DuckDB; returns 503 if not ready |
| GET | /health/detailed |
JSON system status (version, buffer, auth, GeoIP, behavioral extension, cache) |
| GET | /metrics |
Prometheus metrics (text/plain; version=0.0.4) |
| GET | /robots.txt |
Crawler policy |
| GET | /.well-known/security.txt |
RFC 9116 security contact |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/setup |
First-run admin password setup |
| POST | /api/auth/login |
Login with credentials |
| POST | /api/auth/logout |
Logout and clear session |
| GET | /api/auth/status |
Check authentication status |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/event |
Ingest a tracking event (permissive CORS, 64 KB body limit) |
| GET | /api/event |
Pixel tracking — same parameters via query string; returns 1×1 GIF |
Every stats endpoint accepts site_id, a date range (period, or
start_date/end_date), and filters — a segment such as
browsers==Chrome;countries!=US that narrows the whole report.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/sites |
Site IDs that have data |
| GET | /api/stats/main |
Visitors, pageviews, events, and (with the extension) visits, bounce rate and visit duration. Accepts compare=previous_period or compare=year_over_year, adding a comparison object with the same figures for an equally long earlier window |
| GET | /api/stats/timeseries |
Gap-filled visitor and pageview counts per bucket |
| GET | /api/stats/realtime |
Current visitors, top pages and sources, per-minute series. Takes filters but not a date range: its window is realtime_window_minutes |
| GET | /api/stats/revenue |
Revenue by currency, event and page |
| GET | /api/stats/goals |
Conversion rate for every custom event |
| GET | /api/stats/properties |
Custom property keys seen in range |
| GET | /api/stats/property-values |
One property (key), broken down by value; optional event filter |
| GET | /api/stats/breakdown/{dim} |
Breakdown by any dimension (below) |
Breakdown dimensions: pages, entry-pages*, exit-pages*, referrers,
sources, countries, regions, cities, browsers, browser-versions,
os, os-versions, devices, screen-sizes, utm-sources, utm-mediums,
utm-campaigns, utm-contents, utm-terms, events.
* needs the behavioral extension.
Session-derived fields in /api/stats/main are null — not 0 — when the
extension is unavailable, so "no sessions" is distinguishable from "could not
be computed".
| Method | Endpoint | Parameters | Description |
|---|---|---|---|
| GET | /api/stats/sessions |
— | Visits, average duration, pages per visit, bounce rate |
| GET | /api/stats/funnel |
steps, window, modes |
Cumulative funnel: visitors reaching at least each step, with drop-off |
| GET | /api/stats/retention |
weeks (2–32) |
Weekly cohorts with retained counts and rates |
| GET | /api/stats/sequences |
steps (2–32) |
Ordered pattern matching, with a repeat-completion count |
| GET | /api/stats/flow |
page, direction, limit |
Where visitors went next, or came from |
These return 503 with an explanation when the behavioral extension is not
loaded, rather than an empty 200 that reads as "no data".
funnel accepts any combination of the extension's ordering modes:
strict, strict_deduplication, strict_order, strict_increase,
strict_once, allow_reentry, timestamp_dedup.
retention also returns identity_supports_cohorts and, when false, a caveat
explaining that the configured visitor_salt_rotation_days is shorter than the
cohorts being measured — so every week past the first is structurally zero.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/stats/export |
Export analytics data (format=csv or format=json) |
| DELETE | /api/gdpr/erase |
Erase all events for a site within a date range (Art. 17 erasure) |
| POST | /api/keys |
Create an API key |
| GET | /api/keys |
List all API keys |
| DELETE | /api/keys/{hash} |
Revoke an API key |
flowchart TD
TS["Tracking Script\nmallard.js ~3.8KB gzipped"]
DASH["Dashboard SPA\nPreact + HTM"]
TS -->|"POST /api/event"| AXUM
DASH <-->|"GET /api/stats/*"| AXUM
subgraph BINARY["Single Binary — Single Process"]
AXUM["Axum HTTP Server\nport 8000"]
subgraph INGEST["Ingestion Pipeline"]
direction LR
OC["Origin + Rate Limit"] --> BF["Bot Filter + UA Parser"]
BF --> GEO["GeoIP + Visitor ID Hash"]
GEO --> BUF["In-Memory Buffer"]
end
subgraph STORE["Two-Tier Storage"]
direction LR
DB["DuckDB disk-based\nmallard.duckdb"] -->|"COPY TO"| PQ["Parquet Files\ndate-partitioned ZSTD"]
DB --> VIEW["events_all VIEW\nhot union cold"]
PQ -->|"read_parquet()"| VIEW
end
subgraph QUERY["Query Engine"]
direction LR
CACHE["TTL Query Cache"] --> QH["Stats + Sessions\nFunnels + Retention\nSequences + Flow"]
end
AXUM --> OC
BUF -->|"flush"| DB
VIEW --> CACHE
QH --> AXUM
end
| Module | Purpose |
|---|---|
config.rs |
TOML and environment configuration, validation, and startup advisories |
server.rs |
Axum router, middleware stack, health and Prometheus endpoints |
ingest/handler.rs |
Shared event validation and enrichment for the POST and pixel paths |
ingest/buffer.rs |
Bounded in-memory event buffer with atomic drain-and-flush |
ingest/visitor_id.rs |
Per-site HMAC-SHA256 visitor IDs with configurable salt rotation |
ingest/useragent.rs |
User-Agent and Client Hints parsing, bot detection |
ingest/geoip.rs |
MaxMind GeoLite2 reader with graceful fallback |
ingest/ratelimit.rs |
Bounded token-bucket limiter, used per site and per client IP |
storage/mod.rs |
Read-connection pool for analytics queries |
storage/schema.rs |
Table definitions, the events_all view, behavioral extension loading |
storage/parquet.rs |
Atomic Parquet writes, date partitioning, compaction, retention |
storage/migrations.rs |
Schema versioning |
query/mod.rs |
QueryScope — the parameters every analytics query shares |
query/metrics.rs |
Core metrics and session aggregates, in two passes rather than four |
query/breakdowns.rs |
Twenty breakdown dimensions, including session-derived entry/exit pages |
query/timeseries.rs |
Gap-filled time bucketing |
query/realtime.rs |
Current visitors, top pages and sources, per-minute series |
query/revenue.rs |
Revenue by currency, event and page |
query/events.rs |
Goal conversions and custom-property breakdowns |
query/export.rs |
Daily and raw event exports, CSV and JSON rendering |
query/funnel.rs |
window_funnel() — cumulative funnels with ordering modes |
query/retention.rs |
retention() — per-visitor cohort counts |
query/sequences.rs |
sequence_match() and sequence_count() |
query/flow.rs |
sequence_next_node(), forward and backward |
query/cache.rs |
TTL cache with LRU eviction and per-site invalidation |
api/stats.rs |
Analytics handlers, parameter validation, GDPR erasure |
api/errors.rs |
Error types and their HTTP mapping |
api/auth.rs |
Argon2id auth, sessions, API keys, brute-force protection |
dashboard/ |
Embedded SPA (Preact + HTM, no build step) and the tracking script route |
test_support.rs |
AppState builder shared by unit and integration tests |
The dashboard is a single-page application built with Preact + HTM, embedded directly in the binary via rust-embed. No build step or Node.js required.
Views include:
- Visitor and pageview counts with period selector
- Time-series line chart (visitors and pageviews)
- Six breakdown tables (pages, sources, browsers, OS, devices, countries)
- Session analytics cards (total sessions, avg duration, pages/session)
- Funnel analysis visualization (horizontal bar chart)
- Retention cohort grid (weekly cohort boolean matrix)
- Sequence matching conversion metrics
- Flow analysis (next-page navigation table)
- CSV and JSON export buttons
- Period comparison (previous period or same period last year) with per-metric change
- API key management — create, list and revoke keys, with the plaintext shown once
| Component | Technology | Version |
|---|---|---|
| Language | Rust | 1.98.0 (edition 2024) |
| Web Framework | Axum | 0.8 |
| Database | DuckDB (disk-based, embedded) | 1.5.5 (crate 1.10505.0) |
| Analytics Engine | behavioral extension |
0.9.1, runtime-loaded; published per DuckDB version, currently 1.5.5 |
| Storage Format | Parquet (ZSTD compressed) | date-partitioned, compacted |
| Frontend | Preact + HTM | no build step |
| Password Hashing | Argon2id | argon2 0.6 |
| Constant-time comparison | subtle |
2.6 |
| GeoIP | MaxMind GeoLite2 | maxminddb 0.30 |
| Deployment | Static musl binary | FROM scratch Docker |
- Rust 1.98.0 (installed automatically via
rust-toolchain.toml) - Git
# Build
cargo build
# Run all tests
#
# Behavioral-extension tests skip when the extension cannot be downloaded.
# Set MALLARD_REQUIRE_BEHAVIORAL=1 to make that a failure instead, as CI does.
cargo test
# Clippy (zero warnings required)
cargo clippy --all-targets
# Format check
cargo fmt -- --check
# Build documentation
cargo doc --no-deps
# Run the server
cargo run
# Run benchmarks
cargo bench- Zero clippy warnings — pedantic, nursery, and cargo lint groups enabled
- Zero formatting violations — enforced via
cargo fmt - All 602 tests pass — no ignored tests
- Documentation builds without errors
See CONTRIBUTING.md for the full development workflow.
Create a .env file (do not commit to source control):
MALLARD_SECRET=your-random-32-char-secret
MALLARD_ADMIN_PASSWORD=your-strong-dashboard-password
MALLARD_SECURE_COOKIES=true
MALLARD_METRICS_TOKEN=your-prometheus-bearer-tokenThen start:
docker compose up -dserver {
listen 443 ssl;
server_name analytics.example.com;
ssl_certificate /etc/ssl/certs/analytics.example.com.crt;
ssl_certificate_key /etc/ssl/private/analytics.example.com.key;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}- Register for a free MaxMind account at maxmind.com
- Download the GeoLite2-City database (
.mmdbformat) - Set
MALLARD_GEOIP_DB=/path/to/GeoLite2-City.mmdb
If the GeoIP database is missing, country/region/city fields are stored as NULL. The system degrades gracefully — no errors are raised.
The behavioral extension is downloaded from the DuckDB community repository on
first run, so a deployment whose runtime network has no outbound route — the
bundled production compose file is one, on purpose — cannot install it, and
funnels, retention, sessions, sequences and flow answer 503. Seed it once from
somewhere with a route, against the same data directory:
mallard-metrics --install-extension
# Installed the behavioral extension (version 0.9.1) into data/extensionsIt writes the extension to the data volume and exits without creating a database. Every later start loads it from disk. See the VPS guide for the Docker form.
| Document | Description |
|---|---|
| GitHub Pages | Full documentation site — API reference, architecture, deployment, security |
| PRIVACY.md | Data-processing architecture, GDPR/ePrivacy/CCPA analysis, operator compliance obligations |
| CONTRIBUTING.md | Development setup, workflow, code standards, PR checklist |
| SECURITY.md | Security model, privacy guarantees, threat model, vulnerability reporting |
| CHANGELOG.md | Version history following Keep a Changelog format |
| ROADMAP.md | Implementation phases, completed work, and future plans |
| PERF.md | Benchmark framework, methodology, and measured baselines |
| LESSONS.md | 31 development lessons learned, organized by category |
| DEVELOPMENT.md | Module map, build commands, and development guidelines |
| mallard-metrics.toml.example | Annotated configuration template |
AGPL-3.0 — see LICENSE for the full text.
Mallard Metrics is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3.
