# luchy

> Privacy-friendly web and product analytics. Read metrics, people, customers and live visitors of a project, or run read-only SQL.

Call a method with `POST https://dash.luchy.app/api/<service>.<method>`, a JSON body (none when it takes no input) and `Authorization: Bearer <wak_… key>`.
Errors are JSON `{ error }`; invalid input is a 400 `{ error, fields }`. Dates are ISO 8601 strings.
OpenAPI 3.1: https://dash.luchy.app/openapi.json

## analytics

Read the analytics of the current project.

### analytics.query

Metrics for the project over a time range: totals, an optional breakdown, time series and a comparison with another period.

Every requested metric is computed. visitors = distinct coalesce(user, visitor_hash); users = distinct identified users; avg_duration in seconds; bounce_rate in %.

Add `granularity` for a zero-filled time series and `compare: "previous_period"` to also get the period before with deltas; the result renders as a chart.

Example — who used the app this week vs last: `{ metrics: ["users", "events"], granularity: "day", compare: "previous_period" }`.

Anything this cannot express goes through `analytics.sql`.

Permission: `analytics:read`.
Input:
- `from`: string, optional. Inclusive start (ISO 8601). Default: 7 days before `to`.
- `to`: string, optional. Exclusive end (ISO 8601). Default: now.
- `tz`: string, optional. IANA timezone for buckets, hour and weekday. Default UTC.
- `metrics`: "events" | "pageviews" | "sessions" | "visitors" | "users" | "avg_duration" | "bounce_rate"[], required. e.g. ["pageviews", "visitors"]
- `breakdown`: "page" | "event" | "type" | "source" | "referrer" | "utm_source" | "utm_medium" | "utm_campaign" | "country" | "device" | "browser" | "os" | "tenant" | "user" | "actor" | "hour" | "weekday" | string, optional. A fixed field or `props.<key>` (a key of the event payload). `hour` and `weekday` (0 = Sunday) are in `tz`. `utm_*` are first-touch UTMs of the session; `source` is utm_source, else the referrer host, else "direct".
- `filters`: any[][], optional. [field, op, value]; `in` takes a list. e.g. [["props.role", "eq", "admin"]]
- `granularity`: "hour" | "day" | "week" | "month", optional. Adds a zero-filled time series
- `compare`: string | object, optional. Also runs the query on another range and returns deltas
- `limit`: integer, optional. Max breakdown rows (default 100)

### analytics.sql

Run one read-only SELECT or WITH statement over the analytics database, for anything `analytics.query` cannot express.

Not scoped to a project: filter on `project_id = ?` and pass the current project's id (MCP: it's in the server instructions) in `params`. Rows are capped by `limit`.

Luchy analytics database (SQLite / Cloudflare D1). Read-only: one SELECT or WITH statement.
Timestamps are ISO-8601 UTC strings ('2025-01-31T12:00:00.000Z'); compare them as strings or use datetime()/strftime().
Not scoped to a project: filter on project_id.

projects — one tracked site/app
  id TEXT PK, name TEXT, created_at TEXT

sessions — one visit by one (user, actor) or one anonymous visitor; 30 min idle timeout, 24 h cap
  id TEXT PK, project_id TEXT -> projects.id
  visitor_hash TEXT   anonymous browser id (sha256 of a daily salt, project, ip, user agent); rotates daily
  user TEXT           app-provided user id (signed identity), NULL when anonymous
  actor TEXT          the real person when impersonating (user = who is viewed as), else NULL
  started_at TEXT, last_seen_at TEXT   duration = (julianday(last_seen_at) - julianday(started_at)) * 86400 seconds
  browser TEXT, os TEXT, device TEXT ('desktop' | 'mobile' | 'tablet' | ...), country TEXT (ISO 3166-1 alpha-2), language TEXT
  entry_page TEXT     pathname of the first event
  utm_source TEXT, utm_medium TEXT, utm_campaign TEXT   first-touch UTMs of the session, NULL when none

events — everything that happened, pageviews included
  id TEXT PK, project_id TEXT -> projects.id, session_id TEXT -> sessions.id
  type TEXT           'pageview' for pageviews; otherwise sender-defined ('server', 'click', ...) or NULL
  name TEXT           'pageview' for pageviews; event name otherwise ('__pageleave' marks a page exit)
  pathname TEXT, referrer TEXT
  tenant TEXT         the consumer's client/workspace from the signed identity
  payload TEXT        JSON object of props: json_extract(payload, '$.role')
                      pageviews carry every utm_* of their URL (utm_source, utm_medium, utm_campaign, utm_term, utm_content)
  created_at TEXT

people — person profiles sent via identify; one row per (project, user)
  project_id TEXT -> projects.id, user TEXT (same id as sessions.user)
  traits TEXT         JSON object (name, email, ...): json_extract(traits, '$.email')
  created_at TEXT, updated_at TEXT

Conventions
  visitors = COUNT(DISTINCT coalesce(s.user, s.visitor_hash)); users = COUNT(DISTINCT s.user)
  source = coalesce(s.utm_source, referrer host without 'www.', 'direct'), lowercased; known sources folded (google.com and utm_source=google → google, ig and l.instagram.com → instagram, …)
  Join: FROM events e JOIN sessions s ON s.id = e.session_id
  Profiles: LEFT JOIN people p ON p.project_id = s.project_id AND p.user = s.user
  Alias duplicate column names: rows are objects keyed by column name.

Permission: `analytics:read`.
Input:
- `query`: string, required. e.g. SELECT tenant, COUNT(*) AS actions FROM events WHERE project_id = ? GROUP BY tenant
- `params`: string,number,boolean,null[], optional. Bound to `?` placeholders in order
- `limit`: integer, optional. Max rows (default 1000)

### analytics.people

Identified people active in the period or the one before, with sessions and actions this period vs the previous one. Quiet people (active before, not now) come last.

Permission: `analytics:read`.
Input:
- `from`: string, optional. Inclusive start (ISO 8601). Default: 7 days before `to`.
- `to`: string, optional. Exclusive end (ISO 8601). Default: now.
- `tz`: string, optional. IANA timezone for buckets, hour and weekday. Default UTC.
- `tenant`: string, optional. Only people acting for this customer
- `limit`: integer, optional

### analytics.customers

Customers (the `tenant` of events) active in the period or the one before: people and actions this period vs the previous one.

Permission: `analytics:read`.
Input:
- `from`: string, optional. Inclusive start (ISO 8601). Default: 7 days before `to`.
- `to`: string, optional. Exclusive end (ISO 8601). Default: now.
- `tz`: string, optional. IANA timezone for buckets, hour and weekday. Default UTC.
- `limit`: integer, optional

### analytics.person

One person, all time: profile traits, customers, who acted as them, and their latest sessions with what they did.

Permission: `analytics:read`.
Input:
- `user`: string, required. The app-provided user id
- `sessions`: integer, optional. Latest sessions to include (default 30)

### analytics.live

Who is on the site right now: open sessions with their current page, top pages, countries and referrers.

Permission: `analytics:read`.
Input:
- `windowMinutes`: integer, optional. How recent a pageview counts as live (default 5)

## admin

Projects and keys. Owner only.

### admin.projects

List every project (tracked site or app) with its id. Use the id as ?project=<id> on /mcp or /api to switch project.

Permission: `admin`.
No input.

### admin.keys

The current project's ingest keys (public, for the browser script) and its secret key (signs identities, authenticates identify).

Query keys (`wak_…`) live in the willy.im IdP and are managed from the dashboard; they are not listed here.

Permission: `admin`.
No input.
