Analytics Setup (OpenPanel)
This guide explains how to set up web and mobile app analytics for your News Suite installation using OpenPanel, including content attribution, scroll depth, audio, video, and payment event tracking.
OpenPanel is the current standard analytics backend for News Suite installations. If your site still runs on Matomo, see the Matomo setup guide — you can migrate at your own pace using the event mapping notes below.
Basic Setup
Section titled “Basic Setup”Step 1: Install the SDK
Section titled “Step 1: Install the SDK”OpenPanel publishes official SDKs for most frameworks (React, Vue, Astro, Next.js, plain JavaScript, and more). Install the package that matches your stack — see the OpenPanel SDK docs for the full list and framework-specific setup steps.
For a plain JavaScript / bundler-based project:
npm install @openpanel/webStep 2: Initialize the Client
Section titled “Step 2: Initialize the Client”Create a single, shared OpenPanel instance and reuse it everywhere you fire an event:
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({ clientId: 'YOUR_CLIENT_ID', apiUrl: 'https://your-analytics-endpoint/api', trackScreenViews: false,});Contact WhiteBeard News Suite support for your clientId and analytics endpoint.
Set trackScreenViews: false. The SDK’s built-in auto-tracking fires a screen_view on load and will dedupe against any later manual call for the same URL. Since article pages need to attach extra properties to screen_view (see below), fire it manually on every page instead, so behavior is consistent site-wide.
Step 3: Fire screen_view on Every Page
Section titled “Step 3: Fire screen_view on Every Page”Call op.screenView() once per page load, after the DOM elements you need to read attribution data from are available:
op.screenView();This basic call enables general pageview tracking across your website and mobile applications.
Identifying Logged-In Users
Section titled “Identifying Logged-In Users”Call op.identify() once a visitor is signed in, so their events are tied to a single profile across sessions and devices, and op.clear() when they sign out, so subsequent events go back to being anonymous instead of attributed to a stale profile.
op.identify({ profileId })— call when auth state resolves to a signed-in user, and again whenever the signed-in user changes (e.g. account switch).op.clear()— call on logout, or whenever auth state resolves to “no user”.
Guard against redundant calls by tracking the last-identified ID and skipping if it hasn’t changed:
let identifiedProfileId = null;
function onAuthStateChange(user) { const id = user?.id ?? null; if (id === identifiedProfileId) return; identifiedProfileId = id;
if (id) { op.identify({ profileId: id }); } else { op.clear(); }}Wire onAuthStateChange to whatever your app already uses to know the current visitor — an auth store, a session/cookie check, a custom event — since this is independent of the JS framework in use.
Never pass PII as the profileId or as identify properties — use your internal customer/account ID, not an email address or name.
Content Attribution Setup
Section titled “Content Attribution Setup”For accurate content performance tracking, pass additional properties on the screen_view event when the page being viewed is an article (or other content item).
Required Properties
Section titled “Required Properties”| Property | Type | Description |
|---|---|---|
article_id |
String | The Content ID or Article ID |
content_type_id |
String | The CMS content-type ID (distinguishes article/video/podcast/etc.) |
category_id |
String | Comma-separated IDs of the categories of the content |
publication_id |
String | The publication ID the content belongs to |
subscribed |
Boolean | Whether the user has an active subscription |
Send IDs, not names. Use category_id rather than a category name, content_type_id rather than a freeform type string, and so on. IDs keep joins and filters in the analytics backend consistent even if display names change later.
Implementation Example
Section titled “Implementation Example”Render the attribution data as data-* attributes on your article container server-side, then read them when firing screen_view:
<article class="article-main" data-content-type-id="{{ contentType.id }}" data-category-id="{{ category.id }}" data-publication-id="{{ publication.id }}"> ...</article>const articleMain = document.querySelector('.article-main');
op.screenView( articleMain ? { content_type_id: articleMain.dataset.contentTypeId, category_id: articleMain.dataset.categoryId, publication_id: articleMain.dataset.publicationId, } : undefined,);- Non-article pages: call
op.screenView()with no properties. - Single-page apps: re-fire
op.screenView()(with updated properties) on client-side route changes, since the SDK’s auto-tracking is disabled.
Scroll Depth Tracking
Section titled “Scroll Depth Tracking”Track how far into an article’s body readers scroll using a single scroll_depth event with a depth property, rather than a separate event per threshold. OpenPanel supports filtering and grouping by property, so one event name keeps aggregate queries (average depth, % reaching ≥50%) simple instead of fragmenting them across event names.
Portable Helper
Section titled “Portable Helper”This helper takes the OpenPanel instance and a target Element, so it can be dropped into any project unchanged — no site-specific config baked in:
export function trackScrollDepth( op, element, event, properties = {}, thresholds = [10, 25, 50, 75, 100],) { const fired = new Set(); let ticking = false;
function check() { ticking = false; const rect = element.getBoundingClientRect(); const depth = Math.min( 100, Math.max(0, ((window.innerHeight - rect.top) / rect.height) * 100), );
for (const threshold of thresholds) { if (depth >= threshold && !fired.has(threshold)) { fired.add(threshold); op.track(event, { ...properties, depth: threshold }); } }
if (fired.size === thresholds.length) { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); } }
function onScroll() { if (!ticking) { ticking = true; requestAnimationFrame(check); } }
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll);}- Depth formula:
((window.innerHeight - rect.top) / rect.height) * 100on the target element’sgetBoundingClientRect(), clamped 0–100. 0% means the element’s top has just entered the bottom of the viewport; 100% means the element’s bottom has reached the bottom of the viewport. - Each threshold fires once per page load.
- Listeners are removed automatically once every threshold has fired.
Wiring It Up on Article Pages
Section titled “Wiring It Up on Article Pages”Apply the helper to the article body only (not the surrounding author box, related-articles, or most-read widgets), and merge an article_id into every event:
const articleMain = document.querySelector('.article-main');const articleContent = articleMain?.querySelector('.content');
if (articleContent) { trackScrollDepth(op, articleContent, 'scroll_depth', { article_id: articleMain.dataset.id, });}Event Shape
Section titled “Event Shape”| Event | Properties | Fires |
|---|---|---|
scroll_depth |
article_id, depth (10 | 25 | 50 | 75 | 100) |
Once per threshold, per page load |
Audio Analytics
Section titled “Audio Analytics”If you’re implementing a custom audio player, track listener engagement with dedicated op.track() calls. Unlike Matomo’s single audio_event + action pattern, each action gets its own event name, and progress ticks are consolidated into one audio_progress event carrying a seconds property — following the same design used for scroll_depth.
Audio Events
Section titled “Audio Events”| Event | When to Fire | Properties | Notes |
|---|---|---|---|
audio_start |
Audio begins playing for the first time | audio_attachment_id |
|
audio_listened |
10 seconds of continuous playback | audio_attachment_id |
Fires once per page load |
audio_progress |
Every 10 seconds of playback | audio_attachment_id, seconds |
|
audio_end |
Playback reaches the end | audio_attachment_id |
Implementation Examples
Section titled “Implementation Examples”// Starting audio playbackaudioPlayer.addEventListener('play', function () { op.track('audio_start', { audio_attachment_id: audioId });});
// 10-second engagement (fires once per page)let hasTrackedListened = false;
audioPlayer.addEventListener('timeupdate', function () { if (!hasTrackedListened && audioPlayer.currentTime >= 10) { op.track('audio_listened', { audio_attachment_id: audioId }); hasTrackedListened = true; }});
// Progress, every 10 secondsaudioPlayer.addEventListener('timeupdate', function () { const currentTime = Math.floor(audioPlayer.currentTime);
if (currentTime > 0 && currentTime % 10 === 0) { op.track('audio_progress', { audio_attachment_id: audioId, seconds: currentTime, }); }});
// CompletionaudioPlayer.addEventListener('ended', function () { op.track('audio_end', { audio_attachment_id: audioId });});Video Analytics
Section titled “Video Analytics”If you’re implementing a custom video player, track viewer engagement with dedicated op.track() calls — again, each Matomo action becomes its own event name instead of a shared video_event action string.
Video Events
Section titled “Video Events”Core Events
Section titled “Core Events”| Event | When to Fire | Properties | Notes |
|---|---|---|---|
video_loaded |
Video metadata has loaded | video_attachment_id |
|
video_start |
First time playback begins in the session | video_attachment_id |
Fires once per session only |
video_play |
Playback starts/resumes | video_attachment_id, seconds |
|
video_pause |
Playback is paused | video_attachment_id, seconds |
Don’t fire if the pause is the video reaching its natural end, or part of a seek |
video_end |
Playback reaches the end | video_attachment_id |
Progress and Seek
Section titled “Progress and Seek”| Event | When to Fire | Properties |
|---|---|---|
video_progress |
Every 10 seconds of playback | video_attachment_id, seconds |
Player State Events
Section titled “Player State Events”| Event | When to Fire | Properties | Notes |
|---|---|---|---|
video_volume_change |
Volume is changed | video_attachment_id, volume (0 if muted) |
|
video_unmuted |
Volume changes away from muted/0 |
video_attachment_id, volume |
|
video_resize |
Player is resized | video_attachment_id, width, height |
|
video_error |
A player error occurs | video_attachment_id, seconds |
Always fire, even if the same error repeats |
video_fullscreen_enter / video_fullscreen_exit |
Fullscreen is toggled | video_attachment_id, seconds |
Only fire when an ad is not currently playing |
Deduplication
Section titled “Deduplication”If the same event would fire twice in a row with the same properties, only send the first call — skip the repeat. video_error is exempt from this rule and should always be sent.
Implementation Examples
Section titled “Implementation Examples”// Starting video playbacklet hasStarted = false;
videoPlayer.addEventListener('play', function () { op.track(hasStarted ? 'video_play' : 'video_start', { video_attachment_id: videoId, seconds: videoPlayer.currentTime, }); hasStarted = true;});
// Progress, every 10 secondsvideoPlayer.addEventListener('timeupdate', function () { const currentTime = Math.floor(videoPlayer.currentTime);
if (currentTime > 0 && currentTime % 10 === 0) { op.track('video_progress', { video_attachment_id: videoId, seconds: currentTime, }); }});
// CompletionvideoPlayer.addEventListener('ended', function () { op.track('video_end', { video_attachment_id: videoId });});
// Player errors (always fire, no dedup)videoPlayer.addEventListener('error', function () { op.track('video_error', { video_attachment_id: videoId, seconds: videoPlayer.currentTime, });});Revenue Analytics
Section titled “Revenue Analytics”Follow OpenPanel’s Revenue Tracking guide.
Getting Support
Section titled “Getting Support”For assistance with analytics setup or custom implementations:
- Contact WhiteBeard News Suite support for your OpenPanel
clientIdand endpoint - Refer to the OpenPanel SDK documentation for framework-specific integration guides
- Work with your development team to ensure proper integration with your specific platform architecture