Skip to content

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.


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:

Terminal window
npm install @openpanel/web

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.

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.


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.


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).

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.

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.

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.

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:

scrollDepth.js
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) * 100 on the target element’s getBoundingClientRect(), 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.

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 Properties Fires
scroll_depth article_id, depth (10 | 25 | 50 | 75 | 100) Once per threshold, per page load

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.

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
// Starting audio playback
audioPlayer.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 seconds
audioPlayer.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,
});
}
});
// Completion
audioPlayer.addEventListener('ended', function () {
op.track('audio_end', { audio_attachment_id: audioId });
});

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.

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
Event When to Fire Properties
video_progress Every 10 seconds of playback video_attachment_id, seconds
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

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.

// Starting video playback
let 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 seconds
videoPlayer.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,
});
}
});
// Completion
videoPlayer.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,
});
});

Follow OpenPanel’s Revenue Tracking guide.


For assistance with analytics setup or custom implementations:

  • Contact WhiteBeard News Suite support for your OpenPanel clientId and 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