Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Browser Analytics

Strada supports web analytics via standard OpenTelemetry. The browser SDK sends OTLP traces to the same collector used for backend observability. No separate pipeline, no separate schema, no proprietary tracker script.

Core model

Pageviews and auto-instrumented browser events are spans in otel_traces. Custom events (tracked via strada.track()) are log records in otel_logs, following OTel's direction of using the Logs API for events.
otel_traces otel_logs +-- span: pageview "/" +-- log: "button_click" | +-- span: fetch GET /api/user +-- log: "form_submit" +-- span: pageview "/pricing" +-- log: "purchase" +-- span: pageview "/dashboard"
  • Visitor = one visitor.id, cookie strada_vid. Survives tabs, login, and logout. This is unique visitors.
  • Session = one session.id, stored in sessionStorage (per-tab, survives page refreshes, cleared on tab close)
  • Pageview = a span that starts on navigation and ends when the user navigates away or closes the tab
  • First visit = visitor.first_visit=true on the first pageview of a new visitor.id
  • Custom event = a log record in otel_logs with event.name set to the event name, correlated to the current pageview when one is active
  • Auto-instrumented events (fetch, XHR, clicks) = standard OTel spans in otel_traces, usually parented to the current pageview span when no other span is active
Dashboard widgets each query a single table (MVs for pageview analytics, otel_logs for custom events). No joins needed. For advanced use cases like user timelines or mixed funnels, UNION ALL combines both tables.
For dashboard queries (top pages, top browsers, visitor counts, bounce rate), two materialized views pre-aggregate the data at ingest time into compact AggregatingMergeTree tables. This means dashboards query ~1000x fewer rows than scanning otel_traces directly, and analytics data can have its own retention (90 days) independent of raw trace retention (7 days).

Where the data comes from

Every field needed for analytics comes from standard OTel attributes. Here's the full mapping:

What the OTel browser SDK provides (no Strada code needed)

Strada reads navigator.userAgentData, navigator.language, and navigator.userAgent during initStrada() and sets these resource attributes:
Resource attributeSourceExample value
browser.platformnavigator.userAgentData.platform"macOS", "Windows"
browser.mobilenavigator.userAgentData.mobile"false"
browser.languagenavigator.language"en-US"
browser.brandsnavigator.userAgentData.brands"Google Chrome 147, Chromium 147"
user_agent.originalnavigator.userAgent"Mozilla/5.0 ..." (full UA string)
The @opentelemetry/instrumentation-document-load sets URL attributes on document load spans:
Span attributeSourceExample value
url.fullwindow.location.href"https://app.acme.com/pricing?plan=pro"
http.urlsame (deprecated)same

What the Strada browser SDK adds

The Strada SDK is a thin wrapper around the standard OTel browser SDK. It adds:
Span attributeHow it's setExample value
session.idsessionStorage per-tab UUID"f47ac10b-58cc-..."
visitor.idcookie strada_vid"f47ac10b-58cc-..."
visitor.first_visittrue on the first pageview of a new visitor"true"
url.pathwindow.location.pathname"/pricing"
url.querywindow.location.search"?plan=pro"
http.request.header.refererdocument.referrer"https://google.com"
user.idcookie strada_uid"user_123"
These are injected by a custom SpanProcessor that enriches every span before export.

What the collector injects server-side

Two attributes are not available in the browser and must be injected by the Strada collector when it receives the OTLP request:
Span attributeSourceExample valueWhy server-side?
geo.countryCF-IPCountry header"IT"IP geolocation requires a server; browsers can't resolve their own country from IP
user_agent.originalUser-Agent header"Mozilla/5.0 ..."Fallback. The browser detector sets this as a resource attribute, but the collector also injects it as a span attribute so the MV can access it uniformly from SpanAttributes without reading ResourceAttributes
The collector change is small: read two headers from the request and merge them into span_attributes of every trace row before writing to Tinybird/ClickHouse. This is useful for all traces (not just analytics), so it's a general enrichment, not analytics-specific.

Data flow summary

Browser (OTel SDK) Collector (Cloudflare Worker) +-------------------------------+ +----------------------------------+ | Resource attributes: | | | | browser.platform | | Reads from request headers: | | browser.mobile | ---> | CF-IPCountry -> geo.country | | browser.language | | User-Agent -> user_agent | | browser.brands | | | | user_agent.original | | Injects into span_attributes | | | | of every row | | Span attributes: | | | | session.id | | Writes to otel_traces | | visitor.id | | | | | url.path | | | | | url.query | | v | | url.full | | MV fires automatically | | http.request.header.referer | | otel_analytics_pages | | user.id | | otel_analytics_sessions | +-------------------------------+ +----------------------------------+
The ingestion pipeline (POST /v1/traces) does not change. Browser spans go through the same endpoint as backend spans. The only collector change is the header enrichment (2 attributes). The MVs fire automatically on every insert to otel_traces.

Session ID

const sessionId = sessionStorage.getItem('strada.session_id') ?? (() => { const id = crypto.randomUUID() sessionStorage.setItem('strada.session_id', id) return id })()
  • Per-tab, per-origin
  • session.id lives in sessionStorage. No cookie
  • Unique visitors use visitor.id: first-party cookie strada_vid, 1 year. Login writes strada_uid for the account. Logout clears strada_uid and leaves strada_vid. Data stays in the project's own Tinybird/ClickHouse. No third-party tracker
  • When the user is logged in, user.id is set as a separate attribute

Span attributes

Resource attributes (set once on SDK init)

{ "service.name": "my-app", "service.version": "1.4.2", "deployment.environment.name": "production", "browser.platform": "macOS", "browser.mobile": false, "browser.language": "en-US", "browser.brands": "Google Chrome 147, Chromium 147", "user_agent.original": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..." }
These come from Strada's inline browser detection in initStrada().

Pageview span

{ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", "parentSpanId": null, "name": "pageview", "kind": "INTERNAL", "startTimeUnixNano": "1711541826000000000", "endTimeUnixNano": "1711541871000000000", "attributes": { "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "visitor.id": "c0a80123-58cc-4372-a567-0e02b2c3d479", "url.full": "https://app.acme.com/pricing", "url.path": "/pricing", "url.query": "?plan=pro", "http.request.header.referer": "https://google.com", "user.id": "user_123", "geo.country": "IT" }, "resource": { "service.name": "my-app", "service.version": "1.4.2", "browser.platform": "macOS" } }
geo.country is injected server-side by the Strada collector from the CF-IPCountry Cloudflare header. No client-side geolocation needed.

Custom event log record

Custom events are log records in otel_logs, not spans. They are correlated to the active pageview span via TraceId and SpanId while that pageview is open.
{ "timeUnixNano": "1711541840000000000", "severityNumber": 9, "severityText": "INFO", "body": "button_click", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", "attributes": { "event.name": "button_click", "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "visitor.id": "c0a80123-58cc-4372-a567-0e02b2c3d479", "url.path": "/pricing", "url.full": "https://app.acme.com/pricing", "user.id": "user_123", "custom.element": "cta-hero", "custom.text": "Start free trial", "custom.plan": "pro" }, "resource": { "service.name": "my-app", "service.version": "1.4.2", "browser.platform": "macOS" } }
  • TraceId and SpanId point to the active pageview span when the event is emitted during an open pageview
  • event.name is the structured event name for filtering/grouping
  • custom.* prefix isolates user-defined properties from standard OTel attributes
  • session.id, visitor.id, url.path, user.id are injected automatically by the SDK's log processor
  • The log record lands in otel_logs via POST /v1/logs (same collector endpoint used for backend logs)

Auto-instrumented fetch span

{ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "b3d7c2e1f8a90b4c", "parentSpanId": "00f067aa0ba902b7", "name": "GET /api/plans", "kind": "CLIENT", "startTimeUnixNano": "1711541828000000000", "endTimeUnixNano": "1711541829450000000", "attributes": { "http.request.method": "GET", "url.full": "https://api.acme.com/api/plans", "http.response.status_code": 200, "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "visitor.id": "c0a80123-58cc-4372-a567-0e02b2c3d479" } }
This span is created automatically by @opentelemetry/instrumentation-fetch when that optional package is installed. The session.id and visitor.id are injected by the custom span processor.

Auto-instrumentation

Pageviews are automatic. Optional @opentelemetry/auto-instrumentations-web can add fetch, XHR, document-load, and click spans when you install that package:
PackageWhat it captures
instrumentation-document-loadFull page load waterfall: navigation timing, each resource (JS, CSS, images) as child spans
instrumentation-fetchEvery fetch() call -> span with method, URL, status code, duration
instrumentation-xml-http-requestEvery XHR call (Axios, legacy libs) -> same as fetch
instrumentation-user-interactionClick events -> span per click, named after the element

What you get for free on page load

span: documentLoad "/" [0ms -> 1240ms] +-- span: resourceFetch "main.js" [45ms -> 320ms] +-- span: resourceFetch "app.css" [45ms -> 180ms] +-- span: fetchRequest "GET /api" [800ms -> 1100ms]
This is already Core Web Vitals territory. You can see slow resource loads and blocking API calls per pageview without writing a line of instrumentation code.

SPA navigation (automatic, framework-agnostic)

SPA route changes are auto-detected via the browser's Navigation API. The SDK registers a single navigate listener on initStrada() that fires on every client-side navigation regardless of framework — Next.js App Router, React Router, TanStack Router, Vue Router, SvelteKit, and any other SPA router.
navigation.addEventListener('navigate', ...) // fires on every SPA route change
No framework plugins or manual calls needed. The listener:
  • Ends the current pageview span
  • Starts a new pageview span for the new path
  • Skips cross-origin navigations, reloads, and hash-only changes
  • Does not start a new pageview when the tab becomes visible again
Each navigation span carries two extra attributes:
Span attributeValuesDescription
navigation.type"push", "replace", "traverse"How the navigation was triggered
navigation.user_initiatedtrue / falseWhether the user clicked a link vs programmatic nav
Browser support: Navigation API is Baseline since January 2026 (Chrome, Edge, Firefox, Safari). For older browsers, the initial pageview span still works; SPA navigations simply won't cycle the span.
If you need to manually trigger a pageview cycle (e.g. for custom routing logic not using the History API), the lower-level API is still available:
import { startPageSpan, endCurrentPageSpan } from '@strada.sh/sdk' // on route change endCurrentPageSpan() startPageSpan(newPath)

Custom events API

import { initStrada, track, identifyUser } from '@strada.sh/sdk' initStrada({ projectId: '01JTHG5M7XPQR8KNCZ0W4D', service: 'frontend', }) // Simple event track('button_click') // Event with properties track('form_submit', { form: 'signup', plan: 'pro', variant: 'hero-cta', }) identifyUser({ id: 'user_123' }) track('billing_opened', { plan: 'pro', })
strada.track() emits a log record via the OTel Logs API. The log record lands in otel_logs with event.name set to the event name. Context (session.id, visitor.id, url.full, user.id) is injected automatically. If a pageview span is currently open, TraceId and SpanId are attached too. Browser auto-instrumented spans also inherit that pageview as parent unless some other span is already active. Developers only pass event-specific properties.
Under the hood:
logger.emit({ severityNumber: 9, // INFO body: 'button_click', attributes: { 'event.name': 'form_submit', 'session.id': currentSessionId, 'url.path': window.location.pathname, 'url.full': window.location.href, 'user.id': currentUserId, 'custom.form': 'signup', 'custom.plan': 'pro', }, })
The log record is automatically correlated with the active pageview span via TraceId and SpanId when one is active. Custom events are queried from otel_logs, not otel_traces. Each dashboard widget runs its own query against one table, so no joins needed.

Backend custom events

The same pattern works on the backend. When you emit a log inside an active span's context, the OTel SDK automatically stamps the log record with the span's TraceId and SpanId. No extra code needed.
// Node.js backend import { logs } from '@opentelemetry/api-logs' const logger = logs.getLogger('my-api') app.post('/api/checkout', async (req, res) => { // The active span is auto-created by the OTel HTTP instrumentation. // This log automatically inherits the span's TraceId + SpanId. logger.emit({ body: 'purchase', attributes: { 'event.name': 'purchase', 'user.id': req.user.id, 'custom.plan': req.body.plan, 'custom.amount': String(req.body.amount), }, }) // ...handle checkout })
The log record lands in otel_logs with ServiceName = "my-api" (the backend service name). The user.id attribute ties it to the same user as browser events. If the browser sends the traceparent header, the backend log shares the same request trace as the incoming browser request:
Browser request trace (TraceId = abc123) +-- fetch POST /api/checkout (traceparent: abc123) | v Backend (TraceId = abc123) +-- span: POST /api/checkout +-- log: "purchase" event (TraceId = abc123, user.id = "user_123")
This means you can query all custom events for a user across browser and backend in a single query. The ServiceName column tells you the origin:
SELECT Timestamp, ServiceName, -- 'my-app' (browser) or 'my-api' (backend) LogAttributes['event.name'] AS event, LogAttributes['url.path'] AS path, TraceId, LogAttributes FROM otel_logs WHERE LogAttributes['user.id'] = {user_id:String} AND LogAttributes['event.name'] != '' ORDER BY Timestamp ASC
No schema changes needed. The otel_logs table already has TraceId, SpanId, ServiceName, and bloom filter indexes on LogAttributes keys/values.

SDK initialization

import { initStrada } from '@strada.sh/sdk' initStrada({ projectId: '01JTHG5M7XPQR8KNCZ0W4D', // get one with `strada projects create` service: 'my-app', version: '1.4.2', environment: 'production', })
The SDK:
  1. Generates or restores session.id from sessionStorage
  2. Generates or restores visitor.id from cookie strada_vid
  3. Initializes WebTracerProvider with BatchSpanProcessor
  4. Attaches a span processor that injects session.id, visitor.id, and user.id onto every span
  5. Starts the first pageview span

Materialized views for dashboard queries

Raw otel_traces contains every span: backend API calls, database queries, browser fetch requests, resource loads, etc. For a dashboard showing "pageviews per day" or "top browsers", scanning the full traces table is wasteful. Most rows are not pageviews.
Two materialized views fire automatically on every insert to otel_traces and pre-aggregate only the pageview spans into compact tables. This is the same pattern used by Tinybird's web analytics starter kit.

Why AggregatingMergeTree

A flat MergeTree storing one row per pageview would save some storage (no Map columns, no Events arrays). But an AggregatingMergeTree that pre-computes uniqState(visitor_id) and countState() is ~100x more compact. For a site with 1M pageviews/day, the pages MV might store ~5k-50k aggregated rows per day (one per unique combination of date + pathname + device + browser + country). The sessions MV stores one row per session per day (~100k rows for 100k daily sessions), still way less than 1M raw pageview rows.

Why two MVs instead of one

ClickHouse AggregatingMergeTree sorting keys determine which rows merge together and which queries are fast. You can't optimize for both pathname and session_id in the same sorting key. A query grouping by pathname needs pathname early in the sort key; a query computing bounce rate needs session_id early. So we split into two tables:
MVPowersSorting key
otel_analytics_pagesTop pages, browsers, bots, devices, countries, languages, referrers, pageview histogramProjectId, ServiceName, Domain, Date, Device, Browser, Country, Language, Pathname, Referrer
otel_analytics_sessionsBounce rate, avg session durationProjectId, ServiceName, Domain, Date, SessionId

otel_analytics_pages

Pre-aggregates pageview counts and unique visitors by page, device, browser, country, language, referrer, and domain (the host the user is visiting).
-- Schema (AggregatingMergeTree) ProjectId LowCardinality(String) Date Date ServiceName LowCardinality(String) Domain String -- extracted from url.full with domainWithoutWWW() Pathname String Referrer String -- domainWithoutWWW(http.request.header.referer) Device LowCardinality(String) -- 'desktop', 'mobile-android', 'mobile-ios', 'bot' Browser LowCardinality(String) -- 'chrome', 'firefox', 'safari', 'opera', 'ie', 'Unknown' Country LowCardinality(String) -- ISO 3166-1 alpha-2 from geo.country Language LowCardinality(String) -- from browser.language Visits AggregateFunction(uniq, String) -- uniqState(visitor.id), session.id fallback FirstVisits AggregateFunction(uniq, String) -- uniqStateIf(visitor.id, first_visit) Hits AggregateFunction(count, UInt64) -- countState()
Sorting key: ProjectId, ServiceName, Domain, Date, Device, Browser, Country, Language, Pathname, Referrer
Domain is early in the sorting key because multi-domain projects (e.g. acme.com + docs.acme.com) always filter by domain first. Extracted from url.full using domainWithoutWWW() in the MV pipe, so the SDK doesn't need to set it explicitly.
The MV pipe extracts flat fields from SpanAttributes and ResourceAttributes, classifies device/browser from user_agent.original using regex (same approach as Tinybird starter kit), and feeds the result into uniqState/countState aggregates.
Browser/device classification SQL (runs inside the MV pipe):
CASE WHEN match(ua, 'wget|ahrefsbot|curl|urllib|bitdiscovery|googlebot') THEN 'bot' WHEN match(ua, 'android') THEN 'mobile-android' WHEN match(ua, 'ipad|iphone|ipod') THEN 'mobile-ios' ELSE 'desktop' END AS Device, CASE WHEN match(ua, 'firefox') THEN 'firefox' WHEN match(ua, 'chrome|crios') THEN 'chrome' WHEN match(ua, 'opera') THEN 'opera' WHEN match(ua, 'msie|trident') THEN 'ie' WHEN match(ua, 'iphone|ipad|safari') THEN 'safari' ELSE 'Unknown' END AS Browser
Where ua is lower(coalesce(nullIf(SpanAttributes['user_agent.original'], ''), ResourceAttributes['user_agent.original'])). The collector injects user_agent.original into span attributes from the User-Agent request header; the fallback reads the resource attribute set by the browser detector.

otel_analytics_sessions

Pre-aggregates per-session metrics for computing bounce rate and avg session duration.
-- Schema (AggregatingMergeTree) ProjectId LowCardinality(String) Date Date ServiceName LowCardinality(String) Domain String SessionId String Device SimpleAggregateFunction(any, LowCardinality(String)) Browser SimpleAggregateFunction(any, LowCardinality(String)) Country SimpleAggregateFunction(any, LowCardinality(String)) FirstHit SimpleAggregateFunction(min, DateTime) LatestHit SimpleAggregateFunction(max, DateTime) Hits AggregateFunction(count, UInt64)
Sorting key: ProjectId, ServiceName, Domain, Date, SessionId
Device, Browser, Country use SimpleAggregateFunction(any, ...) because they're constant for a given session. We just need any value, not an aggregate.
Bounce rate = sessions where FirstHit = LatestHit (only one pageview in the session). Avg session duration = avg(LatestHit - FirstHit).

Retention

Both MV tables can have their own TTL independent of otel_traces:
-- Analytics MVs: 90-day retention ENGINE_TTL Date + INTERVAL 90 DAY -- Raw traces: 7-day retention (or whatever the user configures)
This means the analytics dashboard shows 90 days of data while raw traces (with their large Map columns, Events arrays, Links arrays) are cleaned up after 7 days. The storage overhead of 90 days of pre-aggregated analytics data is minimal (a few MB vs GB of raw traces).

Dashboard queries

All dashboard queries read from the materialized views, not from otel_traces. They use uniqMerge(Visits) and countMerge(Hits) to combine the pre-aggregated states.
Every query filters by ServiceName and Domain. In a multi-domain project (e.g. acme.com + docs.acme.com), the user selects which domain to view. If the project has only one domain, the UI can auto-select it.

KPIs: pageviews + unique visitors over time

Daily timeseries for the main dashboard chart. Includes optional previous-period comparison for showing growth percentages.
-- Current period WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Date AS day, countMerge(Hits) AS pageviews, uniqMerge(Visits) AS unique_visitors FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY day ORDER BY day
-- Previous period (same duration, shifted back) WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end, dateDiff('day', current_start, current_end) + 1 AS period_days SELECT Date + period_days AS day, -- align to current period for comparison countMerge(Hits) AS pageviews, uniqMerge(Visits) AS unique_visitors FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start - period_days AND Date < current_start GROUP BY day ORDER BY day

Bounce rate + avg session duration over time

LatestHit and FirstHit are SimpleAggregateFunction(max/min, DateTime64(9)) columns. ClickHouse can't subtract SAF types directly. Convert to milliseconds first via toUnixTimestamp64Milli, then diff and divide.
WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT day, count() AS sessions, sumIf(1, latest_ms = first_ms) / greatest(count(), 1) AS bounce_rate, avg(latest_ms - first_ms) / 1000 AS avg_session_duration_sec FROM ( SELECT Date AS day, SessionId, toUnixTimestamp64Milli(max(LatestHit)) AS latest_ms, toUnixTimestamp64Milli(min(FirstHit)) AS first_ms FROM otel_analytics_sessions WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY day, SessionId ) GROUP BY day ORDER BY day

KPI summary cards (single row)

One row with totals for the selected period, used for the big number cards at the top of the dashboard.
WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS total_pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end
-- Session-based KPIs (bounce rate, avg duration) -- Must convert SimpleAggregateFunction columns to millis before subtraction. WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT count() AS total_sessions, sumIf(1, latest_ms = first_ms) / greatest(count(), 1) AS bounce_rate, avg(latest_ms - first_ms) / 1000 AS avg_session_duration_sec FROM ( SELECT SessionId, toUnixTimestamp64Milli(max(LatestHit)) AS latest_ms, toUnixTimestamp64Milli(min(FirstHit)) AS first_ms FROM otel_analytics_sessions WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY SessionId )

Top pages

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Pathname, countMerge(Hits) AS pageviews, uniqMerge(Visits) AS unique_visitors FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY Pathname ORDER BY pageviews DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}

Top countries

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Country, uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY Country ORDER BY unique_visitors DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32}

Top browsers

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Browser, uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY Browser ORDER BY unique_visitors DESC LIMIT {limit:UInt32}

Top devices

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Device, uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY Device ORDER BY unique_visitors DESC LIMIT {limit:UInt32}

Top referrers (traffic sources)

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Referrer, uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end AND Referrer != '' AND Referrer != {domain:String} -- exclude self-referrals GROUP BY Referrer ORDER BY unique_visitors DESC LIMIT {limit:UInt32}

Top languages

WITH toDate({date_from:String}) AS current_start, toDate({date_to:String}) AS current_end SELECT Language, uniqMerge(Visits) AS unique_visitors, countMerge(Hits) AS pageviews FROM otel_analytics_pages WHERE ServiceName = {service:String} AND Domain = {domain:String} AND Date >= current_start AND Date <= current_end GROUP BY Language ORDER BY unique_visitors DESC LIMIT {limit:UInt32}

Realtime visitors (last 5 minutes)

Hits otel_traces directly because the MVs aggregate by day, not by minute:
SELECT uniq(SpanAttributes['session.id']) AS active_visitors FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND Timestamp >= now() - INTERVAL 5 MINUTE

Realtime trend (last 30 minutes, per-minute)

WITH (now() - INTERVAL 30 MINUTE) AS window_start SELECT toStartOfMinute(Timestamp) AS minute, uniq(SpanAttributes['session.id']) AS visitors FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND Timestamp >= window_start GROUP BY minute ORDER BY minute

Queries that use otel_traces directly

Some analytics queries need the full span tree and can't be answered from the pre-aggregated MVs. These are less frequent (user clicks into a specific session, or runs a funnel report) so scanning otel_traces is acceptable.

Page path timeline for a specific user

Shows every page a specific user visited, in order, with time spent on each page.
SELECT Timestamp, SpanAttributes['url.path'] AS path, SpanAttributes['url.full'] AS full_url, Duration / 1e9 AS time_on_page_sec, SpanAttributes['session.id'] AS session_id, SpanAttributes['http.request.header.referer'] AS referrer FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND SpanAttributes['user.id'] = {user_id:String} ORDER BY Timestamp ASC

Page path timeline for a specific session

Same as above but scoped to a single session. Shows the full browsing journey in one tab.
SELECT Timestamp, SpanAttributes['url.path'] AS path, Duration / 1e9 AS time_on_page_sec FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND SpanAttributes['session.id'] = {session_id:String} ORDER BY Timestamp ASC

Session replay (trace spans for one session)

All browser spans for a session: pageviews, fetch calls, resource loads, clicks.
SELECT Timestamp, SpanName, SpanKind, SpanAttributes['url.path'] AS path, Duration / 1e6 AS duration_ms, SpanAttributes FROM otel_traces WHERE SpanAttributes['session.id'] = {session_id:String} ORDER BY Timestamp ASC

Pageview-only funnel analysis

For funnels that only involve page paths (no custom events):
WITH sessions AS ( SELECT SpanAttributes['session.id'] AS session_id, groupArray(SpanAttributes['url.path']) AS pages FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 GROUP BY session_id ) SELECT count() AS total_sessions, countIf(has(pages, '/pricing')) AS step_1_pricing, countIf(has(pages, '/pricing') AND has(pages, '/checkout')) AS step_2_checkout, countIf(has(pages, '/pricing') AND has(pages, '/checkout') AND has(pages, '/success')) AS step_3_success FROM sessions
For funnels mixing pageviews and custom events, see the UNION ALL query in the "Custom event queries" section.

List domains for a project

Useful for the domain selector dropdown in the dashboard.
SELECT Domain, countMerge(Hits) AS total_pageviews, min(Date) AS first_seen, max(Date) AS last_seen FROM otel_analytics_pages WHERE ServiceName = {service:String} GROUP BY Domain ORDER BY total_pageviews DESC

Custom event queries (from otel_logs)

Custom events sent via strada.track() land in otel_logs. Each event is a log record with EventName set to the event name and context attributes (session.id, url.path, user.id) injected by the SDK. Each dashboard widget queries otel_logs independently; no joins with otel_traces needed.

Custom event histogram (top events)

SELECT LogAttributes['event.name'] AS event, count() AS occurrences, uniqExact(LogAttributes['session.id']) AS unique_sessions FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['event.name'] != '' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 GROUP BY event ORDER BY occurrences DESC LIMIT {limit:UInt32}

Custom event timeseries

SELECT toDate(Timestamp) AS day, LogAttributes['event.name'] AS event, count() AS occurrences FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['event.name'] != '' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 GROUP BY day, event ORDER BY day, occurrences DESC

Custom events for a specific user

All events a specific user triggered, ordered by time. Shows the full user journey across sessions.
SELECT Timestamp, LogAttributes['event.name'] AS event, LogAttributes['session.id'] AS session_id, LogAttributes['url.path'] AS path, LogAttributes['user.id'] AS user_id, LogAttributes FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['user.id'] = {user_id:String} AND LogAttributes['event.name'] != '' ORDER BY Timestamp ASC

Custom events for a specific session

SELECT Timestamp, LogAttributes['event.name'] AS event, LogAttributes['url.path'] AS path, LogAttributes FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['session.id'] = {session_id:String} AND LogAttributes['event.name'] != '' ORDER BY Timestamp ASC

Custom event breakdown by property

For a specific event, show the distribution of a custom property. For example, "which plans did users select when submitting the signup form?"
SELECT LogAttributes['custom.plan'] AS plan, count() AS occurrences, uniqExact(LogAttributes['user.id']) AS unique_users FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['event.name'] = 'form_submit' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 GROUP BY plan ORDER BY occurrences DESC

Full user activity timeline (pageviews + custom events)

When you need to see a user's complete journey (both pages visited and events triggered), combine both tables with UNION ALL:
SELECT * FROM ( SELECT Timestamp, 'pageview' AS type, SpanAttributes['url.path'] AS name, SpanAttributes['session.id'] AS session_id, Duration / 1e9 AS duration_sec, SpanAttributes AS properties FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND SpanAttributes['user.id'] = {user_id:String} UNION ALL SELECT Timestamp, 'event' AS type, LogAttributes['event.name'] AS name, LogAttributes['session.id'] AS session_id, 0 AS duration_sec, LogAttributes AS properties FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['user.id'] = {user_id:String} AND LogAttributes['event.name'] != '' ) ORDER BY Timestamp ASC

Funnel analysis (pageviews + custom events)

For funnels that mix pageviews and custom events (e.g. "visited /pricing -> clicked 'start_trial' -> submitted 'signup_form'"), collect events per session from both tables:
WITH events AS ( SELECT SpanAttributes['session.id'] AS session_id, SpanAttributes['url.path'] AS event_name FROM otel_traces WHERE ServiceName = {service:String} AND SpanName = 'pageview' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 UNION ALL SELECT LogAttributes['session.id'] AS session_id, LogAttributes['event.name'] AS event_name FROM otel_logs WHERE ServiceName = {service:String} AND LogAttributes['event.name'] != '' AND Timestamp >= toDate({date_from:String}) AND Timestamp <= toDate({date_to:String}) + 1 ), sessions AS ( SELECT session_id, groupArray(event_name) AS events FROM events GROUP BY session_id ) SELECT count() AS total_sessions, countIf(has(events, '/pricing')) AS step_1_pricing, countIf(has(events, '/pricing') AND has(events, 'start_trial')) AS step_2_start_trial, countIf(has(events, '/pricing') AND has(events, 'start_trial') AND has(events, 'signup_form')) AS step_3_signup FROM sessions

Why logs for custom events?

OTel is deprecating span.addEvent() (targeted March 2026) in favor of the Logs API for events. Using logs for custom analytics events aligns with the OTel direction.
Practical benefits:
  • Each dashboard widget queries one table. Pageview widgets query MVs or otel_traces. Custom event widgets query otel_logs. No joins for the common case.
  • Log records are lighter than spans (no duration, no status, no events/links arrays). Less storage per custom event.
  • otel_logs already has bloom filter indexes on LogAttributes keys and values, so filtering by event.name, session.id, or user.id is efficient.
  • Context propagation still works: TraceId and SpanId on the log record link it back to the active pageview span, so you can correlate if needed.
When you do need to combine both: for user timelines and funnels that mix pageviews and custom events, use UNION ALL as shown in the queries above. This is the advanced case, not the common dashboard path.

Implementation checklist

Collector changes (minimal)

  • Read CF-IPCountry header and inject as geo.country in span_attributes of every trace row
  • Read User-Agent header and inject as user_agent.original in span_attributes (fallback for when the browser detector doesn't set it as a resource attribute)
  • Both are general enrichments, not analytics-specific

New Tinybird datasources

  • tinybird/datasources/otel_analytics_pages.datasource (AggregatingMergeTree)
  • tinybird/datasources/otel_analytics_sessions.datasource (AggregatingMergeTree)

New Tinybird materializations

  • tinybird/materializations/otel_analytics_pages_mv.pipe (fires on insert to otel_traces, WHERE SpanName = 'pageview')
  • tinybird/materializations/otel_analytics_sessions_mv.pipe (fires on insert to otel_traces, WHERE SpanName = 'pageview')

ClickHouse DDL (self-hosted)

  • Add otel_analytics_pages table + MV to clickhouse.sql
  • Add otel_analytics_sessions table + MV to clickhouse.sql

JWT scopes

  • Add otel_analytics_pages and otel_analytics_sessions to the JWT scopes array with ProjectId filter (same pattern as other tables)