@player-ui/metrics-plugin 0.0.1-next.1

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/src/metrics.ts ADDED
@@ -0,0 +1,586 @@
1
+ import type { Player, PlayerPlugin } from '@player-ui/player';
2
+ import { SyncHook, SyncBailHook } from 'tapable';
3
+ import type { BeaconPluginPlugin, BeaconArgs } from '@player-ui/beacon-plugin';
4
+ import { BeaconPlugin } from '@player-ui/beacon-plugin';
5
+ import {
6
+ MetricsCorePluginSymbol,
7
+ MetricsViewBeaconPluginContextSymbol,
8
+ } from './symbols';
9
+
10
+ // Try to use performance.now() but fall back to Date.now() if you can't
11
+ export const defaultGetTime =
12
+ typeof performance === 'undefined'
13
+ ? () => Date.now()
14
+ : () => performance.now();
15
+
16
+ export type Timing = {
17
+ /** Time this duration started (ms) */
18
+ startTime: number;
19
+ } & (
20
+ | {
21
+ /** Flag set if this is currently in progress */
22
+ completed: false;
23
+ }
24
+ | {
25
+ /** The stopwatch has stopped */
26
+ completed: true;
27
+
28
+ /** The time in (ms) that the process ended */
29
+ endTime: number;
30
+
31
+ /** The elapsed time of this event (ms) */
32
+ duration: number;
33
+ }
34
+ );
35
+
36
+ export type NodeMetrics = Timing & {
37
+ /** The type of the flow-state */
38
+ stateType: string;
39
+
40
+ /** The name of the flow-state */
41
+ stateName: string;
42
+ };
43
+
44
+ export type NodeRenderMetrics = NodeMetrics & {
45
+ /** Timing representing the initial render */
46
+ render: Timing;
47
+
48
+ /** An array of timings representing updates to the view */
49
+ updates: Array<Timing>;
50
+ };
51
+
52
+ export interface PlayerFlowMetrics {
53
+ /** All metrics about a running flow */
54
+ flow?: {
55
+ /** The id of the flow these metrics are for */
56
+ id: string;
57
+
58
+ /** request time */
59
+ requestTime?: number;
60
+
61
+ /** A timeline of events for each node-state */
62
+ timeline: Array<NodeMetrics | NodeRenderMetrics>;
63
+
64
+ /** A timing measuring until the first interactive render */
65
+ interactive: Timing;
66
+ } & Timing;
67
+ }
68
+
69
+ const callbacks = [
70
+ 'onFlowBegin',
71
+ 'onFlowEnd',
72
+ 'onInteractive',
73
+ 'onNodeStart',
74
+ 'onNodeEnd',
75
+ 'onRenderStart',
76
+ 'onRenderEnd',
77
+ 'onUpdateStart',
78
+ 'onUpdateEnd',
79
+ 'onUpdate',
80
+ ] as const;
81
+
82
+ /** Context structure for 'viewed' beacons rendering metrics */
83
+ export interface MetricsViewBeaconPluginContext {
84
+ /** Represents the time taken before the view is first rendered */
85
+ renderTime?: number;
86
+ /** request time */
87
+ requestTime?: number;
88
+ }
89
+
90
+ /** Simple [BeaconPluginPlugin] that adds renderTime to 'viewed' beacons data */
91
+ export class MetricsViewBeaconPlugin implements BeaconPluginPlugin {
92
+ static Symbol = MetricsViewBeaconPluginContextSymbol;
93
+ public readonly symbol = MetricsViewBeaconPlugin.Symbol;
94
+
95
+ private metricsPlugin: MetricsCorePlugin;
96
+
97
+ private resolvePendingRenderTime: ((renderTime: number) => void) | undefined;
98
+
99
+ constructor(metricsPlugin: MetricsCorePlugin) {
100
+ this.metricsPlugin = metricsPlugin;
101
+ this.metricsPlugin.hooks.onRenderEnd.tap(
102
+ 'MetricsViewBeaconPlugin',
103
+ (timing) => {
104
+ if (timing.completed && this.resolvePendingRenderTime) {
105
+ this.resolvePendingRenderTime(timing.duration);
106
+ this.resolvePendingRenderTime = undefined;
107
+ }
108
+ }
109
+ );
110
+ }
111
+
112
+ apply(beaconPlugin: BeaconPlugin) {
113
+ beaconPlugin.hooks.buildBeacon.intercept({
114
+ context: true,
115
+ call: (context, beacon) => {
116
+ if (context && (beacon as BeaconArgs).action === 'viewed') {
117
+ context[this.symbol] = this.buildContext();
118
+ }
119
+ },
120
+ });
121
+ }
122
+
123
+ private async buildContext(): Promise<MetricsViewBeaconPluginContext> {
124
+ return {
125
+ renderTime: await this.getRenderTime(),
126
+ requestTime: this.getRequestTime(),
127
+ };
128
+ }
129
+
130
+ private async getRenderTime(): Promise<number> {
131
+ const { flow } = this.metricsPlugin.getMetrics();
132
+
133
+ if (flow) {
134
+ const lastItem = flow.timeline[flow.timeline.length - 1];
135
+
136
+ if ('render' in lastItem && lastItem.render.completed) {
137
+ return lastItem.render.duration;
138
+ }
139
+ }
140
+
141
+ return new Promise((resolve) => {
142
+ this.resolvePendingRenderTime = resolve;
143
+ });
144
+ }
145
+
146
+ private getRequestTime(): number | undefined {
147
+ const { flow } = this.metricsPlugin.getMetrics();
148
+
149
+ return flow?.requestTime;
150
+ }
151
+ }
152
+
153
+ export interface MetricsWebPluginOptions {
154
+ /** Called when a flow starts */
155
+ onFlowBegin?: (update: PlayerFlowMetrics) => void;
156
+
157
+ /** Called when a flow ends */
158
+ onFlowEnd?: (update: PlayerFlowMetrics) => void;
159
+
160
+ /** Called when a flow becomes interactive for the first time */
161
+ onInteractive?: (timing: Timing, update: PlayerFlowMetrics) => void;
162
+
163
+ /** Called when a new node is started */
164
+ onNodeStart?: (
165
+ nodeMetrics: NodeMetrics | NodeRenderMetrics,
166
+ update: PlayerFlowMetrics
167
+ ) => void;
168
+
169
+ /** Called when a node is ended */
170
+ onNodeEnd?: (
171
+ nodeMetrics: NodeMetrics | NodeRenderMetrics,
172
+ update: PlayerFlowMetrics
173
+ ) => void;
174
+
175
+ /** Called when rendering for a node begins */
176
+ onRenderStart?: (
177
+ timing: Timing,
178
+ nodeMetrics: NodeRenderMetrics,
179
+ update: PlayerFlowMetrics
180
+ ) => void;
181
+
182
+ /** Called when rendering for a node ends */
183
+ onRenderEnd?: (
184
+ timing: Timing,
185
+ nodeMetrics: NodeRenderMetrics,
186
+ update: PlayerFlowMetrics
187
+ ) => void;
188
+
189
+ /** Called when an update for a node begins */
190
+ onUpdateStart?: (
191
+ timing: Timing,
192
+ nodeMetrics: NodeRenderMetrics,
193
+ update: PlayerFlowMetrics
194
+ ) => void;
195
+
196
+ /** Called when an update for a node ends */
197
+ onUpdateEnd?: (
198
+ timing: Timing,
199
+ nodeMetrics: NodeRenderMetrics,
200
+ update: PlayerFlowMetrics
201
+ ) => void;
202
+
203
+ /** Callback to subscribe to updates for any metric */
204
+ onUpdate?: (metrics: PlayerFlowMetrics) => void;
205
+
206
+ /**
207
+ * A flag to set if you want to track render times for nodes
208
+ * This requires that the UI calls `renderEnd()` when the view is painted.
209
+ */
210
+ trackRenderTime?: boolean;
211
+
212
+ /**
213
+ * A flag to set if you want to track update times for nodes
214
+ * This requires that the UI calls `renderEnd()` when the view is painted.
215
+ */
216
+ trackUpdateTime?: boolean;
217
+
218
+ /** A function to get the current time (in ms) */
219
+ getTime?: () => number;
220
+ }
221
+
222
+ /**
223
+ * A plugin that enables request time metrics
224
+ */
225
+ export class RequestTimeWebPlugin {
226
+ getRequestTime: () => number | undefined;
227
+ name = 'RequestTimeWebPlugin';
228
+
229
+ constructor(getRequestTime: () => number | undefined) {
230
+ this.getRequestTime = getRequestTime;
231
+ }
232
+
233
+ apply(metricsCorePlugin: MetricsCorePlugin) {
234
+ metricsCorePlugin.hooks.resolveRequestTime.tap(this.name, () => {
235
+ return this.getRequestTime();
236
+ });
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A plugin that enables gathering of render metrics
242
+ */
243
+ export class MetricsCorePlugin implements PlayerPlugin {
244
+ name = 'metrics';
245
+
246
+ static Symbol = MetricsCorePluginSymbol;
247
+ public readonly symbol = MetricsCorePluginSymbol;
248
+
249
+ protected trackRender: boolean;
250
+ protected trackUpdate: boolean;
251
+ protected getTime: () => number;
252
+
253
+ public readonly hooks = {
254
+ resolveRequestTime: new SyncBailHook<number>(['requestTime']),
255
+
256
+ onFlowBegin: new SyncHook<PlayerFlowMetrics>(['update']),
257
+ onFlowEnd: new SyncHook<PlayerFlowMetrics>(['update']),
258
+
259
+ onInteractive: new SyncHook<Timing, PlayerFlowMetrics>([
260
+ 'timing',
261
+ 'update',
262
+ ]),
263
+
264
+ onNodeStart: new SyncHook<NodeMetrics | NodeRenderMetrics>([
265
+ 'nodeMetrics',
266
+ 'update',
267
+ ]),
268
+ onNodeEnd: new SyncHook<NodeMetrics | NodeRenderMetrics>([
269
+ 'nodeMetrics',
270
+ 'update',
271
+ ]),
272
+
273
+ onRenderStart: new SyncHook<Timing, NodeRenderMetrics, PlayerFlowMetrics>([
274
+ 'timing',
275
+ 'nodeMetrics',
276
+ 'update',
277
+ ]),
278
+ onRenderEnd: new SyncHook<Timing, NodeRenderMetrics, PlayerFlowMetrics>([
279
+ 'timing',
280
+ 'nodeMetrics',
281
+ 'update',
282
+ ]),
283
+
284
+ onUpdateStart: new SyncHook<Timing, NodeRenderMetrics, PlayerFlowMetrics>([
285
+ 'timing',
286
+ 'nodeMetrics',
287
+ 'update',
288
+ ]),
289
+ onUpdateEnd: new SyncHook<Timing, NodeRenderMetrics, PlayerFlowMetrics>([
290
+ 'timing',
291
+ 'nodeMetrics',
292
+ 'update',
293
+ ]),
294
+
295
+ onUpdate: new SyncHook<PlayerFlowMetrics>(['update']),
296
+ };
297
+
298
+ private metrics: PlayerFlowMetrics = {};
299
+
300
+ constructor(options?: MetricsWebPluginOptions) {
301
+ this.trackRender = options?.trackRenderTime ?? false;
302
+ this.trackUpdate = options?.trackUpdateTime ?? false;
303
+ this.getTime = options?.getTime ?? defaultGetTime;
304
+
305
+ /** fn to call the update hook */
306
+ const callOnUpdate = () => {
307
+ this.hooks.onUpdate.call(this.metrics);
308
+ };
309
+
310
+ this.hooks.onFlowBegin.tap(this.name, callOnUpdate);
311
+ this.hooks.onFlowEnd.tap(this.name, callOnUpdate);
312
+ this.hooks.onInteractive.tap(this.name, callOnUpdate);
313
+ this.hooks.onNodeStart.tap(this.name, callOnUpdate);
314
+ this.hooks.onNodeEnd.tap(this.name, callOnUpdate);
315
+
316
+ this.hooks.onRenderStart.tap(this.name, callOnUpdate);
317
+ this.hooks.onRenderEnd.tap(this.name, callOnUpdate);
318
+
319
+ this.hooks.onUpdateStart.tap(this.name, callOnUpdate);
320
+ this.hooks.onUpdateEnd.tap(this.name, callOnUpdate);
321
+
322
+ callbacks.forEach((hookName) => {
323
+ if (options?.[hookName] !== undefined) {
324
+ this.hooks[hookName].tap('options', options?.[hookName] as any);
325
+ }
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Fetch the metrics of the current flow
331
+ */
332
+ public getMetrics(): PlayerFlowMetrics {
333
+ return this.metrics;
334
+ }
335
+
336
+ /** Called when the UI layer wishes to start a timer for rendering */
337
+ private renderStart(): void {
338
+ // Grab the last update
339
+ const timeline = this.metrics.flow?.timeline;
340
+
341
+ if (!timeline || timeline.length === 0) {
342
+ return;
343
+ }
344
+
345
+ const lastItem = timeline[timeline.length - 1];
346
+
347
+ if ('updates' in lastItem) {
348
+ // Get the last update, make sure it's completed
349
+ if (lastItem.updates.length > 0) {
350
+ const lastUpdate = lastItem.updates[lastItem.updates.length - 1];
351
+
352
+ if (lastUpdate.completed === false) {
353
+ // Starting a new render before the last one was finished.
354
+ // Just ignore it and include as part of 1 render time
355
+ return;
356
+ }
357
+ }
358
+
359
+ if (!lastItem.render.completed) {
360
+ // Starting a new render before the last one was finished.
361
+ // Just ignore it and include as part of 1 render time
362
+ return;
363
+ }
364
+
365
+ const update: Timing = {
366
+ completed: false,
367
+ startTime: defaultGetTime(),
368
+ };
369
+
370
+ lastItem.updates.push(update);
371
+
372
+ this.hooks.onUpdateStart.call(update, lastItem, this.metrics);
373
+ } else {
374
+ const renderInfo = {
375
+ ...lastItem,
376
+ render: {
377
+ completed: false,
378
+ startTime: defaultGetTime(),
379
+ },
380
+ updates: [],
381
+ } as NodeRenderMetrics;
382
+
383
+ timeline[timeline.length - 1] = renderInfo;
384
+
385
+ this.hooks.onRenderStart.call(
386
+ renderInfo.render,
387
+ renderInfo,
388
+ this.metrics
389
+ );
390
+ }
391
+ }
392
+
393
+ /** Called when the UI layer wants to end the rendering timer */
394
+ public renderEnd(): void {
395
+ if (!this.trackRender) {
396
+ throw new Error(
397
+ 'Must start the metrics-plugin with render tracking enabled'
398
+ );
399
+ }
400
+
401
+ const { flow } = this.metrics;
402
+
403
+ if (!flow) {
404
+ return;
405
+ }
406
+
407
+ const { timeline, interactive } = flow;
408
+
409
+ if (!timeline || !interactive || timeline.length === 0) {
410
+ return;
411
+ }
412
+
413
+ const lastItem = timeline[timeline.length - 1];
414
+
415
+ if (!('render' in lastItem)) {
416
+ return;
417
+ }
418
+
419
+ // Check if this is an update or render
420
+ const endTime = defaultGetTime();
421
+
422
+ if (lastItem.render.completed) {
423
+ // This is the end of an existing update
424
+
425
+ if (lastItem.updates.length === 0) {
426
+ // throw new Error("Trying to end an update that's not in progress");
427
+ return;
428
+ }
429
+
430
+ const lastUpdate = lastItem.updates[lastItem.updates.length - 1];
431
+
432
+ if (lastUpdate.completed === true) {
433
+ // throw new Error("Trying to end an update that's not in progress");
434
+ return;
435
+ }
436
+
437
+ const update = {
438
+ ...lastUpdate,
439
+ completed: true,
440
+ endTime,
441
+ duration: endTime - lastUpdate.startTime,
442
+ };
443
+
444
+ lastItem.updates[lastItem.updates.length - 1] = update;
445
+ this.hooks.onUpdateEnd.call(update, lastItem, this.metrics);
446
+ } else {
447
+ lastItem.render = {
448
+ ...lastItem.render,
449
+ completed: true,
450
+ endTime,
451
+ duration: endTime - lastItem.startTime,
452
+ };
453
+ this.hooks.onRenderEnd.call(lastItem.render, lastItem, this.metrics);
454
+
455
+ if (!interactive.completed) {
456
+ flow.interactive = {
457
+ ...interactive,
458
+ completed: true,
459
+ duration: endTime - interactive.startTime,
460
+ endTime,
461
+ };
462
+
463
+ this.hooks.onInteractive.call(flow.interactive, this.metrics);
464
+ }
465
+ }
466
+ }
467
+
468
+ apply(player: Player): void {
469
+ player.hooks.onStart.tap(this.name, (flow) => {
470
+ const requestTime = this.hooks.resolveRequestTime.call();
471
+ const startTime = defaultGetTime();
472
+ this.metrics = {
473
+ flow: {
474
+ id: flow.id,
475
+ requestTime,
476
+ timeline: [],
477
+ startTime,
478
+ completed: false,
479
+ interactive: {
480
+ completed: false,
481
+ startTime,
482
+ },
483
+ },
484
+ };
485
+
486
+ this.hooks.onFlowBegin.call(this.metrics);
487
+ });
488
+
489
+ player.hooks.state.tap(this.name, (state) => {
490
+ if (state.status === 'completed' || state.status === 'error') {
491
+ const endTime = defaultGetTime();
492
+ const { flow } = this.metrics;
493
+
494
+ if (flow === undefined || flow?.completed === true) {
495
+ return;
496
+ }
497
+
498
+ this.metrics = {
499
+ flow: {
500
+ ...flow,
501
+ completed: true,
502
+ endTime,
503
+ duration: endTime - flow.startTime,
504
+ },
505
+ };
506
+
507
+ // get the last update
508
+
509
+ const lastUpdate = flow.timeline[flow.timeline.length - 1];
510
+
511
+ if (lastUpdate && !lastUpdate.completed) {
512
+ (this.metrics.flow as any).timeline[flow.timeline.length - 1] = {
513
+ ...lastUpdate,
514
+ completed: true,
515
+ endTime,
516
+ duration: endTime - lastUpdate.startTime,
517
+ };
518
+ }
519
+
520
+ this.hooks.onFlowEnd.call(this.metrics);
521
+ }
522
+ });
523
+
524
+ player.hooks.flowController.tap(this.name, (fc) => {
525
+ fc.hooks.flow.tap(this.name, (f) => {
526
+ f.hooks.transition.tap(this.name, (from, to) => {
527
+ const time = defaultGetTime();
528
+ const { flow } = this.metrics;
529
+
530
+ if (!flow) {
531
+ return;
532
+ }
533
+
534
+ const { timeline } = flow;
535
+
536
+ // End the last state, and start the next one
537
+
538
+ if (timeline.length > 0) {
539
+ const prev = timeline[timeline.length - 1];
540
+
541
+ if (prev.completed) {
542
+ throw new Error("Completing a state that's already done.");
543
+ }
544
+
545
+ timeline[timeline.length - 1] = {
546
+ ...prev,
547
+ completed: true,
548
+ endTime: time,
549
+ duration: time - prev.startTime,
550
+ };
551
+
552
+ this.hooks.onNodeEnd.call(timeline[timeline.length - 1]);
553
+ }
554
+
555
+ const nodeMetrics = {
556
+ completed: false,
557
+ startTime: time,
558
+ stateName: to.name,
559
+ stateType: to.value.state_type,
560
+ } as const;
561
+
562
+ timeline.push(nodeMetrics);
563
+ this.hooks.onNodeStart.call(nodeMetrics);
564
+ });
565
+ });
566
+ });
567
+
568
+ if (this.trackRender) {
569
+ player.hooks.view.tap(this.name, (v) => {
570
+ if (this.trackUpdate) {
571
+ v.hooks.onUpdate.tap(this.name, () => {
572
+ this.renderStart();
573
+ });
574
+ } else {
575
+ this.renderStart();
576
+ }
577
+ });
578
+
579
+ player.applyTo<BeaconPlugin>(BeaconPlugin.Symbol, (beaconPlugin) =>
580
+ new MetricsViewBeaconPlugin(this).apply(beaconPlugin)
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ export default MetricsCorePlugin;
package/src/symbols.ts ADDED
@@ -0,0 +1,4 @@
1
+ export const MetricsCorePluginSymbol = Symbol.for('MetricsCorePlugin');
2
+ export const MetricsViewBeaconPluginContextSymbol = Symbol.for(
3
+ 'MetricsViewBeaconPluginContext'
4
+ );