This is the hands-on companion to Your first playback-quality numbers, in minutes on Medium, which covers the same run for product owners.
Most streaming teams read their player quality numbers in a vendor's dashboard: fixed metric definitions, no raw events, and a history that ends where the retention window ends. This post goes the other way. Your web player sends events in an open format, every event becomes a row in a ClickHouse database that belongs to you, and you can read it in a dashboard or with SQL.
It uses Open Analytics, our player analytics service on Open Source Cloud. On the live service, we got from clicking Get started to the first dashboard numbers in minutes. The SDK, the event model and the pipeline are open source, so the player code here also works against a pipeline you run yourself.
What you end up with
- A pipeline: an HTTPS eventsink your players post to, a queue, a worker, a ClickHouse database and a Grafana, started and run for you.
- A web page with an hls.js player that reports startup, playing, pauses, seeks, stalls, bitrate changes, errors and the end of each session.
- Your sessions in the built-in dashboard, and the same rows in SQL.
Events follow EPAS, the Eyevinn Player Analytics Specification: an open Apache-2.0 event model. The same backend takes events from the iOS/tvOS and Android SDKs.
Step 1. Create a pipeline
Open analytics.apps.osaas.io, click Get started and sign in with your Open Source Cloud account. Pick the team the pipeline belongs to and allow access. Open Analytics needs the team to be on the Business plan (€199/month excl. VAT); a team on another plan is sent to a page that explains this and links to the upgrade.
On Your Analytics Pipelines, type a name (lowercase letters only, for example myanalytics) and click Create. The card says "This will take a few minutes" while the services start. In our runs it was ready in under two minutes in a team account that had been used before, and in about five in a brand-new one.
When it is ready, the card has three things:
- Open Dashboard, the built-in QoE dashboard.
- Open Grafana Dashboard. The first time, the app tells you the default login; change the password right away.
- Integration Guide: your Eventsink URL with a Copy button, and a code example with that URL filled in.
The eventsink URL is the only thing your player needs.
Step 2. Add the Web SDK
In a project with a bundler:
npm install @eyevinn/player-analytics-client-sdk-web On a plain HTML page, import it as an ES module from a CDN instead, as the full page below does. This post uses version 0.4.2, the latest on npm today.
Step 3. Start a session, describe the content, attach to the video
This is the code from the Integration Guide:
import { PlayerAnalyticsConnector } from "@eyevinn/player-analytics-client-sdk-web"; const analytics = new PlayerAnalyticsConnector("YOUR_EVENTSINK_URL"); await analytics.init({ sessionId: crypto.randomUUID() }); analytics.reportMetadata({ contentId: "YOUR_CONTENT_ID", contentTitle: "YOUR_CONTENT_TITLE", deviceType: /Mobi/i.test(navigator.userAgent) ? "mobile" : "desktop", timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); analytics.load(videoElement); Line by line:
-
init()opens a session at your eventsink.sessionIdis optional; leave it out and the backend generates one. -
reportMetadata()says what is playing. The dashboard's device and content breakdowns readdeviceType,contentIdandcontentTitlefrom this event, so fill them with real values. ReplaceYOUR_CONTENT_IDandYOUR_CONTENT_TITLE: pasted unchanged, the dashboard lists the placeholder instead of your video. -
timeZonegives the dashboard a country. A hosted pipeline does not see the viewer's IP address, so the country is derived from the device time zone. From SDK 0.4.2 the SDK addstimeZoneitself, so you can drop that line; pass your own value and it wins. -
load()attaches to anHTMLVideoElementand from then on reports events by itself.
load() does not have to wait for init(): events from before the handshake are queued and sent in order. With autoplay, attach as early as you can so the first playing event is not lost.
Step 4. Wire it to hls.js
Call analytics.load(video) before hls.js attaches the stream. The SDK sends loading when you call load(), and the dashboard's startup time runs from the first loading to the first loaded. Attach late and you measure less than the viewer waited.
Two things the SDK cannot see on a plain <video> element, so you pass them on from hls.js: bitrate changes and errors. reportError() is fatal and also ends the session; use reportWarning() for errors you recover from.
Here is a complete page. It plays a public Eyevinn test stream; replace the eventsink URL with yours.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Open Analytics: first numbers from a web player</title> </head> <body> <video id="video" controls muted playsinline width="640"></video> <p><button id="stop">Stop session</button></p> <script type="module"> import Hls from "https://cdn.jsdelivr.net/npm/hls.js@1.7.4/+esm"; import { PlayerAnalyticsConnector } from "https://cdn.jsdelivr.net/npm/@eyevinn/player-analytics-client-sdk-web@0.4.2/+esm"; const EVENTSINK_URL = "YOUR_EVENTSINK_URL"; // from your pipeline card const STREAM_URL = "https://maitv-vod.lab.eyevinn.technology/VINN.mp4/master.m3u8"; const video = document.getElementById("video"); const analytics = new PlayerAnalyticsConnector(EVENTSINK_URL, false, (err, event) => console.warn(`analytics: ${event.event} not sent: ${err.status ?? ""} ${err.message}`)); try { // 1. Start a session at your eventsink. await analytics.init({ sessionId: crypto.randomUUID() }); // 2. Say what is playing. In 0.4.2 the SDK adds timeZone itself. analytics.reportMetadata({ contentId: "vinn", contentTitle: "VINN", deviceType: /Mobi/i.test(navigator.userAgent) ? "mobile" : "desktop", }); // 3. Attach to the <video> element before the stream is loaded. analytics.load(video); } catch (err) { console.error(err); analytics.deinit(); } // 4. Play the stream (Safari plays HLS natively). if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(STREAM_URL); hls.attachMedia(video); hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => { const level = hls.levels[data.level]; analytics.reportBitrateChange({ bitrate: level.bitrate / 1000, width: level.width, height: level.height }); }); hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) analytics.reportError({ category: data.type, code: data.details }); }); } else { video.src = STREAM_URL; } // 5. End the session when the viewer is done. document.getElementById("stop").onclick = () => { analytics.reportStop(); analytics.destroy(); video.pause(); }; </script> </body> </html> The SDK attaches to any HTMLVideoElement, so players built on one (dash.js, Video.js, Shaka Player) should connect the same way: give load() the element they render into. We ran this post with hls.js only.
Step 5. Check that events arrive
Open the page, play for half a minute, pause, seek, then click Stop session.
- In the browser. DevTools, Network tab: one request per event to your eventsink URL. Any event that fails to send shows up in the console through the
onErrorcallback, the third constructor argument above. To see the events without sending anything, passtrueas the second argument (debug mode logs them to the console instead). - In the dashboard. Open Dashboard on the pipeline card. Your session counts as a view, with startup time, rebuffering, failures and exits before start, plus device, content and country breakdowns. Click the session in the list to see its timeline; each row has a { } JSON link with the raw EPAS event.
Give the last session a minute. In our run, a session that had just ended showed a provisional length for about a minute before the final value arrived.
What the SDK reports by itself after load(): loading, loaded, playing (and a heartbeat while playing), paused, seeking, seeked, buffering, buffered, and stopped when the video ends or the page closes. Switching tabs does not end a session. reportStop() sends stopped with reason aborted.
Step 6. Your first queries
Every dashboard card has Show SQL: the exact query behind the number, which you can run yourself. For your own queries, open SQL Access in the app's sidebar. It lists the ClickHouse connection details per pipeline and the table schema.
Events land in the default database, one table per shard: epas_ plus the event's shardId. The code above sets none, so look for epas_default:
SHOW TABLES FROM default; Did your session land? One row per session from the last hour:
SELECT sessionId, min(timestamp) AS started, count() AS events, groupUniqArray(event) AS event_types FROM default.epas_default WHERE timestamp > now() - INTERVAL 1 HOUR GROUP BY sessionId ORDER BY started DESC; Startup time, median and 95th percentile over seven days. This is the dashboard's own query, as Show SQL returns it:
WITH bounded AS (SELECT * FROM epas_default WHERE timestamp >= now64(3) - toIntervalMillisecond(604800000)), per_session AS ( SELECT sessionId, minIf(timestamp, event = 'loading') AS t_loading, minIf(timestamp, event = 'loaded') AS t_loaded FROM bounded GROUP BY sessionId HAVING countIf(event = 'loading') > 0 AND countIf(event = 'loaded') > 0 AND t_loaded >= t_loading ) SELECT quantileExact(0.50)(dateDiff('millisecond', t_loading, t_loaded)) AS startup_p50_ms, quantileExact(0.95)(dateDiff('millisecond', t_loading, t_loaded)) AS startup_p95_ms FROM per_session; The metric definitions behind the cards are published with the specification, in metrics.md. SQL Access in the app has more example queries to start from: startup time by device, buffering per session, errors by content, concurrent viewers and bitrate distribution.
Things to watch
- Keep test traffic in its own pipeline. The plan has room for test and staging pipelines next to production. The dashboard opens the table with the most rows, so a busy test shard in the production pipeline can push production out of view. If you split environments by
shardIdinstead, query the tables directly. - Playhead units. Against today's hosted eventsink, the Web SDK sends
playheadanddurationin seconds. Against an eventsink that advertises EPAS 0.7.0 or later, it switches to integer milliseconds, as the mobile SDKs use. Check before you compare positions across platforms. - Your data stays. There is no forced retention window: you decide how long to keep your data. Each pipeline includes 30 GB of storage, and more is available on request.
Where to go from here
- Open Analytics: analytics.apps.osaas.io, or the public demo dashboard with sample data.
- Web SDK: player-analytics-client-sdk-web (its
demo/folder has a fuller hls.js page) - Mobile SDKs: Android, iOS and tvOS
- The event model: EPAS
- Run the pipeline yourself: eventsink, worker



