Analytics
Reports playback as events, for whatever analytics service you use.
import { analytics } from "react-video-kit/analytics";Adds 774 B to a player906 B on its ownSource
Calls one function with an event each time something happens in playback. Play, seek, and pause the demo, and watch the events appear under it.
Play, seek, and pause. Each event appears here as onEvent receives it, newest first, with a heartbeat for every 10 seconds played.
- No events yet. Press play.
"use client";import { useMemo, useState } from "react";import { analytics } from "react-video-kit/analytics";import { VideoPlayer } from "react-video-kit/player";const oneDecimal = (_key: string, value: unknown) => typeof value === "number" ? Math.round(value * 10) / 10 : value;export default function Analytics() { const [log, setLog] = useState<string[]>([]); const plugins = useMemo( () => [ analytics({ heartbeat: 10, onEvent: (event) => setLog((lines) => [JSON.stringify(event, oneDecimal), ...lines].slice(0, 8)), }), ], [] ); return ( <div className="flex flex-col gap-3"> <VideoPlayer src="/media/sample-720.mp4" poster="/media/poster.jpg" plugins={plugins} load="visible" /> <p className="text-sm text-current opacity-80"> Play, seek, and pause. Each event appears here as onEvent receives it, newest first, with a heartbeat for every 10 seconds played. </p> <ol aria-label="Analytics events" className="flex min-h-44 flex-col gap-1 font-mono text-xs break-all text-current"> {log.length === 0 && <li className="opacity-80">No events yet. Press play.</li>} {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </div> );}Usage
import { analytics } from "react-video-kit/analytics";import { VideoPlayer } from "react-video-kit/player";const plugins = [analytics({ onEvent: (event) => console.log(event) })];<VideoPlayer src="/video.mp4" plugins={plugins} />;heartbeat sets the seconds of playback between heartbeat events, and defaults to 30. 0 turns them off. progress sets the percentages that send a progress event, and defaults to 10, 25, 50, and 75:
analytics({ onEvent: (event) => console.log(event), heartbeat: 10, progress: [25, 50, 75, 90] });Send events to a service
analytics({ onEvent(event) { navigator.sendBeacon("/api/video-events", JSON.stringify(event)); },});Google Analytics
googleAnalytics turns these events into the ones Google Analytics 4 uses for video, with the same names and parameters as its own measurement of embedded YouTube videos, so the two line up in your reports. Play this demo, and watch what would be sent. It contacts nobody, because send replaces the real call.
- Nothing sent yet. Press play.
"use client";import { useMemo, useState } from "react";import { analytics } from "react-video-kit/analytics";import { googleAnalytics } from "react-video-kit/analytics/google";import { VideoPlayer } from "react-video-kit/player";export default function GoogleAnalytics() { const [log, setLog] = useState<string[]>([]); const plugins = useMemo( () => [ analytics({ heartbeat: 0, // `send` takes the place of gtag here, so this demo contacts nobody. onEvent: googleAnalytics({ title: "View From A Blue Moon", events: ["pause", "seek"], send: (name, params) => setLog((lines) => [`${name} ${JSON.stringify(params)}`, ...lines].slice(0, 6)), }), }), ], [] ); return ( <div className="flex flex-col gap-3"> <VideoPlayer src="/media/sample-720.mp4?signed=secret-token" poster="/media/poster.jpg" plugins={plugins} load="visible" /> <ol aria-label="Events for Google Analytics" className="flex min-h-44 flex-col gap-1 font-mono text-xs break-all text-current"> {log.length === 0 && <li className="opacity-80">Nothing sent yet. Press play.</li>} {log.map((line, index) => ( <li key={index}>{line}</li> ))} </ol> </div> );}import { analytics } from "react-video-kit/analytics";import { googleAnalytics } from "react-video-kit/analytics/google";const plugins = [analytics({ onEvent: googleAnalytics({ title: "My video" }) })];| Event | When it's sent |
|---|---|
video_start | Playback first begins for this video. |
video_progress | Playback passes 10, 25, 50, and 75 percent, once each. Seeking past a mark doesn't count. |
video_complete | The video ends, after playback carried it past the last mark. Seeking to the end doesn't count. |
Each has video_current_time, video_duration, video_percent, video_provider, video_title, video_url, and visible, which says whether the page was showing. Analytics already has dimensions for video_provider, video_title, video_url, and visible. To report on the other three, register them as custom dimensions or metrics.
With gtag.js
Keep Google's usual snippet on the page. The plugin finds window.gtag, and never loads Google's script itself:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script><script> window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag("js", new Date()); gtag("config", "G-XXXXXXXXXX");</script>On a page with more than one Google tag, measurementId sends these events only to yours:
googleAnalytics({ measurementId: "G-XXXXXXXXXX", title: "My video" });With Tag Manager
Without gtag, each event goes to window.dataLayer, as { event: "video_start", video_percent: 0, ... }. In Tag Manager, add a Custom Event trigger matching video_start|video_progress|video_complete as a regular expression, Data Layer Variables for the parameters you want, and a Google Analytics event tag using them.
If Tag Manager's own YouTube trigger or enhanced measurement also covers this page, that only watches embedded YouTube players, so it won't count these videos twice.
More events
Google has no names for these, so they're custom events, and off until you ask:
googleAnalytics({ events: ["pause", "seek", "rebuffer", "quality-change", "error"] });In events | The event | Its extra parameter |
|---|---|---|
pause | video_pause | |
seek | video_seek | |
rebuffer | video_rebuffer | stalled_ms |
quality-change | video_quality_change | video_quality |
error | video_error | error_code, error_fatal |
error_code is one of the error codes. Google counts each parameter's value up to 100 characters, and each event up to 25 parameters, which these stay within apart from a long video_url.
Privacy and consent
-
video_urlhas no query string or hash. Signed URLs carry tokens there. Change that withurl, which can also leave the URL out:tsxgoogleAnalytics({ url: (source) => new URL(source, location.href).pathname });googleAnalytics({ url: () => undefined }); -
video_errorsends only the code, because a browser's message can include a URL. -
Nothing here sets or reads consent. That stays with your site and your consent banner. With Consent Mode, set your defaults before the Google tag loads, and these events follow them like any others:
jsgtag("consent", "default", { analytics_storage: "denied" });// Once the viewer chooses:gtag("consent", "update", { analytics_storage: "granted" });
To send to something else that takes these names, give it send:
googleAnalytics({ send: (name, params) => myAnalytics.track(name, params) });Events
type | When it fires | Extra field |
|---|---|---|
load | A source starts loading. | |
ready | The source moves from loading to ready. | startupMs |
start | Playback really begins for this video. Retrying it doesn't send this again, and neither does an ad. | |
play | Playback starts or resumes. | |
pause | Playback pauses, except when the video ended. | |
seek | A seek finishes. | |
progress | Playback passes one of the progress marks, once each for this video. | percent |
rebuffer-start | Playback stalls after it has started, while not paused or recovering from a seek. | |
rebuffer-end | That stall stops, including because the viewer paused or started a seek. | stalledMs |
quality-change | The active quality level changes to a known one. | quality |
ended | The video ends. | complete |
error | Anything onError hears, fatal or not. See the error codes. | error |
heartbeat | Every heartbeat seconds of video played. |
Every event has type, position, duration, percent, and source.
| Field | Type | Description |
|---|---|---|
| type | AnalyticsEventType | What happened. |
| position | number | Playback position when it happened, in seconds. |
| duration | number | The video's length in seconds, which is 0 until known and Infinity for a live stream. |
| percent | number | How far through the video that is, from 0 to 100. A progress event gives the mark it passed. |
| source | string | null | URL of the current source, or null when there isn't one. |
| startupMs? | number | Milliseconds from load to ready, on ready. |
| stalledMs? | number | Milliseconds spent stalled, on rebuffer-end. |
| quality? | string | null | The new level's id, on quality-change. |
| complete? | boolean | True on ended when playback, rather than a seek, carried the video past its last mark. |
| error? | MediaErrorInfo | null | What went wrong, on error. |
Behaviour
- Heartbeats count video played. Seeks and source changes add nothing and don't reset the count, so adding heartbeats up gives a close measure of how much was watched. It counts video time, so playing at 2× reaches the next heartbeat twice as fast.
positioncomes from the player's clock when the event is sent, soseekreports where the seek landed.- Dragging the timeline can send several
seekevents, one for each seek that finishes. - A stall caused by a seek isn't counted as a rebuffer, including the moment after the seek finishes while playback picks back up.
- The end of the video sends
endedwithout apause. progressandcompletefollow playback, not position. Seeking past a mark doesn't send it, though playing through it later does.completeis true onendedwhen playback carried the video past its last mark, and has to be earned again on a second viewing.- Progress reads the clock about once a second, and never writes to the store, so it adds no renders.
onEventruns inside the player's store update. Keep it quick, and don't let it throw, because that would stop the other subscribers in that update.
API
Options
| Option | Type | Description |
|---|---|---|
| onEvent | (event: AnalyticsEvent) => void | Called with each event as it happens. |
| heartbeat? | number | Seconds of playback between heartbeats. 0 turns them off. |
| progress? | readonly number[] | The percentages that send a progress event once each, as playback passes them. |
State
This plugin has no state of its own.
Commands
This plugin has no commands of its own.
Google Analytics API
Functions
googleAnalytics
Sends the analytics plugin's events to Google Analytics 4 under Google's own names for video. It uses the gtag or dataLayer already on the page: it never loads Google's script or changes consent, which stay with your site. Keeps no state, so players can share one.
(options?: GoogleAnalyticsOptions) => (event: AnalyticsEvent) => void
Types
GoogleAnalyticsExtra
Optional events beyond Google's own video events.
GoogleAnalyticsExtra
GoogleAnalyticsOptions
| Field | Type | Description |
|---|---|---|
| measurementId? | string | Sends only to this measurement ID, as send_to, for a page with more than one. |
| title? | string | ((event: AnalyticsEvent) => string | undefined) | The video_title, or a function to work it out from the event. |
| provider? | string | The video_provider. Defaults to react-video-kit. |
| events? | readonly GoogleAnalyticsExtra[] | Turns on events beyond video_start, video_progress, and video_complete. |
| url? | ((source: string) => string | undefined) | Turns the source into video_url. By default that drops the query string and hash, which may hold tokens. Return undefined to send no URL. |
| send? | ((name: string, params: GoogleAnalyticsParams) => void) | Sends the event yourself, in place of gtag or dataLayer. |
GoogleAnalyticsParams
The parameters sent with an event.
GoogleAnalyticsParams