Quick Start
EXOS Analytics is an operational intelligence platform built on ClickHouse Cloud, HyperDX, and AKS. It unifies session replay, product analytics, process mining, and AI-powered querying into a single platform -- replacing tools that would cost $200K+/yr from Amplitude, FullStory, Celonis, and others.
Explore the live portal
Start with Process Mining to see operator patterns, then jump to Session Replay for full DOM recordings.
Ask a question in natural language
Open Ask AI and try: "Which operators handle the most orders?" or "Show me sessions with rage clicks"
Read the architecture docs
Dive into the documents below for system architecture, data model, storage tiering, and competitive analysis.
Process Mining
Operator Patterns leaderboard and Order Lifecycle funnel analysis
Sessions
Full DOM session replay with drill-down tabs and frustration signals
Product Analytics
Sessions, visitors, top pages, browser/OS breakdowns, user journeys
Site Health
Error rates, Web Vitals, long tasks, console errors, HTTP monitoring
Architecture Overview
All browser telemetry flows through a single pipeline: HyperDX SDK captures DOM snapshots, traces, and console logs, sends them through an OpenTelemetry Collector, and lands them in three ClickHouse tables. The portal and API layer read from those tables to power every view.
EXOS Analytics -- System Architecture
+------------------+ +-------------------+ +------------------------+
| Browser Client | OTEL | OTEL Collector | Batch | ClickHouse Cloud |
| (HyperDX SDK) | ------> | collector.exos- | ------> | Azure East US 2 |
| | HTTP | demo.com | Insert | |
+------------------+ +-------------------+ | +------------------+ |
| | | otel_traces | |
| Captures: | | (clicks, HTTP, | |
| - DOM snapshots (rrweb) | | page loads, | |
| - User interactions | | domain events) | |
| - HTTP requests | +------------------+ |
| - Console output | | hyperdx_sessions | |
| - Web Vitals | | (rrweb DOM | |
| - Domain events | | recordings) | |
| +------------------+ |
| | otel_logs | |
| | (console output) | |
+------------------+ +-------------------+ | +------------------+ |
| Static Portal | API | Node.js API | SQL | |
| (Vercel) | <-----> | (AKS) | <-----> | Join key: |
| Vanilla HTML | REST | 21 endpoints | Query | rum.sessionId |
+------------------+ +-------------------+ +------------------------+
|
| /api/insights calls
v
+-------------------+
| OpenAI GPT-4.1 |
| (Ask AI) |
+-------------------+
ClickHouse Cloud
Columnar OLAP on Azure East US 2. Sub-second queries at $170/mo.
HyperDX SDK
OTEL-compatible browser SDK: DOM replay, traces, console, network.
OTEL Collector
Receives telemetry, batches inserts to ClickHouse.
Node.js API (AKS)
21 endpoints for stats, sessions, insights, funnels, OCPM, and more.
Static Portal (Vercel)
Vanilla HTML + Tailwind CSS. No build step, no framework.
Ask AI (GPT-4.1)
Natural language to SQL. Conversation memory with follow-ups.
Documentation
Architecture documents covering system design, data patterns, competitive positioning, and operational intelligence techniques.
System Architecture
End-to-end system design: data flow from HyperDX SDK through OTEL Collector to ClickHouse Cloud, API layer on AKS, and static portal on Vercel.
ArchitectureProduct Analytics on ClickHouse
How to build PostHog/Amplitude-class product analytics using ClickHouse SQL: sessions, funnels, paths, retention, and cohort analysis patterns.
PatternsClickStack Alignment
How EXOS aligns with ClickHouse's ClickStack initiative and how ServiceLink could adopt the bundled observability stack.
Strategyvs. Commercial Analytics
Comprehensive comparison against PostHog, Amplitude, FullStory, Clarity, Celonis, Pendo, Heap, Mixpanel, and GA360 across 12 dimensions.
ComparisonObject-Centric Process Mining
First ClickHouse-native OCPM implementation using standard SQL -- cross-object patterns, vendor contention, and category interaction graphs.
Process MiningOperational Intelligence Playbook
Universal techniques for process mining, anomaly detection, and high-cardinality analysis -- ClickHouse examples with platform-agnostic patterns.
PlaybookStorage Architecture
ClickHouse internals, MergeTree mechanics, data retention policies, TTL-based tiering, and cost optimization for columnar storage.
StorageAsk AI
Natural-language SQL interface powered by GPT-4.1 with schema awareness, conversation memory, and self-documenting ClickHouse primitives.
AIData Architecture
All browser telemetry is captured by the HyperDX Browser SDK and sent to the OTEL Collector at collector.exos-demo.com, which writes to three ClickHouse tables. Every table shares a common join key: ResourceAttributes['rum.sessionId'].
Data Flow
graph TD
A[Browser SDK HyperDX] -->|DOM snapshots
rrweb format, video replay| B[hyperdx_sessions
~33K events]
A -->|User events
clicks, HTTP, page loads, domain events| C[otel_traces
~18K spans]
A -->|Console output
log, warn, error, debug| D[otel_logs
~21K logs]
classDef default fill:#1e1e2e,stroke:#333,color:#d4d4d4;
classDef edgeLabel fill:#1e1e2e,color:#d4d4d4,stroke:none;
All joined by: ResourceAttributes['rum.sessionId']
otel_traces
Every user interaction, HTTP request, page load, performance event, and domain event. This is the primary analytics table.
Key columns
| Column | Type | Description |
|---|---|---|
| Timestamp | DateTime64 | Event time (nanosecond precision) |
| SpanName | String | Event type (see taxonomy below) |
| Duration | UInt64 | Span duration in nanoseconds |
| SpanAttributes | Map(String, String) | Event-specific metadata (http.url, visitor.*, operator.*, etc.) |
| ResourceAttributes | Map(String, String) | Session-level metadata (rum.sessionId, service.name, etc.) |
SpanName taxonomy
| Category | SpanName | Count | Meaning |
|---|---|---|---|
| User Interactions | |||
| click | 1,123 | Mouse click on any element | |
| mousedown | 305 | Mouse button pressed | |
| mouseup | 286 | Mouse button released | |
| Network | |||
| HTTP POST | 10,869 | Outbound HTTP POST (API calls) | |
| HTTP GET | 829 | Outbound HTTP GET (page/asset loads) | |
| resourceFetch | 1,412 | Resource timing (scripts, stylesheets, images) | |
| Performance | |||
| longtask | 4,668 | Main thread blocked >50ms | |
| webvitals | 283 | Core Web Vitals (LCP, FID, CLS, TTFB) | |
| documentLoad | 149 | Full page load timing | |
| Navigation | |||
| visibility | 262 | Tab visibility change (focus/blur) | |
| page.order_detail | 519 | Navigated to order detail view | |
| page.scheduling | 373 | Navigated to scheduling view | |
| page.inbox | 356 | Navigated to inbox | |
| page.documents | 347 | Navigated to documents view | |
| Domain Events | |||
| email.read | 767 | Operator opened an email | |
| email.received | 748 | Inbound email arrived | |
| email.sent | 711 | Operator sent an email | |
| review.started | 187 | Review process initiated | |
| report.submitted | 177 | Report submitted to client | |
| Replay | |||
| record init | 149 | rrweb recording session started | |
Example query
SELECT SpanName, count() as cnt FROM otel_traces WHERE Timestamp >= now() - INTERVAL 7 DAY GROUP BY SpanName ORDER BY cnt DESC LIMIT 20
Used by: Operator Patterns, Order Lifecycle, Product Analytics, Session Replay drill-down tabs
hyperdx_sessions
Full DOM recording data captured by rrweb. Each row contains a batch of rrweb events for a session, enabling pixel-perfect video replay.
Key columns
| Column | Type | Description |
|---|---|---|
| session_id | String | Session identifier (matches rum.sessionId) |
| events | String (JSON) | Serialized array of rrweb events |
| timestamp | DateTime64 | Batch recording time |
Example query
SELECT session_id, count() as batches, min(timestamp) as started FROM hyperdx_sessions WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY session_id ORDER BY started DESC LIMIT 10
Used by: Session Replay player, activity heatbar
otel_logs
Console output captured from the browser. Every console.log(), console.warn(), console.error(), and console.debug() call is recorded.
Key columns
| Column | Type | Description |
|---|---|---|
| Timestamp | DateTime64 | When the log was emitted |
| SeverityText | String | info (13,199), debug (7,422), warn (27) |
| Body | String | Log message content |
| ResourceAttributes | Map(String, String) | Session-level metadata (rum.sessionId, etc.) |
Example query
SELECT SeverityText, count() as cnt FROM otel_logs WHERE Timestamp >= now() - INTERVAL 7 DAY GROUP BY SeverityText ORDER BY cnt DESC
Used by: Session Replay Console tab, Site Health error groups
Visitor Enrichment
On every page load, the portal calls /api/visitor to resolve the user's IP into geo, device, and network attributes via HyperDX.setGlobalAttributes().
| Attribute | Source | Example |
|---|---|---|
| visitor.city | geo.city | Chicago |
| visitor.region | geo.region | Illinois |
| visitor.country | geo.country | US |
| visitor.org | network.asOrganization | Comcast Cable |
| visitor.asn | network.asn | 7922 |
| visitor.browser | device.browser | Chrome |
| visitor.browser_version | device.browserVersion | 122.0.0.0 |
| visitor.os | device.os | macOS |
| visitor.os_version | device.osVersion | 14.3.1 |
| visitor.device_type | device.type | desktop |
| visitor.ip | visit.ip | 73.162.x.x |
| visitor.language | visit.acceptLanguage | en-US |
| visitor.returning | visit.isReturning | true |
| visitor.visit_count | visit.visitCount | 14 |
| visitor.referer | request.referer | https://google.com/ |
Session Identity
When a user signs in via Firebase Auth, their identity is written to all subsequent spans using HyperDX.setGlobalAttributes():
Session Replay Architecture
Session replay reconstructs a pixel-perfect video of what the user saw and did. It combines rrweb DOM snapshots from hyperdx_sessions with behavioral telemetry from otel_traces and otel_logs.
How rrweb Records
The HyperDX SDK uses rrweb (v2.0.0-alpha.4) to capture the DOM. Three event types matter:
type 4 (Meta) -- Records viewport dimensions and page URL. Emitted at session start and on each navigation.
type 2 (Full Snapshot) -- Complete DOM serialization. The "keyframe" the replayer needs to build the page.
type 3 (Incremental Snapshot) -- Diffs: DOM mutations, mouse movements, scroll positions, input changes, viewport resizes.
How the Replayer Works
The rrweb Replayer reconstructs the page inside a sandboxed <iframe>. It deserializes the full snapshot (type 2) to build the initial DOM, then applies incremental events (type 3) in timestamp order. Compiled Tailwind CSS is injected so the replay retains original styling.
Seek-to-Time Mapping
When you click a row in any drill-down tab, the player jumps to that moment:
offset_ms = otel_traces.Timestamp - rrweb_first_event.timestamp
The absolute timestamp from otel_traces is converted to a millisecond offset from the first rrweb event. Implemented in seekToAbsTime().
Activity Heatbar
48px SVG waveform below the replay video showing activity density.
Bucketing: Up to 400 time buckets (min 50, 2px per bucket).
Weighted signals: click: 1.0, mousedown: 0.8, HTTP POST: 0.6, HTTP GET: 0.5, resourceFetch: 0.4, longtask: 1.5, documentLoad: 2.0. Scroll: 0.2.
Rendering: Catmull-Rom spline SVG. Errors = red circles, rage clicks = amber triangles, navigations = blue diamonds.
Interaction: Click to seek. White playhead tracks current position.
Drill-Down Tabs
Five tabs, each querying a different slice. Every row has a clickable timestamp that seeks the replay.
| Tab | Data Source | SpanName Filter |
|---|---|---|
| Events | otel_traces | click, mousedown, mouseup, visibility, page.* |
| Network | otel_traces | HTTP POST, HTTP GET, resourceFetch |
| Console | otel_logs | All severity levels |
| Errors | otel_logs + otel_traces | SeverityText = 'error' + error-status traces |
| Perf | otel_traces | webvitals, longtask, documentLoad |
Frustration Signal Detection
dead-click-tracker.js detects four frustration signals via HyperDX.addAction():
| Signal | Action | Rule |
|---|---|---|
| Rage Click | rage_click | >4 clicks on same element within 2s |
| Dead Click | dead_click | No DOM mutation within 500ms of click |
| Excessive Scroll | excessive_scroll | >3 direction changes AND >130% doc height |
| Quick Back | quick_back | A->B->A within 15 seconds |
Query Cookbook
Use Ask AI for natural language queries or write SQL directly.
Natural Language Examples
Ask
"Which operators handle the most orders?"
Follow up
"Now filter to closing LOB"
Ask
"Show me sessions with rage clicks from returning visitors"
Ask
"What are the slowest API calls this week?"
SQL Examples
Sessions with rage clicks
SELECT ResourceAttributes['rum.sessionId'] as session_id, count() as rage_count FROM otel_traces WHERE SpanName = 'rage_click' GROUP BY session_id ORDER BY rage_count DESC
Slowest HTTP requests
SELECT SpanAttributes['http.url'] as url, avg(Duration) / 1e6 as avg_ms, count() as calls
FROM otel_traces WHERE SpanName IN ('HTTP POST', 'HTTP GET')
GROUP BY url ORDER BY avg_ms DESC LIMIT 10Sessions from returning visitors in Chicago
SELECT DISTINCT ResourceAttributes['rum.sessionId'] as session_id FROM otel_traces WHERE SpanAttributes['visitor.city'] = 'Chicago' AND SpanAttributes['visitor.returning'] = 'true'
Web Vitals by page
SELECT SpanAttributes['location.href'] as page,
avg(toFloat64(SpanAttributes['lcp'])) as avg_lcp_ms,
avg(toFloat64(SpanAttributes['fid'])) as avg_fid_ms
FROM otel_traces WHERE SpanName = 'webvitals'
GROUP BY page ORDER BY avg_lcp_ms DESCConsole errors by session
SELECT ResourceAttributes['rum.sessionId'] as session_id, count() as error_count,
groupArray(10)(Body) as sample_messages
FROM otel_logs WHERE SeverityText = 'error'
GROUP BY session_id ORDER BY error_count DESC LIMIT 10Process Mining
EXOS performs legitimate process mining using ClickHouse's native sequence and window functions. No interviews, no manual process mapping, no proprietary query language.
Operator Patterns
The Operator Patterns page ranks operators by activity volume, task distribution, and automation opportunity.
Order Lifecycle
Three analysis modes:
Sequences -- Top 10 operator paths via groupArray() with AI-generated narrative insights.
Bottlenecks -- Transition timing via lagInFrame() and P95 via quantile(0.95).
Funnels -- Conversion analysis via windowFunnel().
ClickHouse Primitives
Seven functions power the analysis. The /api/insights endpoint self-documents every primitive.
| Function | Purpose | Celonis Equivalent |
|---|---|---|
| sequenceMatch() | Happy path conformance | Process Conformance |
| sequenceCount() | Rework loop detection | Rework Analysis |
| lagInFrame() | Transition timing | Bottleneck Analysis |
| windowFunnel() | Funnel conversion | Conversion Funnel |
| groupArray() | Variant paths | Process Variants |
| quantile() | P95 bottlenecks | KPI Thresholds |
| rolling z-score | Anomaly detection | Anomaly Alerts |
Process Mining SQL Examples
Conformance check
SELECT countIf(matched) as conforming, count() as total,
round(conforming / total * 100, 1) as conformance_pct
FROM (
SELECT SpanAttributes['order.id'] as order_id,
sequenceMatch('(?1)(?2)(?3)')(toDateTime(Timestamp),
SpanName = 'email.received', SpanName = 'review.started',
SpanName = 'report.submitted') as matched
FROM otel_traces WHERE SpanAttributes['order.id'] != '' GROUP BY order_id
)Rework detection
SELECT SpanAttributes['order.id'] as order_id,
sequenceCount('(?1)(?2)')(toDateTime(Timestamp),
SpanName = 'review.started', SpanName = 'revision.requested') as rework_loops
FROM otel_traces WHERE SpanAttributes['order.id'] != ''
GROUP BY order_id HAVING rework_loops > 0 ORDER BY rework_loops DESCTransition bottlenecks
SELECT step_from, step_to, round(avg(gap_hours), 1) as avg_hours,
round(quantile(0.95)(gap_hours), 1) as p95_hours
FROM (
SELECT SpanName as step_to,
lagInFrame(SpanName) OVER (PARTITION BY SpanAttributes['order.id'] ORDER BY Timestamp) as step_from,
dateDiff('hour', lagInFrame(Timestamp) OVER (PARTITION BY SpanAttributes['order.id'] ORDER BY Timestamp), Timestamp) as gap_hours
FROM otel_traces WHERE SpanAttributes['order.id'] != ''
) WHERE step_from != '' GROUP BY step_from, step_to ORDER BY p95_hours DESC LIMIT 10SDK & Instrumentation
HyperDX Browser SDK
Every page includes the HyperDX SDK for DOM replay, console, network, and interaction traces.
<script src="https://www.unpkg.com/@hyperdx/browser@0.22.0/build/index.js"></script>
<script>
window.HyperDX && window.HyperDX.init({
apiKey: 'H06oX4xamF3QHsFN0hJn', service: 'clickstack-portal',
url: 'https://collector.exos-demo.com',
consoleCapture: true, advancedNetworkCapture: true,
disableReplay: false, maskAllText: false, maskAllInputs: true,
});
</script>consoleCapture: true -- all console output to otel_logs
advancedNetworkCapture: true -- records headers/bodies for HTTP spans
disableReplay: false -- enables rrweb DOM snapshots
maskAllInputs: true -- redacts form field values for privacy
Visitor Enrichment
On page load, fetches visitor metadata and attaches to all spans:
fetch('/api/visitor').then(r => r.json()).then(v => {
window.HyperDX && window.HyperDX.setGlobalAttributes({
'visitor.country': v.geo?.country || '',
'visitor.city': v.geo?.city || '',
'visitor.org': v.network?.asOrganization || '',
'visitor.browser': v.device?.browser || '',
// ... (15 attributes total, see Data Architecture)
});
});Firebase Auth Identity Flow
Auth module attaches identity to all subsequent telemetry:
// auth.js onAuthStateChanged:
if (window.HyperDX) {
window.HyperDX.setGlobalAttributes({
'user.email': userData.email,
'user.name': userData.displayName,
'user.uid': userData.uid,
});
}Custom Domain Events
Logged via HyperDX.addAction(), appearing as spans in otel_traces:
HyperDX.addAction('rage_click', { element: 'button#submit', count: 6 });
HyperDX.addAction('email.sent', { order_id: '12345', template: 'scheduling_confirmation' });Auth Bypass for Testing
For Playwright, Cypress, or manual testing:
https://exos-demo.com?skipAuth=true
Sets localStorage.exos_skip_auth = 'true', persists across loads. Clear to re-enable auth.
Object-Centric Process Mining
OCPM extends traditional case-centric analysis by recognizing that events can belong to multiple object types simultaneously. EXOS implements the first ClickHouse-native OCPM using standard SQL -- no proprietary query language required.
Why OCPM?
Case-centric mining sees one order at a time. OCPM reveals cross-object patterns:
Vendor contention -- when multiple orders compete for the same vendor, creating invisible bottlenecks
Document dependencies -- when a shared report blocks multiple downstream orders
Cross-category handoff delays -- the actual time lost when work transitions between Communication, Portal, and System Actions
Object Categories
Eight object categories derived from SpanName prefixes -- no schema changes needed:
| Category | SpanName Prefix | Examples |
|---|---|---|
| Communication | email.* | email.read, email.sent, email.received |
| Portal | page.* | page.order_detail, page.scheduling, page.inbox |
| System Action | action.* | action.update_status, action.assign_vendor |
| Order Lifecycle | order.* | order.created, order.closed |
| Quality Control | review.* | review.started |
| Document | report.* | report.submitted |
| Field Work | inspection.* | inspection.scheduled |
| Valuation | appraisal.* | appraisal.assigned |
ClickHouse Primitives for OCPM
Five SQL queries power the OCPM dashboard, using eight ClickHouse primitives:
| Function | OCPM Purpose | Celonis Equivalent |
|---|---|---|
| arrayJoin() | Explode category arrays into co-occurrence pairs | Object Interaction Graph |
| arrayDistinct() | Deduplicate categories per order | N/A (PQL engine) |
| groupArray() | Reconstruct event sequences per category | Process Variant Analysis |
| cityHash64() | Fast variant hashing for counting | Proprietary indexing |
| topK() | Approximate top-K path patterns | Top Variants view |
| lagInFrame() | Cross-category transition timing | Bottleneck Analysis (OCPM) |
| quantile() | P50/P95 lifecycle durations | KPI Thresholds |
| dateDiff() | Object lifecycle span computation | Throughput Time |
Example: Object Interaction Query
SELECT c1 AS category_a, c2 AS category_b,
count() AS co_occurrence_count
FROM (
SELECT oid,
arrayJoin(categories) AS c1,
arrayJoin(categories) AS c2
FROM (
SELECT SpanAttributes['order.id'] AS oid,
arrayDistinct(groupArray(
CASE WHEN SpanName LIKE 'email.%' THEN 'Communication'
WHEN SpanName LIKE 'page.%' THEN 'Portal'
WHEN SpanName LIKE 'action.%' THEN 'System Action'
END
)) AS categories
FROM otel_traces
WHERE SpanAttributes['order.id'] != ''
GROUP BY oid
HAVING length(categories) >= 2
)
) WHERE c1 < c2
GROUP BY c1, c2
ORDER BY co_occurrence_count DESC
Uses arrayJoin() to create a cross-product of categories within each order, then counts co-occurrences.
Cost Comparison
| Capability | Celonis OCPM | EXOS OCPM |
|---|---|---|
| Annual cost | $100K+ | $0 incremental |
| Query language | PQL (proprietary) | Standard SQL |
| Setup time | Weeks | Zero |
| Schema changes | Required | None |
Full documentation: docs/OCPM.md
Keyboard Shortcuts
Press ? on any page. Press Esc to close.
Session Replay: Space (play/pause), Left/Right (seek), F (fullscreen).