Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .babelrc

This file was deleted.

12 changes: 6 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,29 @@ jobs:
with:
ref: 'refs/heads/master'

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 20
registry-url: 'https://registry.npmjs.org'

- name: Configure Git
run: |
git config user.name "cloudinary-bot"
git config user.email "cloudinary-bot@clodinary.com"

- name: Setup yarn
run: npm install -g yarn

- name: Install dependencies
run: yarn install --frozen-lockfile
run: pnpm install

- name: Bump version
run: npm version ${{ github.event.inputs.version-type }} -m "Bumping to version %s" --tag-version-prefix=""

- name: Build
shell: bash
run: yarn build
run: pnpm build

- name: Publish packages
run: npm publish --access="public"
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ tags
# nodejs
node_modules/
npm-debug.log
yarn-error.log
pnpm-debug.log

# project
env.js
Expand Down
2 changes: 1 addition & 1 deletion .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ tags
# nodejs
node_modules/
npm-debug.log
yarn-error.log
pnpm-debug.log

# project
env.js
Expand Down
141 changes: 138 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Cloudinary Video Analytics
The Cloudinary Video Analytics package allows you to track analytics for your Cloudinary videos using video players other than the Cloudinary Video Player. The library targets the HTML5 video tag and is designed to work with any video player that use this tag. For more information view the [documentation](https://cloudinary.com/documentation/video_analytics).

### Usage
The package is written in TypeScript and ships its own type declarations.

## Usage
**1. Install the package**:

```shell
Expand All @@ -12,7 +14,7 @@ npm i cloudinary-video-analytics
```js
import { connectCloudinaryAnalytics } from 'cloudinary-video-analytics';
```
**3. Connect the analytics:**:
**3. Connect the analytics**:

Connect the analytics by calling the `connectCloudinaryAnalytics` method and provide the video element as a parameter. For example, if your video element has the id ‘video-player’:
```js
Expand All @@ -36,7 +38,140 @@ cloudinaryAnalytics.startManualTracking({

Auto and manual tracking cannot be used together for the same video element. Manual tracking should only be used in cases where you need to track certain videos, you want to track a video tag element which is dynamic, or you have custom domains which require providing `cloudName` and `publicId`.

### CodeSandbox examples
## API

`connectCloudinaryAnalytics(videoElement, mainOptions?)` returns an object with the following methods:

| Method | Description |
| --- | --- |
| `startAutoTracking(options?)` | Automatically detects and tracks Cloudinary videos played in the element. |
| `startManualTracking(metadata, options?)` | Tracks a specific video (or live stream) identified by `cloudName` / `publicId`. |
| `stopManualTracking()` | Stops the current tracking session. |
| `stopAutoTracking()` | Alias of `stopManualTracking` — stops the current tracking session. |
| `stopTracking()` | Stops whichever tracking (auto or manual) is currently active. |
| `reportCustomEvent(name, data?)` | Records a consumer-defined event into the current view (see [Custom events](#custom-events)). |

### `metadata` (manual tracking)

| Field | Type | Description |
| --- | --- | --- |
| `cloudName` | `string` | Your Cloudinary cloud name. Required. |
| `publicId` | `string` | Public ID of the video (or live stream output). Required. |
| `type` | `'live'` | Optional. Set to `'live'` to track a live stream. |

### `options` (tracking options)

| Field | Type | Description |
| --- | --- | --- |
| `customData` | `object \| () => object` | Up to five string fields (`customData1`…`customData5`); non-string values are ignored. |
| `videoPlayerType` | `string` | `'native'` (default) or `'cloudinary video player'`. |
| `videoPlayerVersion` | `string` | Semantic version of the player. |
| `customVideoUrlFallback` | `(videoUrl) => ({ cloudName, publicId })` | Resolves `cloudName`/`publicId` from a video URL (auto tracking / custom domains). |

### `mainOptions`

| Field | Type | Description |
| --- | --- | --- |
| `playerAdapter` | `PlayerAdapter` | Custom player adapter. Defaults to the built-in native HTML5 `<video>` adapter. |

## Custom events

Report your own events into the active view — for example an "on screen" / viewability signal coming from your player integration — so they can be correlated (by time) with the built-in events:

```js
cloudinaryAnalytics.reportCustomEvent('onScreen', { visible: true, ratio: 0.75 });
```

- The event is **recorded** into the current view and shipped in the same payload; it is not sent immediately.
- `name` must be a non-empty string; `data` must be a plain object (JSON-serializable, ≤ 2000 characters).
- It is a **no-op** when no view is currently being tracked, and for live streams (which are not collected into a view).

## Collected events

### Payload shape

Data is sent once per view (see [Views](#views)) via `navigator.sendBeacon` as `multipart/form-data` with three fields:

| Field | Description |
| --- | --- |
| `userId` | Anonymous, persistent per-browser id (stored in `localStorage`). Stable across views and sessions. |
| `viewId` | Unique id for a single view. |
| `events` | JSON-stringified array of event objects. |

Every event object has the shape:

```json
{ "eventName": "play", "eventTime": 1784973602000, "eventDetails": {} }
```

- `eventName` — the event type (see below).
- `eventTime` — epoch milliseconds when the event was recorded.
- `eventDetails` — event-specific payload.

### Views

A **view** is one `viewStart` … `viewEnd` window sharing a single `viewId`. Events accumulate in memory during the view and are sent as a single beacon when the view ends (page unload on desktop, tab hidden on mobile). Backgrounding and returning to a tab ends one view and begins a new one (a new `viewId`, a separate beacon).

### Event reference

| `eventName` | Fired when | `eventDetails` |
| --- | --- | --- |
| `viewStart` | A view begins. | `videoUrl`, `trackingType` (`'auto'` \| `'manual'`), `analyticsModuleVersion`, `videoPlayer` (`{ type, version }`), `customerData` (`{ providedData?, videoData? }`) |
| `viewEnd` | A view ends. | `{}` |
| `play` | Playback is requested (user or autoplay). | `{}` |
| `pause` | Playback is paused (or the source is emptied). | `{}` |
| `ended` | Playback reaches the natural end. | `{}` |
| `loadMetadata` | Video metadata has loaded. | `videoDuration` (seconds, or `'Infinity'` / `null`), `videoUrl` |
| `startup` | The first frame renders after play was requested. | `joinTime` (ms from play intent to first frame) |
| `buffering` | Once per view (summary of all rebuffering). | see [Buffering](#buffering) |
| `qualityChange` | The decoded video resolution changes (incl. the initial resolution). | `videoWidth`, `videoHeight` |
| `error` | A media error occurs. | `errorCode` (`MediaError.code` or `null`), `errorMessage` |
| `fullscreenChange` | This video enters or leaves fullscreen. | `isFullscreen` (`boolean`) |
| `pictureInPictureChange` | This video enters or leaves picture-in-picture. | `isPictureInPicture` (`boolean`) |
| `custom` | You call `reportCustomEvent`. | `name` (your event name), `data` (your payload) |
| `truncated` | A view hits the per-view event cap (250). Emitted once; further events are dropped. | `limit` |

> A view that has a `play` event but no `startup` event means playback never began — a **startup failure**. If it also has no `error`, it is an **exit before video start**.

### Buffering

Rebuffering is reported as a single summary event per view. Stalls that occur before the first frame are attributed to `startup` (join time), not buffering; a user pause during a stall ends that stall so paused time is never counted.

```json
{
"eventName": "buffering",
"eventTime": 1784973685000,
"eventDetails": {
"count": 4,
"totalDuration": 7400,
"maxDuration": 4200,
"byCause": {
"seek": { "count": 1, "totalDuration": 1200 },
"rebuffer": { "count": 3, "totalDuration": 6200 }
},
"occurrences": [
{ "atTime": 1784973620000, "duration": 4200, "cause": "rebuffer", "endedBy": "recovery" },
{ "atTime": 1784973640000, "duration": 1200, "cause": "seek", "endedBy": "recovery" }
],
"occurrencesTruncated": false
}
}
```

| Field | Description |
| --- | --- |
| `count` | Total number of buffering episodes in the view. |
| `totalDuration` | Total buffering time (ms) across all episodes. |
| `maxDuration` | Longest single episode (ms). |
| `byCause` | Per-cause totals. `cause` is `'seek'` (buffering after a user seek) or `'rebuffer'` (buffering during normal playback — the true QoE signal). |
| `occurrences` | Exact per-episode detail, capped at 20 entries. Each has `atTime`, `duration`, `cause`, and `endedBy` (`'recovery'` \| `'pause'` \| `'viewEnd'`). |
| `occurrencesTruncated` | `true` when there were more episodes than the `occurrences` array holds (totals still cover them all). |

### Live streams

For live streams (`type: 'live'`), only `viewStart` and `viewEnd` are collected, and each is sent immediately as its own beacon.

## CodeSandbox examples
[Native HTML Video Tag - Auto tracking](https://4rqcfc.csb.app/src/native-html-auto-tracking/index.html)
<br />
[Native HTML Video Tag - Manual tracking](https://4rqcfc.csb.app/src/native-html-manual-tracking/index.html)
49 changes: 32 additions & 17 deletions dev/main.js → dev/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { connectCloudinaryAnalytics } from 'cloudinary-video-analytics';

declare global {
interface Window {
connectCloudinaryAnalytics: typeof connectCloudinaryAnalytics;
}
}

window.connectCloudinaryAnalytics = connectCloudinaryAnalytics;

const videos = [
interface DemoVideo {
url: string;
publicId: string;
}

const videos: DemoVideo[] = [
{
url: 'https://res.cloudinary.com/demo/video/upload/v1651840278/samples/cld-sample-video.mp4',
publicId: 'samples/cld-sample-video',
Expand All @@ -17,22 +29,23 @@ const videos = [
];

window.addEventListener('load', () => {
const manualVideoElement = document.getElementById('main-video-player');
const manualVideoElement = document.getElementById('main-video-player') as HTMLVideoElement;
const manualCloudinaryAnalytics = connectCloudinaryAnalytics(manualVideoElement);
const connectVideoPanel = () => {
let currentSelectedRadioValue = '0';
const onMainVideoChange = (newVideoPublicId) => {
const onMainVideoChange = (newVideoPublicId: string) => {
manualVideoElement.src = newVideoPublicId;
manualCloudinaryAnalytics.startManualTracking({
cloudName: 'demo',
publicId: newVideoPublicId,
});
};

document.addEventListener('input', (e) => {
if (e.target.getAttribute('name') === 'videoType') {
currentSelectedRadioValue = e.target.value;
onMainVideoChange(videos[currentSelectedRadioValue].url);
document.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.getAttribute('name') === 'videoType') {
currentSelectedRadioValue = target.value;
onMainVideoChange(videos[Number(currentSelectedRadioValue)].url);
}
});
};
Expand All @@ -45,33 +58,34 @@ window.addEventListener('load', () => {
});

// connect extra videos
const extraVideo1Element = document.getElementById('extra-video-1');
const extraVideo1Element = document.getElementById('extra-video-1') as HTMLVideoElement;
const extraVideo1CldAnalytics = connectCloudinaryAnalytics(extraVideo1Element);
extraVideo1CldAnalytics.startManualTracking({
cloudName: 'demo',
publicId: videos[0].publicId,
});

const extraVideo2Element = document.getElementById('extra-video-2');
const extraVideo2Element = document.getElementById('extra-video-2') as HTMLVideoElement;
const extraVideo2CldAnalytics = connectCloudinaryAnalytics(extraVideo2Element);
extraVideo2CldAnalytics.startManualTracking({
cloudName: 'demo',
publicId: videos[1].publicId,
});

// auto detection
const autoVideoElement = document.getElementById('auto-video-player');
const autoVideoElement = document.getElementById('auto-video-player') as HTMLVideoElement;
const autoCloudinaryAnalytics = connectCloudinaryAnalytics(autoVideoElement);
const connectAutoVideoPanel = () => {
let currentSelectedRadioValue = '0';
const onMainVideoChange = (newVideoPublicId) => {
const onMainVideoChange = (newVideoPublicId: string) => {
autoVideoElement.src = newVideoPublicId;
};

document.addEventListener('input', (e) => {
if (e.target.getAttribute('name') === 'autoVideoType') {
currentSelectedRadioValue = e.target.value;
onMainVideoChange(videos[currentSelectedRadioValue].url || '');
document.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.getAttribute('name') === 'autoVideoType') {
currentSelectedRadioValue = target.value;
onMainVideoChange(videos[Number(currentSelectedRadioValue)].url || '');
}
});
};
Expand All @@ -87,18 +101,19 @@ window.addEventListener('load', () => {
value: 1,
},
},
customVideoUrlFallback: (videoUrl) => {
customVideoUrlFallback: (videoUrl: string) => {
if (videoUrl === 'https://res.cloudinary.com/demo/video/upload/v1692105601/dog_demo.mp4') {
return {
cloudName: 'fallback-cloud-name',
publicId: 'fallback-public-id',
};
}
return undefined;
},
});

// live stream
const liveStreamVideoElement = document.getElementById('live-stream-video-player');
const liveStreamVideoElement = document.getElementById('live-stream-video-player') as HTMLVideoElement;
const liveStreamCldAnalytics = connectCloudinaryAnalytics(liveStreamVideoElement);
liveStreamCldAnalytics.startManualTracking({
cloudName: 'demo',
Expand Down
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"version": "1.8.2",
"description": "",
"main": "dist/main.js",
"types": "dist/index.d.ts",
"packageManager": "pnpm@10.33.2",
"scripts": {
"build": "NODE_ENV=production webpack",
"build": "NODE_ENV=production webpack && tsc -p tsconfig.types.json",
"typecheck": "tsc --noEmit -p tsconfig.json",
"start": "webpack serve --config webpack.dev.config.js ",
"start-server": "node ./dev/http-server.js",
"dev": "concurrently --kill-others \"yarn start-server\" \"yarn start\""
"dev": "concurrently --kill-others \"pnpm start-server\" \"pnpm start\""
},
"author": "Cloudinary",
"license": "MIT",
Expand All @@ -16,13 +19,12 @@
"uuid": "14.0.0"
},
"devDependencies": {
"@babel/core": "7.22.5",
"@babel/preset-env": "7.22.5",
"babel-loader": "9.1.2",
"concurrently": "8.2.0",
"html-webpack-plugin": "5.5.0",
"http": "0.0.1-security",
"multiparty": "4.2.3",
"ts-loader": "9.5.1",
"typescript": "5.5.4",
"webpack": "5.88.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1",
Expand Down
Loading