@chunkify/analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +413 -0
- package/dist/core.d.ts +144 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +660 -0
- package/dist/core.js.map +1 -0
- package/dist/datadog.d.ts +8 -0
- package/dist/datadog.d.ts.map +1 -0
- package/dist/datadog.js +15 -0
- package/dist/datadog.js.map +1 -0
- package/dist/event-types.d.ts +3 -0
- package/dist/event-types.d.ts.map +1 -0
- package/dist/event-types.js +5 -0
- package/dist/event-types.js.map +1 -0
- package/dist/hlsjs.d.ts +10 -0
- package/dist/hlsjs.d.ts.map +1 -0
- package/dist/hlsjs.js +185 -0
- package/dist/hlsjs.js.map +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +43 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mapper.d.ts +11 -0
- package/dist/mapper.d.ts.map +1 -0
- package/dist/mapper.js +11 -0
- package/dist/mapper.js.map +1 -0
- package/dist/monitor.d.ts +6 -0
- package/dist/monitor.d.ts.map +1 -0
- package/dist/monitor.js +14 -0
- package/dist/monitor.js.map +1 -0
- package/dist/options.d.ts +4 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +26 -0
- package/dist/options.js.map +1 -0
- package/dist/otlp.d.ts +4 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.js +56 -0
- package/dist/otlp.js.map +1 -0
- package/dist/player.d.ts +8 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/player.js +93 -0
- package/dist/player.js.map +1 -0
- package/dist/posthog.d.ts +8 -0
- package/dist/posthog.d.ts.map +1 -0
- package/dist/posthog.js +15 -0
- package/dist/posthog.js.map +1 -0
- package/dist/shaka.d.ts +26 -0
- package/dist/shaka.d.ts.map +1 -0
- package/dist/shaka.js +164 -0
- package/dist/shaka.js.map +1 -0
- package/dist/videojs.d.ts +11 -0
- package/dist/videojs.d.ts.map +1 -0
- package/dist/videojs.js +77 -0
- package/dist/videojs.js.map +1 -0
- package/package.json +75 -0
- package/src/core.ts +774 -0
- package/src/datadog.ts +24 -0
- package/src/event-types.ts +6 -0
- package/src/hlsjs.ts +192 -0
- package/src/http.ts +43 -0
- package/src/index.ts +11 -0
- package/src/mapper.ts +22 -0
- package/src/monitor.ts +15 -0
- package/src/options.ts +28 -0
- package/src/otlp.ts +70 -0
- package/src/player.ts +96 -0
- package/src/posthog.ts +24 -0
- package/src/shaka.ts +181 -0
- package/src/videojs.ts +89 -0
package/src/datadog.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnalyticsDestination, ChunkifyPlayerAnalyticsEvent } from "./core.js";
|
|
2
|
+
|
|
3
|
+
/** The small part of an initialized Datadog RUM client needed by this adapter. */
|
|
4
|
+
export interface DatadogRumCompatibleClient {
|
|
5
|
+
addAction(name: string, context: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Send one action per event, optionally mapping its Datadog context. */
|
|
9
|
+
export function createDatadogAdapter(
|
|
10
|
+
client: DatadogRumCompatibleClient,
|
|
11
|
+
mapEvent: (event: ChunkifyPlayerAnalyticsEvent) => Record<string, unknown> = (event) => ({ ...event }),
|
|
12
|
+
): AnalyticsDestination {
|
|
13
|
+
if (!client || typeof client.addAction !== "function") {
|
|
14
|
+
throw new TypeError("Datadog adapter requires an initialized client with addAction().");
|
|
15
|
+
}
|
|
16
|
+
if (typeof mapEvent !== "function") {
|
|
17
|
+
throw new TypeError("Datadog event mapper must be a function.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
send(event: ChunkifyPlayerAnalyticsEvent) {
|
|
21
|
+
client.addAction(event.event_name, mapEvent(event));
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/hlsjs.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type Hls from "hls.js";
|
|
2
|
+
import type {
|
|
3
|
+
AudioTrackLoadedData, ErrorData, FragLoadedData, LevelLoadedData,
|
|
4
|
+
LevelSwitchedData, LoaderStats, ManifestLoadedData, SubtitleTrackLoadedData,
|
|
5
|
+
} from "hls.js";
|
|
6
|
+
import { attachAnalyticsWithManagedSource, type AnalyticsConnector, type AnalyticsConnectorError,
|
|
7
|
+
type AnalyticsConnectorSnapshot, type AnalyticsOptions, type AnalyticsRendition,
|
|
8
|
+
type AnalyticsSession } from "./core.js";
|
|
9
|
+
import { assertAnalyticsOptions } from "./options.js";
|
|
10
|
+
|
|
11
|
+
export interface HlsJsAnalyticsConnector extends AnalyticsConnector {
|
|
12
|
+
destroy(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type HlsJsSnapshot = AnalyticsConnectorSnapshot & {
|
|
16
|
+
renditionSwitchCount: number;
|
|
17
|
+
deliveryRequestCount: number;
|
|
18
|
+
deliveryTransferredBytes: number;
|
|
19
|
+
deliveryDurationMs: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Observe an hls.js instance without changing its playback configuration. */
|
|
23
|
+
export function createHlsJsConnector(hls: Hls): HlsJsAnalyticsConnector {
|
|
24
|
+
const HlsClass = hls.constructor as typeof Hls;
|
|
25
|
+
const snapshot: HlsJsSnapshot = {
|
|
26
|
+
renditionWidth: null, renditionHeight: null, renditionCodec: null, renditionBitrate: null,
|
|
27
|
+
renditionSwitchCount: 0, deliveryRequestCount: 0, deliveryTransferredBytes: 0,
|
|
28
|
+
deliveryDurationMs: 0,
|
|
29
|
+
};
|
|
30
|
+
const seenStats = new WeakSet<object>();
|
|
31
|
+
const errorListeners = new Set<(error: AnalyticsConnectorError) => void>();
|
|
32
|
+
const renditionListeners = new Set<(rendition: AnalyticsRendition | null) => void>();
|
|
33
|
+
let selectedLevel: number | null = null;
|
|
34
|
+
let currentRendition: AnalyticsRendition | null = null;
|
|
35
|
+
let destroyed = false;
|
|
36
|
+
|
|
37
|
+
const selectLevel = (level: number) => {
|
|
38
|
+
const rendition = hls.levels[level];
|
|
39
|
+
if (!rendition) return;
|
|
40
|
+
if (selectedLevel !== null && selectedLevel !== level) snapshot.renditionSwitchCount++;
|
|
41
|
+
selectedLevel = level;
|
|
42
|
+
snapshot.renditionWidth = rendition.width || null;
|
|
43
|
+
snapshot.renditionHeight = rendition.height || null;
|
|
44
|
+
snapshot.renditionCodec = rendition.videoCodec || rendition.codecSet || null;
|
|
45
|
+
snapshot.renditionBitrate = rendition.bitrate || null;
|
|
46
|
+
currentRendition = {
|
|
47
|
+
width: snapshot.renditionWidth,
|
|
48
|
+
height: snapshot.renditionHeight,
|
|
49
|
+
codec: snapshot.renditionCodec,
|
|
50
|
+
bitrate_bps: snapshot.renditionBitrate,
|
|
51
|
+
frame_rate: rendition.frameRate || null,
|
|
52
|
+
};
|
|
53
|
+
for (const listener of renditionListeners) listener(currentRendition);
|
|
54
|
+
};
|
|
55
|
+
if (hls.currentLevel >= 0) selectLevel(hls.currentLevel);
|
|
56
|
+
|
|
57
|
+
const recordStats = (stats: LoaderStats | null | undefined) => {
|
|
58
|
+
if (!stats || seenStats.has(stats)) return;
|
|
59
|
+
seenStats.add(stats);
|
|
60
|
+
const bytes = Number.isFinite(stats.loaded) && stats.loaded >= 0 ? stats.loaded : 0;
|
|
61
|
+
const elapsed = stats.loading.end - stats.loading.start;
|
|
62
|
+
snapshot.deliveryRequestCount++;
|
|
63
|
+
snapshot.deliveryTransferredBytes += bytes;
|
|
64
|
+
if (Number.isFinite(elapsed) && elapsed > 0) snapshot.deliveryDurationMs += elapsed;
|
|
65
|
+
};
|
|
66
|
+
const onManifestLoaded = (_event: unknown, data: ManifestLoadedData) => recordStats(data.stats);
|
|
67
|
+
const onLevelLoaded = (_event: unknown, data: LevelLoadedData) => recordStats(data.stats);
|
|
68
|
+
const onAudioTrackLoaded = (_event: unknown, data: AudioTrackLoadedData) => recordStats(data.stats);
|
|
69
|
+
const onSubtitleTrackLoaded = (_event: unknown, data: SubtitleTrackLoadedData) => recordStats(data.stats);
|
|
70
|
+
const onFragLoaded = (_event: unknown, data: FragLoadedData) => recordStats(data.part?.stats ?? data.frag.stats);
|
|
71
|
+
const onLevelSwitched = (_event: unknown, data: LevelSwitchedData) => selectLevel(data.level);
|
|
72
|
+
const onError = (_event: unknown, data: ErrorData) => {
|
|
73
|
+
if (!data.fatal) return;
|
|
74
|
+
const category = data.type === HlsClass.ErrorTypes.NETWORK_ERROR ? "network"
|
|
75
|
+
: data.type === HlsClass.ErrorTypes.MEDIA_ERROR || data.type === HlsClass.ErrorTypes.MUX_ERROR ? "decode"
|
|
76
|
+
: data.type === HlsClass.ErrorTypes.KEY_SYSTEM_ERROR ? "unsupported" : "unknown";
|
|
77
|
+
for (const listener of errorListeners) listener({ category, code: data.details, fatal: true });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
hls.on(HlsClass.Events.MANIFEST_LOADED, onManifestLoaded);
|
|
81
|
+
hls.on(HlsClass.Events.LEVEL_LOADED, onLevelLoaded);
|
|
82
|
+
hls.on(HlsClass.Events.AUDIO_TRACK_LOADED, onAudioTrackLoaded);
|
|
83
|
+
hls.on(HlsClass.Events.SUBTITLE_TRACK_LOADED, onSubtitleTrackLoaded);
|
|
84
|
+
hls.on(HlsClass.Events.FRAG_LOADED, onFragLoaded);
|
|
85
|
+
hls.on(HlsClass.Events.LEVEL_SWITCHED, onLevelSwitched);
|
|
86
|
+
hls.on(HlsClass.Events.ERROR, onError);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name: "hls.js", version: HlsClass.version,
|
|
90
|
+
playerName: "hls.js", playerVersion: HlsClass.version,
|
|
91
|
+
snapshot: () => ({ ...snapshot }),
|
|
92
|
+
subscribeToErrors(listener) {
|
|
93
|
+
errorListeners.add(listener);
|
|
94
|
+
return () => { errorListeners.delete(listener); };
|
|
95
|
+
},
|
|
96
|
+
subscribeToRenditions(listener) {
|
|
97
|
+
renditionListeners.add(listener);
|
|
98
|
+
listener(currentRendition);
|
|
99
|
+
return () => { renditionListeners.delete(listener); };
|
|
100
|
+
},
|
|
101
|
+
destroy() {
|
|
102
|
+
if (destroyed) return;
|
|
103
|
+
destroyed = true;
|
|
104
|
+
hls.off(HlsClass.Events.MANIFEST_LOADED, onManifestLoaded);
|
|
105
|
+
hls.off(HlsClass.Events.LEVEL_LOADED, onLevelLoaded);
|
|
106
|
+
hls.off(HlsClass.Events.AUDIO_TRACK_LOADED, onAudioTrackLoaded);
|
|
107
|
+
hls.off(HlsClass.Events.SUBTITLE_TRACK_LOADED, onSubtitleTrackLoaded);
|
|
108
|
+
hls.off(HlsClass.Events.FRAG_LOADED, onFragLoaded);
|
|
109
|
+
hls.off(HlsClass.Events.LEVEL_SWITCHED, onLevelSwitched);
|
|
110
|
+
hls.off(HlsClass.Events.ERROR, onError);
|
|
111
|
+
errorListeners.clear();
|
|
112
|
+
renditionListeners.clear();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Follow an hls.js instance as it attaches to or detaches from media. */
|
|
118
|
+
export function monitorHlsJs(hls: Hls, options: AnalyticsOptions):
|
|
119
|
+
Pick<AnalyticsSession, "startAttempt" | "sourceChanged" | "destroy"> {
|
|
120
|
+
assertAnalyticsOptions(options);
|
|
121
|
+
const HlsClass = hls.constructor as typeof Hls;
|
|
122
|
+
let media: HTMLMediaElement | null = null;
|
|
123
|
+
let session: AnalyticsSession | null = null;
|
|
124
|
+
let connector: HlsJsAnalyticsConnector | null = null;
|
|
125
|
+
let pendingAttempt = false;
|
|
126
|
+
let destroyed = false;
|
|
127
|
+
|
|
128
|
+
const releaseMedia = (sourceChanged: boolean) => {
|
|
129
|
+
const previousSession = session;
|
|
130
|
+
const previousConnector = connector;
|
|
131
|
+
media = null;
|
|
132
|
+
session = null;
|
|
133
|
+
connector = null;
|
|
134
|
+
if (sourceChanged) previousSession?.sourceChanged();
|
|
135
|
+
previousSession?.destroy();
|
|
136
|
+
previousConnector?.destroy();
|
|
137
|
+
};
|
|
138
|
+
const attachMedia = () => {
|
|
139
|
+
const nextMedia = hls.media;
|
|
140
|
+
if (destroyed || !nextMedia || nextMedia === media) return;
|
|
141
|
+
if (media) releaseMedia(true);
|
|
142
|
+
const nextSession = attachAnalyticsWithManagedSource(nextMedia, options);
|
|
143
|
+
media = nextMedia;
|
|
144
|
+
session = nextSession;
|
|
145
|
+
try {
|
|
146
|
+
connector = createHlsJsConnector(hls);
|
|
147
|
+
nextSession.setConnector(connector);
|
|
148
|
+
} catch {
|
|
149
|
+
// An engine measurement failure must not prevent media playback analytics.
|
|
150
|
+
}
|
|
151
|
+
if (pendingAttempt) {
|
|
152
|
+
pendingAttempt = false;
|
|
153
|
+
nextSession.startAttempt();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const onMediaAttaching = () => attachMedia();
|
|
157
|
+
const onMediaDetaching = () => {
|
|
158
|
+
// hls.js can detach and reattach the same media synchronously while
|
|
159
|
+
// recovering an error or recreating its MediaSource. That is one view.
|
|
160
|
+
queueMicrotask(() => {
|
|
161
|
+
if (!destroyed && !hls.media) releaseMedia(true);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const onDestroying = () => destroy();
|
|
165
|
+
function destroy() {
|
|
166
|
+
if (destroyed) return;
|
|
167
|
+
destroyed = true;
|
|
168
|
+
hls.off(HlsClass.Events.MEDIA_ATTACHING, onMediaAttaching);
|
|
169
|
+
hls.off(HlsClass.Events.MEDIA_DETACHING, onMediaDetaching);
|
|
170
|
+
hls.off(HlsClass.Events.DESTROYING, onDestroying);
|
|
171
|
+
releaseMedia(false);
|
|
172
|
+
pendingAttempt = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
hls.on(HlsClass.Events.MEDIA_ATTACHING, onMediaAttaching);
|
|
176
|
+
hls.on(HlsClass.Events.MEDIA_DETACHING, onMediaDetaching);
|
|
177
|
+
hls.on(HlsClass.Events.DESTROYING, onDestroying);
|
|
178
|
+
attachMedia();
|
|
179
|
+
return {
|
|
180
|
+
startAttempt() {
|
|
181
|
+
if (destroyed) return;
|
|
182
|
+
if (session) session.startAttempt();
|
|
183
|
+
else pendingAttempt = true;
|
|
184
|
+
},
|
|
185
|
+
sourceChanged() {
|
|
186
|
+
if (destroyed) return;
|
|
187
|
+
pendingAttempt = false;
|
|
188
|
+
session?.sourceChanged();
|
|
189
|
+
},
|
|
190
|
+
destroy,
|
|
191
|
+
};
|
|
192
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AnalyticsDestination, ChunkifyPlayerAnalyticsEvent } from "./core.js";
|
|
2
|
+
|
|
3
|
+
/** POST each event as JSON to a customer endpoint. No credentials or custom headers are accepted. */
|
|
4
|
+
export function createHttpAdapter(
|
|
5
|
+
endpoint: string,
|
|
6
|
+
mapEvent: (event: ChunkifyPlayerAnalyticsEvent) => unknown = (event) => event,
|
|
7
|
+
): AnalyticsDestination {
|
|
8
|
+
if (typeof mapEvent !== "function") throw new TypeError("HTTP adapter mapper must be a function.");
|
|
9
|
+
if (typeof endpoint !== "string" || !endpoint || endpoint.startsWith("//")) {
|
|
10
|
+
throw new TypeError("HTTP adapter requires an HTTPS URL or a root-relative path.");
|
|
11
|
+
}
|
|
12
|
+
const rootRelative = endpoint.startsWith("/");
|
|
13
|
+
const absolute = /^[a-z][a-z\d+.-]*:/i.test(endpoint);
|
|
14
|
+
let url: URL;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(endpoint, "https://chunkify.invalid");
|
|
17
|
+
} catch {
|
|
18
|
+
throw new TypeError("HTTP adapter requires a valid endpoint URL.");
|
|
19
|
+
}
|
|
20
|
+
const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
21
|
+
if ((!rootRelative && !absolute) ||
|
|
22
|
+
(rootRelative && url.origin !== "https://chunkify.invalid") ||
|
|
23
|
+
(!rootRelative && url.protocol !== "https:" && !localHttp) ||
|
|
24
|
+
url.username || url.password || url.search || url.hash) {
|
|
25
|
+
throw new TypeError("HTTP adapter requires a root-relative path or HTTPS URL without credentials or query parameters (HTTP is allowed on loopback).");
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
async send(event: ChunkifyPlayerAnalyticsEvent) {
|
|
29
|
+
const body = JSON.stringify(mapEvent(event));
|
|
30
|
+
if (body === undefined) throw new TypeError("HTTP adapter mapper must return a JSON value.");
|
|
31
|
+
const response = await fetch(endpoint, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body,
|
|
35
|
+
credentials: "omit",
|
|
36
|
+
referrerPolicy: "no-referrer",
|
|
37
|
+
redirect: "error",
|
|
38
|
+
keepalive: event.event_name === "chunkify.video.view_ended",
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) throw new Error(`Analytics endpoint returned HTTP ${response.status}.`);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { attachAnalytics } from "./core.js";
|
|
2
|
+
export type { AnalyticsEventType, AnalyticsEndReason, AnalyticsErrorCategory, AnalyticsStartupOutcome,
|
|
3
|
+
AnalyticsPlaybackState, AnalyticsEventEnvelope, AnalyticsViewEvent, AnalyticsPlaybackStateEvent,
|
|
4
|
+
AnalyticsRenditionEvent, RenditionPlayingTime, AnalyticsRendition, ViewMetrics, ChunkifyPlayerAnalyticsEvent,
|
|
5
|
+
AnalyticsDestination, AnalyticsConfig, AnalyticsOptions, AnalyticsConnectorSnapshot,
|
|
6
|
+
AnalyticsConnectorError, AnalyticsConnector, AnalyticsSession } from "./core.js";
|
|
7
|
+
export { createEventMapper } from "./mapper.js";
|
|
8
|
+
export { monitor, type MonitorSession } from "./monitor.js";
|
|
9
|
+
export { monitorHlsJs } from "./hlsjs.js";
|
|
10
|
+
export { monitorVideoJs } from "./videojs.js";
|
|
11
|
+
export { monitorShaka } from "./shaka.js";
|
package/src/mapper.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AnalyticsPlaybackStateEvent, AnalyticsRenditionEvent, AnalyticsViewEvent,
|
|
2
|
+
ChunkifyPlayerAnalyticsEvent } from "./core.js";
|
|
3
|
+
|
|
4
|
+
type EventField = keyof AnalyticsViewEvent | keyof AnalyticsPlaybackStateEvent | keyof AnalyticsRenditionEvent;
|
|
5
|
+
type FieldSelector = EventField | ((event: ChunkifyPlayerAnalyticsEvent) => unknown);
|
|
6
|
+
type EventFieldValue<Key extends EventField> = ChunkifyPlayerAnalyticsEvent extends infer Event
|
|
7
|
+
? Event extends unknown ? Key extends keyof Event ? Event[Key] : undefined : never
|
|
8
|
+
: never;
|
|
9
|
+
type SelectedValue<Selector> = Selector extends EventField
|
|
10
|
+
? EventFieldValue<Selector>
|
|
11
|
+
: Selector extends (event: ChunkifyPlayerAnalyticsEvent) => infer Value ? Value : never;
|
|
12
|
+
|
|
13
|
+
/** Select, rename, or calculate fields for a destination without changing the source event. */
|
|
14
|
+
export function createEventMapper<const Fields extends Record<string, FieldSelector>>(fields: Fields):
|
|
15
|
+
(event: ChunkifyPlayerAnalyticsEvent) => { [Name in keyof Fields]: SelectedValue<Fields[Name]> } {
|
|
16
|
+
const entries = Object.entries(fields);
|
|
17
|
+
return (event) => Object.fromEntries(entries.flatMap(([name, source]) => {
|
|
18
|
+
if (typeof source !== "function" && !(source in event)) return [];
|
|
19
|
+
const value = typeof source === "function" ? source(event) : event[source as keyof typeof event];
|
|
20
|
+
return value === undefined ? [] : [[name, value]];
|
|
21
|
+
})) as { [Name in keyof Fields]: SelectedValue<Fields[Name]> };
|
|
22
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { attachAnalytics, type AnalyticsOptions, type AnalyticsSession } from "./core.js";
|
|
2
|
+
import { attachPlayerAnalytics, type MediaPlayerHost } from "./player.js";
|
|
3
|
+
|
|
4
|
+
export type MonitorSession = Pick<AnalyticsSession, "startAttempt" | "sourceChanged" | "destroy">;
|
|
5
|
+
|
|
6
|
+
/** Start analytics for a video element or a Chunkify Player. */
|
|
7
|
+
export function monitor(target: HTMLVideoElement | MediaPlayerHost, options: AnalyticsOptions): MonitorSession {
|
|
8
|
+
const view = target?.ownerDocument?.defaultView;
|
|
9
|
+
if (view && target instanceof view.HTMLVideoElement) return attachAnalytics(target, options);
|
|
10
|
+
if (view && target instanceof view.HTMLElement
|
|
11
|
+
&& target.localName === "chunkify-player" && "media" in target) {
|
|
12
|
+
return attachPlayerAnalytics(target as MediaPlayerHost, options);
|
|
13
|
+
}
|
|
14
|
+
throw new TypeError("Analytics supports a <video> or <chunkify-player> element.");
|
|
15
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AnalyticsOptions } from "./core.js";
|
|
2
|
+
import { analyticsEventTypes } from "./event-types.js";
|
|
3
|
+
|
|
4
|
+
const eventTypes = new Set<string>(analyticsEventTypes);
|
|
5
|
+
|
|
6
|
+
/** Validate options before installing any media or engine listeners. */
|
|
7
|
+
export function assertAnalyticsOptions(options: AnalyticsOptions): void {
|
|
8
|
+
if (typeof options.videoId !== "string" || !options.videoId.trim()) {
|
|
9
|
+
throw new TypeError("Analytics requires a non-empty videoId.");
|
|
10
|
+
}
|
|
11
|
+
if (!Array.isArray(options.destinations) || options.destinations.length === 0 ||
|
|
12
|
+
!Array.from(options.destinations).every((destination) => destination && typeof destination.send === "function")) {
|
|
13
|
+
throw new TypeError("Analytics requires a non-empty destinations array with send functions.");
|
|
14
|
+
}
|
|
15
|
+
if ((options.environment !== undefined && typeof options.environment !== "string") ||
|
|
16
|
+
(options.viewerId !== undefined && typeof options.viewerId !== "string")) {
|
|
17
|
+
throw new TypeError("Analytics environment and viewerId must be strings when provided.");
|
|
18
|
+
}
|
|
19
|
+
if (options.snapshotIntervalMs !== undefined &&
|
|
20
|
+
(!Number.isSafeInteger(options.snapshotIntervalMs) || options.snapshotIntervalMs < 1000 ||
|
|
21
|
+
options.snapshotIntervalMs > 2_147_483_647)) {
|
|
22
|
+
throw new TypeError("Analytics snapshotIntervalMs must be an integer from 1000 to 2147483647 ms.");
|
|
23
|
+
}
|
|
24
|
+
if (options.includeEvents !== undefined &&
|
|
25
|
+
(!Array.isArray(options.includeEvents) || !options.includeEvents.every((event) => eventTypes.has(event)))) {
|
|
26
|
+
throw new TypeError("Analytics includeEvents must be an array of supported event types.");
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/otlp.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AnalyticsDestination, ChunkifyPlayerAnalyticsEvent } from "./core.js";
|
|
2
|
+
import { createHttpAdapter } from "./http.js";
|
|
3
|
+
|
|
4
|
+
type OtlpValue =
|
|
5
|
+
| { stringValue: string }
|
|
6
|
+
| { boolValue: boolean }
|
|
7
|
+
| { intValue: string }
|
|
8
|
+
| { doubleValue: number }
|
|
9
|
+
| { arrayValue: { values: OtlpValue[] } }
|
|
10
|
+
| { kvlistValue: { values: OtlpAttribute[] } };
|
|
11
|
+
|
|
12
|
+
interface OtlpAttribute {
|
|
13
|
+
key: string;
|
|
14
|
+
value: OtlpValue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toOtlpValue(value: unknown): OtlpValue | null {
|
|
18
|
+
if (value == null) return null;
|
|
19
|
+
if (typeof value === "string") return { stringValue: value };
|
|
20
|
+
if (typeof value === "boolean") return { boolValue: value };
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (!Number.isFinite(value)) return null;
|
|
23
|
+
return Number.isSafeInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return { arrayValue: { values: value.map(toOtlpValue).filter((item): item is OtlpValue => item !== null) } };
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "object") return { kvlistValue: { values: toOtlpAttributes(value) } };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toOtlpAttributes(record: object): OtlpAttribute[] {
|
|
33
|
+
return Object.entries(record).flatMap(([key, value]) => {
|
|
34
|
+
const encoded = toOtlpValue(value);
|
|
35
|
+
return encoded === null ? [] : [{ key, value: encoded }];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toOtlpLogRequest(event: ChunkifyPlayerAnalyticsEvent, attributes: object) {
|
|
40
|
+
const occurredAtMs = Date.parse(event.occurred_at);
|
|
41
|
+
if (!Number.isFinite(occurredAtMs)) throw new TypeError("OTLP adapter requires a valid occurred_at timestamp.");
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
resourceLogs: [{
|
|
45
|
+
scopeLogs: [{
|
|
46
|
+
scope: { name: "@chunkify/analytics", version: event.sdk_version },
|
|
47
|
+
logRecords: [{
|
|
48
|
+
timeUnixNano: String(BigInt(occurredAtMs) * 1_000_000n),
|
|
49
|
+
eventName: event.event_name,
|
|
50
|
+
attributes: toOtlpAttributes(attributes),
|
|
51
|
+
}],
|
|
52
|
+
}],
|
|
53
|
+
}],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Send each analytics event as an OTLP/HTTP JSON log record to a customer gateway. */
|
|
58
|
+
export function createOtlpAdapter(
|
|
59
|
+
endpoint: string,
|
|
60
|
+
mapAttributes: (event: ChunkifyPlayerAnalyticsEvent) => object = (event) => event,
|
|
61
|
+
): AnalyticsDestination {
|
|
62
|
+
if (typeof mapAttributes !== "function") throw new TypeError("OTLP attribute mapper must be a function.");
|
|
63
|
+
return createHttpAdapter(endpoint, (event) => {
|
|
64
|
+
const attributes = mapAttributes(event);
|
|
65
|
+
if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) {
|
|
66
|
+
throw new TypeError("OTLP attribute mapper must return an object.");
|
|
67
|
+
}
|
|
68
|
+
return toOtlpLogRequest(event, attributes);
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/player.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { attachAnalytics, type AnalyticsOptions, type AnalyticsSession } from "./core.js";
|
|
2
|
+
import { createHlsJsConnector, type HlsJsAnalyticsConnector } from "./hlsjs.js";
|
|
3
|
+
import type Hls from "hls.js";
|
|
4
|
+
|
|
5
|
+
/** The public surface needed from a player that can replace its media element. */
|
|
6
|
+
export interface MediaPlayerHost extends HTMLElement {
|
|
7
|
+
readonly media: HTMLMediaElement;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Attach the standalone media collector to a Chunkify Player. */
|
|
11
|
+
export function attachPlayerAnalytics(
|
|
12
|
+
player: MediaPlayerHost,
|
|
13
|
+
options: AnalyticsOptions,
|
|
14
|
+
): Pick<AnalyticsSession, "startAttempt" | "sourceChanged" | "destroy"> {
|
|
15
|
+
let media = player.media;
|
|
16
|
+
let session = attachAnalytics(media, options);
|
|
17
|
+
let destroyed = false;
|
|
18
|
+
let activeHls: Hls | null = null;
|
|
19
|
+
let connector: HlsJsAnalyticsConnector | null = null;
|
|
20
|
+
let checkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
const clearCheckTimer = () => {
|
|
23
|
+
if (checkTimer === null) return;
|
|
24
|
+
clearTimeout(checkTimer);
|
|
25
|
+
checkTimer = null;
|
|
26
|
+
};
|
|
27
|
+
const detachConnector = () => {
|
|
28
|
+
session.setConnector(null);
|
|
29
|
+
connector?.destroy();
|
|
30
|
+
connector = null;
|
|
31
|
+
activeHls = null;
|
|
32
|
+
};
|
|
33
|
+
const syncConnector = () => {
|
|
34
|
+
if (destroyed) return;
|
|
35
|
+
const hls = media.localName === "hls-video"
|
|
36
|
+
? (media as HTMLMediaElement & { api?: Hls | null }).api ?? null : null;
|
|
37
|
+
if (hls === activeHls) return;
|
|
38
|
+
detachConnector();
|
|
39
|
+
if (!hls) return;
|
|
40
|
+
try {
|
|
41
|
+
const next = createHlsJsConnector(hls);
|
|
42
|
+
session.setConnector(next);
|
|
43
|
+
connector = next;
|
|
44
|
+
activeHls = hls;
|
|
45
|
+
} catch {
|
|
46
|
+
// Analytics must not interrupt playback if an engine is unavailable.
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const scheduleConnectorCheck = () => {
|
|
50
|
+
clearCheckTimer();
|
|
51
|
+
checkTimer = globalThis.setTimeout(() => {
|
|
52
|
+
checkTimer = null;
|
|
53
|
+
syncConnector();
|
|
54
|
+
}, 0);
|
|
55
|
+
};
|
|
56
|
+
const observer = new MutationObserver(scheduleConnectorCheck);
|
|
57
|
+
const bindMedia = () => {
|
|
58
|
+
media.addEventListener("loadstart", syncConnector);
|
|
59
|
+
observer.observe(media, { attributes: true, attributeFilter: ["src"] });
|
|
60
|
+
syncConnector();
|
|
61
|
+
// hls-video-element creates its hls.js instance after setting src.
|
|
62
|
+
scheduleConnectorCheck();
|
|
63
|
+
};
|
|
64
|
+
const unbindMedia = () => {
|
|
65
|
+
clearCheckTimer();
|
|
66
|
+
observer.disconnect();
|
|
67
|
+
media.removeEventListener("loadstart", syncConnector);
|
|
68
|
+
};
|
|
69
|
+
bindMedia();
|
|
70
|
+
|
|
71
|
+
const onMediaChange = () => {
|
|
72
|
+
const next = player.media;
|
|
73
|
+
if (destroyed || next === media) return;
|
|
74
|
+
session.sourceChanged();
|
|
75
|
+
unbindMedia();
|
|
76
|
+
session.destroy();
|
|
77
|
+
detachConnector();
|
|
78
|
+
media = next;
|
|
79
|
+
session = attachAnalytics(media, options);
|
|
80
|
+
bindMedia();
|
|
81
|
+
};
|
|
82
|
+
player.addEventListener("mediachange", onMediaChange);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
startAttempt() { if (!destroyed) session.startAttempt(); },
|
|
86
|
+
sourceChanged() { if (!destroyed) session.sourceChanged(); },
|
|
87
|
+
destroy() {
|
|
88
|
+
if (destroyed) return;
|
|
89
|
+
destroyed = true;
|
|
90
|
+
player.removeEventListener("mediachange", onMediaChange);
|
|
91
|
+
unbindMedia();
|
|
92
|
+
session.destroy();
|
|
93
|
+
detachConnector();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/posthog.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnalyticsDestination, ChunkifyPlayerAnalyticsEvent } from "./core.js";
|
|
2
|
+
|
|
3
|
+
/** The small part of an initialized PostHog browser client needed by this adapter. */
|
|
4
|
+
export interface PostHogCompatibleClient {
|
|
5
|
+
capture(name: string, properties: Record<string, unknown>): unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Send one PostHog event for each analytics event. */
|
|
9
|
+
export function createPostHogAdapter(
|
|
10
|
+
client: PostHogCompatibleClient,
|
|
11
|
+
mapEvent: (event: ChunkifyPlayerAnalyticsEvent) => Record<string, unknown> = (event) => ({ ...event }),
|
|
12
|
+
): AnalyticsDestination {
|
|
13
|
+
if (!client || typeof client.capture !== "function") {
|
|
14
|
+
throw new TypeError("PostHog adapter requires an initialized client with capture().");
|
|
15
|
+
}
|
|
16
|
+
if (typeof mapEvent !== "function") {
|
|
17
|
+
throw new TypeError("PostHog event mapper must be a function.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
send(event: ChunkifyPlayerAnalyticsEvent) {
|
|
21
|
+
client.capture(event.event_name, mapEvent(event));
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|