Every query, every click, every AI rescue.
An append-only event table feeds seven dashboard tabs, a live activity feed, a latency histogram with real percentiles, and the AI Coach that turns the data into one-click fixes.
One row per search. No PII, ever.
Every query writes one row to search_events — an append-only
table. Skryx never stores IPs, user agents, or user IDs; the data is
aggregated at query time, not pre-hashed. Click signal arrives as a
nullable update on the same row.
// search_events row { "tenant_id": 42, "index_id": 7, "query": "wireless headphones", "results_count": 142, "clicked_doc_id": "sku-001", "clicked_position": 2, "search_mode": "hybrid", "ai_enhanced": true, "ai_interventions": { "typo_fix": false, "query_rewrite":false, "synonym_used": true }, "response_time_ms": 18, "embed_time_ms": 3, "dwell_time_ms": 8200, "quality_score": 0.87, "occurred_at": "2026-05-25T17:14:02Z" }
Twelve columns.
Designed for triage, not surveillance.
The shape is deliberately small: query text + result count + click position + mode + AI intervention flags + latency. Anything beyond that isn't worth storing. Click events are not a separate table — they arrive as a post-fact update on the original search row, so query and outcome stay correlated without a join.
- Append-only:
UPDATED_ATis null on the model — clicks update via explicit UPDATE, not Eloquent magic - Indexed: on (tenant, occurred_at), (index, results_count), (tenant, ai_enhanced, occurred_at), (tenant, search_mode)
- No IP, no UA, no user ID — aggregation happens at SQL
GROUP BYtime
Seven tabs. Each one tells a different story.
The four numbers your boss asks for.
Total searches, zero-result count, average latency, AI-enhanced count. Plus a volume timeseries chart, granularity auto-picked: hourly for ranges ≤ 24h, daily for longer. No pre-computed rollups — Skryx aggregates on demand, so the data is always current.
- Range presets: 24h / 7d / 30d / 90d
- JSON API endpoint:
/api/dashboard/timeseries
Real p50 / p75 / p95 / p99.
Histogram with 10ms buckets.
Latency percentiles computed from the actual response_time_ms
column at query time — no sampling, no approximation. Histogram covers
0–500 ms in 10 ms buckets plus a tail bucket for everything above 500 ms.
Slowest queries listed separately with their query text for inspection.
- p50, p75, p95, p99 + mean latency
- 50-bucket histogram (0–500 ms) +
tail_500ms_pluscount - Top 20 slowest queries with average latency and last-seen timestamp
Sortable, paginated, drillable.
Paginated table (25/page) of unique queries: volume, average results, average latency, CTR clicks, last seen. Sort by any column. Click into any query to see the result set Skryx returned during the period. Plus a day-of-week × hour heatmap (7 × 24) so you can spot traffic patterns at a glance.
- Sort: count / latency / zero-results / recent
- Heatmap: weekly traffic shape, 168 cells
- JSON:
/api/analytics/queries,/api/analytics/heatmap
Skryx AI's contribution, quantified.
A dedicated tab counts every AI intervention by family:
typo_fix, synonym_used, query_rewrite,
semantic_dispatched, hybrid_dispatched. The
headline metric — queries rescued from zero results —
counts every search where keyword would have returned nothing but the
AI pipeline produced hits.
- Per-intervention breakdown (5 families)
- AI-rescued zero-results count
- JSON:
/api/analytics/ai
The queries you're losing customers on.
Volume-sorted list of zero-result queries with AI-rescued count split out. This is the table AI Coach reads to suggest synonyms and catalog gaps — so every recommendation has a direct link back to the underlying queries.
- Zero-result rate (%) + count
- AI-rescued count (how many became non-zero via Skryx AI)
- One-click jump to "Add synonym" with the query pre-filled
Curated rules + their impressions / CTR.
Every Visual Curator rule (pin / replace / hide) records impressions, clicks, and which trigger fired. A separate tab in the analytics surface shows per-rule CTR plus last-fired timestamps so editorial decisions stay accountable to traffic data.
- Per-rule: impressions, clicks, CTR, last fired, active flag
- Built-in A/B comparison for rule variants
- Same data via the per-rule analytics API endpoint
Two clever bits worth calling out.
Distinguishes typos from rewrites.
If the engine's final query differs from the original, Skryx checks
Levenshtein distance. ≤ 2 edits and not already marked
as an LLM rewrite → flagged as typo_fix with from/to
recorded. Anything farther afield is a query_rewrite.
That separation makes the AI Impact tab honest about what's helping.
{
"original_query": "sneekers",
"final_query": "sneakers",
"levenshtein": 1,
"ai_interventions": {
"typo_fix": {
"from": "sneekers",
"to": "sneakers"
}
}
}
Every chart is a queryable endpoint.
Five endpoints cover the dashboard: timeseries, latency histogram, queries, heatmap, AI breakdown. All return JSON, all accept a period parameter. Pipe them into Metabase, Looker, or a homegrown report — Skryx doesn't try to be your BI tool.
/api/dashboard/timeseries/api/analytics/latency-histogram/api/analytics/queries(paginated)/api/analytics/heatmap(7 × 24)/api/analytics/ai
GET /api/analytics/queries
?period=7d&page=1&sort=count
{ "rows": [
{ "q": "sneakers",
"vol": 1420,
"hits": 142,
"ctr": 0.42,
"avg_ms": 17,
"last_seen": "2026-05-25T17:12Z" },
…
],
"page": 1,
"per_page": 25 }
EU-hosted. No PII. No third-party trackers.
Storage in Frankfurt. No IP, user-agent, or user-id captured. Click data
arrives as a nullable update on the existing row, not a separate event
broadcast. Aggregation happens at SQL GROUP BY time, so
individual searches never appear in dashboard counters.
/api/analytics/* for every dashboard view