Skip to content
This repository was archived by the owner on Oct 3, 2023. It is now read-only.

Commit 0d5dbbc

Browse files
crdgonzalezcadraffensperger
authored andcommitted
Detect when an interaction is stable and measure total time to complete the interaction. (#89)
1 parent 09a7ace commit 0d5dbbc

File tree

4 files changed

+235
-28
lines changed

4 files changed

+235
-28
lines changed

examples/user_interaction/client/src/App.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,17 @@ class App extends React.Component {
2929
handleClick() {
3030
console.log("Entering handle click.");
3131

32-
this.callSleepApi();
32+
// Use promises to test behavior on MicroTasks.
33+
const promise = new Promise(resolve => {
34+
setTimeout(function () {
35+
resolve();
36+
}, 1000);
37+
});
38+
39+
promise.then(() => {
40+
console.log("Resolving promise");
41+
this.callSleepApi();
42+
});
3343
}
3444

3545
callSleepApi() {

packages/opencensus-web-instrumentation-zone/src/interaction-tracker.ts

Lines changed: 134 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,48 +15,48 @@
1515
*/
1616

1717
import { randomTraceId } from '@opencensus/web-core';
18+
import { AsyncTask } from './zone-types';
19+
import {
20+
OnPageInteractionStopwatch,
21+
startOnPageInteraction,
22+
} from './on-page-interaction';
1823

1924
// Allows us to monkey patch Zone prototype without TS compiler errors.
2025
declare const Zone: ZoneType & { prototype: Zone };
2126

22-
export interface AsyncTaskData {
23-
interactionId: string;
24-
pageView: string;
25-
}
26-
27-
export type AsyncTask = Task & {
28-
data: AsyncTaskData;
29-
eventName: string;
30-
target: HTMLElement;
31-
// Allows access to the private `_zone` property of a Zone.js Task.
32-
_zone: Zone;
33-
};
34-
3527
// Delay of 50 ms to reset currentEventTracingZone.
3628
const RESET_TRACING_ZONE_DELAY = 50;
3729

3830
export class InteractionTracker {
3931
// Allows to track several events triggered by the same user interaction in the right Zone.
4032
private currentEventTracingZone?: Zone;
4133

34+
// Map of interaction Ids to stopwatches.
35+
private readonly interactions: {
36+
[index: string]: OnPageInteractionStopwatch;
37+
} = {};
38+
39+
// Map of interaction Ids to timeout ids.
40+
private readonly interactionCompletionTimeouts: {
41+
[index: string]: number;
42+
} = {};
43+
4244
constructor() {
4345
const runTask = Zone.prototype.runTask;
4446
Zone.prototype.runTask = (
4547
task: AsyncTask,
4648
applyThis: unknown,
4749
applyArgs: unknown
4850
) => {
49-
const time = Date.now();
50-
5151
console.warn('Running task');
5252
console.log(task);
5353
console.log(task.zone);
5454

55+
const interceptingElement = getTrackedElement(task);
5556
let taskZone = Zone.current;
56-
if (isTrackedElement(task)) {
57+
if (interceptingElement) {
5758
console.log('Click detected');
58-
59-
if (this.currentEventTracingZone === undefined) {
59+
if (this.currentEventTracingZone === undefined) {
6060
const traceId = randomTraceId();
6161
this.currentEventTracingZone = Zone.root.fork({
6262
name: traceId,
@@ -66,6 +66,11 @@ export class InteractionTracker {
6666
},
6767
});
6868

69+
this.interactions[traceId] = startOnPageInteraction({
70+
id: traceId,
71+
eventType: task.eventName,
72+
target: task.target,
73+
});
6974
// Timeout to reset currentEventTracingZone to allow the creation of a new
7075
// zone for a new user interaction.
7176
Zone.root.run(() =>
@@ -82,6 +87,7 @@ export class InteractionTracker {
8287
// Change the zone task.
8388
task._zone = this.currentEventTracingZone;
8489
taskZone = this.currentEventTracingZone;
90+
this.incrementTaskCount(task.zone.get('traceId'));
8591
} else if (task.zone && task.zone.get('isTracingZone')) {
8692
// If we already are in a tracing zone, just run the task in our tracing zone.
8793
taskZone = task.zone;
@@ -90,43 +96,144 @@ export class InteractionTracker {
9096
return runTask.call(taskZone as {}, task, applyThis, applyArgs);
9197
} finally {
9298
console.log('Run task finished.');
93-
console.log('Time to complete: ' + (Date.now() - time));
99+
if (
100+
interceptingElement ||
101+
(shouldCountTask(task) && isTrackedTask(task))
102+
) {
103+
this.decrementTaskCount(task.zone.get('traceId'));
104+
}
94105
}
95106
};
96107

97108
const scheduleTask = Zone.prototype.scheduleTask;
98-
Zone.prototype.scheduleTask = function<T extends Task>(task: T): T {
109+
Zone.prototype.scheduleTask = <T extends Task>(task: T) => {
99110
console.warn('Scheduling task');
100111
console.log(task);
101112

102-
let taskZone: Zone = this;
103-
if (task.zone && task.zone && task.zone.get('isTracingZone')) {
113+
let taskZone = Zone.current;
114+
if (isTrackedTask(task)) {
104115
taskZone = task.zone;
105116
}
106117
try {
107118
return scheduleTask.call(taskZone as {}, task) as T;
108119
} finally {
120+
if (shouldCountTask(task) && isTrackedTask(task)) {
121+
this.incrementTaskCount(task.zone.get('traceId'));
122+
}
123+
console.warn('Finished Scheduling task');
109124
}
110125
};
111126

112127
const cancelTask = Zone.prototype.cancelTask;
113-
Zone.prototype.cancelTask = function(task: AsyncTask) {
128+
Zone.prototype.cancelTask = (task: AsyncTask) => {
114129
console.warn('Cancel task');
115130
console.log(task);
116131

117-
let taskZone: Zone = this;
118-
if (task.zone && task.zone.get('isTracingZone')) {
132+
let taskZone = Zone.current;
133+
if (isTrackedTask(task)) {
119134
taskZone = task.zone;
120135
}
121136

122137
try {
123138
return cancelTask.call(taskZone as {}, task);
124139
} finally {
140+
if (isTrackedTask(task) && shouldCountTask(task)) {
141+
this.decrementTaskCount(task.zone.get('traceId'));
142+
}
143+
console.warn('Finished cancel task');
125144
}
126145
};
127146
}
147+
148+
/** Increments the count of outstanding tasks for a given interaction id. */
149+
private incrementTaskCount(interactionId: string) {
150+
const stopWatch = this.getStopwatch(interactionId);
151+
if (!stopWatch) return;
152+
stopWatch.incrementTaskCount();
153+
154+
if (interactionId in this.interactionCompletionTimeouts) {
155+
// Clear the task that is supposed to complete the interaction as there are new
156+
// tasks incrementing the task cout. Sometimes the task count might be 0
157+
// but the interaction has more scheduled tasks.
158+
Zone.root.run(() => {
159+
clearTimeout(this.interactionCompletionTimeouts[interactionId]);
160+
delete this.interactionCompletionTimeouts[interactionId];
161+
});
162+
}
163+
}
164+
165+
/** Decrements the count of outstanding tasks for a given interaction id. */
166+
private decrementTaskCount(interactionId: string) {
167+
const stopWatch = this.getStopwatch(interactionId);
168+
if (!stopWatch) return;
169+
stopWatch.decrementTaskCount();
170+
171+
if (!stopWatch.hasRemainingTasks()) {
172+
this.maybeCompleteInteraction(interactionId);
173+
}
174+
}
175+
176+
private getStopwatch(
177+
interactionId: string
178+
): OnPageInteractionStopwatch | undefined {
179+
if (!(interactionId in this.interactions)) return;
180+
return this.interactions[interactionId];
181+
}
182+
183+
/**
184+
* Instead of declaring an interaction to be complete when the number of
185+
* active interactions reaches 0 we add a task to the queue that will actually
186+
* complete the interaction if none of the tasks scheduled ahead of it try
187+
* and increment the task counter for the given interaction id.
188+
*/
189+
private maybeCompleteInteraction(interactionId: string) {
190+
const stopWatch = this.getStopwatch(interactionId);
191+
if (!stopWatch) return;
192+
193+
if (this.interactionCompletionTimeouts[interactionId] !== undefined) return;
194+
195+
// Add a task to the queue that will actually complete the interaction in case
196+
// there are no more scheduled tasks ahead it.
197+
Zone.root.run(() => {
198+
this.interactionCompletionTimeouts[interactionId] = setTimeout(() => {
199+
this.completeInteraction(interactionId);
200+
delete this.interactionCompletionTimeouts[interactionId];
201+
});
202+
});
203+
}
204+
205+
private completeInteraction(interactionId: string) {
206+
const stopWatch = this.getStopwatch(interactionId);
207+
if (!stopWatch) return;
208+
stopWatch.stopAndRecord();
209+
delete this.interactions[interactionId];
210+
}
211+
}
212+
213+
function getTrackedElement(task: AsyncTask): HTMLElement | null {
214+
if (!(task.eventName && task.eventName === 'click')) return null;
215+
216+
return task.target as HTMLElement;
128217
}
129218

130-
function isTrackedElement(task: AsyncTask): boolean {
131-
return !!(task.eventName && task.eventName === 'click');
219+
/**
220+
* Whether or not a task is being tracked as part of an interaction.
221+
*/
222+
function isTrackedTask(task: Task): boolean {
223+
return !!(task.zone && task.zone.get('isTracingZone'));
224+
}
225+
226+
/**
227+
* Whether or not a task should be tracked as part of an interaction.
228+
*/
229+
function shouldCountTask(task: Task): boolean {
230+
if (!task.data) return false;
231+
232+
// Don't count periodic tasks with a delay greater than 1 s.
233+
if (task.data.isPeriodic && (task.data.delay && task.data.delay >= 1000)) {
234+
return false;
235+
}
236+
237+
// We're only interested in macroTasks and microTasks.
238+
return task.type === 'macroTask' || task.type === 'microTask';
132239
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Copyright 2019, OpenCensus Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { OnPageInteractionData } from './zone-types';
18+
19+
/** A helper class for tracking on page interactions. */
20+
export class OnPageInteractionStopwatch {
21+
private taskCount = 0;
22+
private readonly startTimeMs = performance.now();
23+
private endTimeMs?: number;
24+
25+
constructor(private readonly data: OnPageInteractionData) {}
26+
27+
incrementTaskCount() {
28+
this.taskCount++;
29+
}
30+
31+
decrementTaskCount() {
32+
if (this.taskCount > 0) this.taskCount--;
33+
}
34+
35+
hasRemainingTasks() {
36+
return this.taskCount > 0;
37+
}
38+
39+
getTaskCount() {
40+
return this.taskCount;
41+
}
42+
43+
/** Stops the stopwatch and record the xhr response. */
44+
stopAndRecord(): void {
45+
this.endTimeMs = performance.now();
46+
const latencyMs = this.endTimeMs - this.startTimeMs;
47+
console.log('End of tracking. The interaction is stable.');
48+
console.log('Time to stable: ' + latencyMs + ' ms.');
49+
console.log(this.data);
50+
}
51+
}
52+
53+
export function startOnPageInteraction(interaction: OnPageInteractionData) {
54+
return new OnPageInteractionStopwatch(interaction);
55+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Copyright 2019, OpenCensus Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface AsyncTaskData extends TaskData {
18+
interactionId: string;
19+
pageView: string;
20+
}
21+
22+
export type AsyncTask = Task & {
23+
data: AsyncTaskData;
24+
eventName: string;
25+
target: HTMLElement;
26+
// Allows access to the private `_zone` property of a Zone.js Task.
27+
_zone: Zone;
28+
};
29+
30+
/** Data used to create a new OnPageInteractionStopwatch. */
31+
export interface OnPageInteractionData {
32+
id: string;
33+
eventType: string;
34+
target: HTMLElement;
35+
}

0 commit comments

Comments
 (0)