Skip to content

Commit b4fab85

Browse files
committed
Added service worker, network traces and tracking simple page navigation
1 parent 6224c5e commit b4fab85

File tree

8 files changed

+259
-19
lines changed

8 files changed

+259
-19
lines changed

src/constants.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,12 @@ export const smallMobileBreakpoint = 400;
344344
/**
345345
* Key of the default admin user
346346
*/
347-
export const integrationUserId = '/users/integration';
347+
export const integrationUserId = '/users/integration';
348+
349+
/**
350+
* Cookie name of the user session
351+
* This is used to store the unique user in cookies and identify the user session in client telemetry.
352+
*/
353+
export const USER_SESSION = "userSessionId";
354+
export const USER_ID = "userId";
355+
export const USER_ACTION = "data-action";

src/logging/clientLogger.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ISettingsProvider } from "@paperbits/common/configuration";
55
import { ClientEvent } from "../models/logging/clientEvent";
66
import { v4 as uuidv4 } from "uuid";
77
import * as Constants from "../constants";
8+
import { Utils } from "../utils";
89

910
export enum eventTypes {
1011
error = "Error",
@@ -33,6 +34,7 @@ export class ClientLogger implements Logger {
3334

3435
public async trackEvent(eventName: string, properties?: Bag<string>): Promise<void> {
3536
const devPortalEvent = new ClientEvent();
37+
this.addUserDataToEventData(properties);
3638

3739
devPortalEvent.eventType = eventName;
3840
devPortalEvent.message = properties?.message;
@@ -43,6 +45,7 @@ export class ClientLogger implements Logger {
4345

4446
public async trackError(error: Error, properties?: Bag<string>): Promise<void> {
4547
const devPortalEvent = new ClientEvent();
48+
this.addUserDataToEventData(properties);
4649

4750
devPortalEvent.eventType = eventTypes.error;
4851
devPortalEvent.message = error?.message;
@@ -54,6 +57,7 @@ export class ClientLogger implements Logger {
5457

5558
public async trackView(viewName: string, properties?: Bag<string>): Promise<void> {
5659
const devPortalEvent = new ClientEvent();
60+
this.addUserDataToEventData(properties);
5761

5862
devPortalEvent.eventType = viewName;
5963
devPortalEvent.message = properties?.message;
@@ -70,6 +74,13 @@ export class ClientLogger implements Logger {
7074
// Not implemented
7175
}
7276

77+
private addUserDataToEventData(eventData?: Bag<string>) {
78+
const userData = Utils.getUserData();
79+
eventData = eventData || {};
80+
eventData[Constants.USER_ID] = userData.userId;
81+
eventData[Constants.USER_SESSION] = userData.sessionId;
82+
}
83+
7384
private async traceEvent(clientEvent: ClientEvent) {
7485
const datetime = new Date();
7586

src/startup.runtime.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { staticDataEnvironment } from "./../environmentConstants";
66
import { define } from "mime";
77
import { TraceClick } from "./bindingHandlers/traceClick";
88
import { Logger } from "@paperbits/common/logging";
9+
import { TelemetryConfigurator } from "./telemetry/telemetryConfigurator";
10+
import { Utils } from "./utils";
911

1012
define({ "application/x-zip-compressed": ["zip"] }, true);
1113

@@ -25,21 +27,8 @@ document.addEventListener("DOMContentLoaded", () => {
2527
traceClick.setupBinding();
2628
});
2729

28-
window.onload = () => {
29-
const logger = injector.resolve<Logger>("logger");
30-
if (logger) {
31-
const observer = new PerformanceObserver((list) => {
32-
const [timing] = list.getEntriesByType("navigation");
33-
if (timing) {
34-
const location = window.location;
35-
logger.trackEvent("PageLoadCounters", { host: location.host, pathName: location.pathname, total: timing["loadEventEnd"], pageLoadTiming: JSON.stringify(timing)});
36-
}
37-
});
38-
observer.observe({ type: "navigation", buffered: true });
39-
} else {
40-
console.error("Logger is not available");
41-
}
42-
}
30+
let telemetryConfigurator = new TelemetryConfigurator(injector);
31+
telemetryConfigurator.configure();
4332

4433
window.onbeforeunload = () => {
4534
if (!location.pathname.startsWith("/signin-sso") &&
@@ -48,6 +37,6 @@ window.onbeforeunload = () => {
4837
const rest = location.href.split(location.pathname)[1];
4938
const returnUrl = location.pathname + rest;
5039
sessionStorage.setItem("returnUrl", returnUrl);
51-
document.cookie = `returnUrl=${returnUrl}`; // for delegation
40+
Utils.setCookie("returnUrl", returnUrl); // for delegation
5241
}
5342
};

src/telemetry/serviceWorker.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Bag } from "@paperbits/common/bag";
2+
declare var clients: any;
3+
4+
function sendMessageToClients(message: Bag<string>): void {
5+
clients.matchAll().then((items: any[]) => {
6+
if (items.length > 0) {
7+
const client = items[0];
8+
client.postMessage(message);
9+
}
10+
});
11+
}
12+
13+
addEventListener("fetch", (event: FetchEvent) => {
14+
const request = event.request;
15+
16+
event.respondWith(
17+
(async () => {
18+
const response = await fetch(request);
19+
20+
if (request.url.endsWith("/trace")) {
21+
return response;
22+
}
23+
24+
const telemetryData = {
25+
url: request.url,
26+
method: request.method.toUpperCase(),
27+
status: response.status.toString(),
28+
responseHeaders: ""
29+
};
30+
31+
const headers: { [key: string]: string } = {};
32+
response.headers.forEach((value, key) => {
33+
if (key.toLocaleLowerCase() === "authorization") {
34+
return;
35+
}
36+
headers[key] = value;
37+
});
38+
telemetryData.responseHeaders = JSON.stringify(headers);
39+
40+
sendMessageToClients(telemetryData);
41+
42+
return response;
43+
})()
44+
);
45+
});
46+
47+
console.log("Telemetry worker started.");
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { IInjector } from "@paperbits/common/injection";
2+
import { Logger } from "@paperbits/common/logging";
3+
import { Utils } from "../utils";
4+
import { USER_ACTION, USER_ID, USER_SESSION } from "../constants";
5+
6+
const TrackingEventElements = ["BUTTON", "A"];
7+
8+
export class TelemetryConfigurator {
9+
10+
constructor(private injector: IInjector) {
11+
// required for user session init.
12+
console.log("TelemetryConfigurator initialized with userId: " + this.userId + " and sessionId: " + this.sessionId);
13+
}
14+
15+
public get userId(): string {
16+
const sessionCookie = Utils.getCookie(USER_ID);
17+
if(sessionCookie) {
18+
return sessionCookie.value;
19+
} else {
20+
const newId = Utils.guid();
21+
Utils.setCookie(USER_ID, newId, 400); // set cookie for 400 days - maximum allowed by the browser
22+
return newId;
23+
}
24+
}
25+
26+
public get sessionId(): string {
27+
const sessionId = sessionStorage.getItem(USER_SESSION);
28+
if (sessionId) {
29+
return sessionId;
30+
} else {
31+
const newId = Utils.guid();
32+
sessionStorage.setItem(USER_SESSION, newId);
33+
return newId;
34+
}
35+
}
36+
37+
public configure(): void {
38+
const logger = this.injector.resolve<Logger>("logger");
39+
// Register service worker for network telemetry.
40+
if ("serviceWorker" in navigator) {
41+
navigator.serviceWorker.register("/serviceWorker.js", { scope: "/" }).then(registration => {
42+
console.log("Service Worker registered with scope:", registration.scope);
43+
}).catch(error => {
44+
console.error("Service Worker registration failed:", error);
45+
logger.trackError(error);
46+
});
47+
48+
// Listen for messages from the service worker
49+
navigator.serviceWorker.addEventListener('message', (event) => {
50+
console.log('Received message from Service Worker:', event.data);
51+
if (event.data) {
52+
logger.trackEvent("NetworkRequest", event.data);
53+
} else {
54+
console.error("No telemetry data received from Service Worker.");
55+
}
56+
});
57+
}
58+
59+
// Init page load telemetry.
60+
window.onload = () => {
61+
if (logger) {
62+
const observer = new PerformanceObserver((list: PerformanceObserverEntryList) => {
63+
const timing = list.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
64+
if (timing) {
65+
const location = window.location;
66+
const screenSize = {
67+
width: window.innerWidth.toString(),
68+
height: window.innerHeight.toString()
69+
};
70+
const pageLoadTime = timing.loadEventEnd - timing.loadEventStart;
71+
const domRenderingTime = timing.domComplete - timing.domInteractive;
72+
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
73+
const jsCssResources = resources.filter(resource => {
74+
return resource.initiatorType === 'script' || resource.initiatorType === 'link';
75+
});
76+
const stats = {
77+
pageLoadTime,
78+
domRenderingTime,
79+
jsCssResources: jsCssResources.map(resource => ({
80+
name: resource.name,
81+
duration: resource.duration
82+
}))
83+
};
84+
logger.trackEvent("PageLoad", { host: location.host, pathName: location.pathname, total: timing.loadEventEnd.toString(), pageLoadStats: JSON.stringify(stats), ...screenSize });
85+
}
86+
});
87+
observer.observe({ type: "navigation", buffered: true });
88+
} else {
89+
console.error("Logger is not available");
90+
}
91+
}
92+
93+
document.addEventListener("click", (event) => {
94+
this.processUserInteraction(event).then(() => {
95+
console.log("Click processed");
96+
}).catch((error) => {
97+
console.error("Error processing user interaction:", error);
98+
});
99+
});
100+
101+
document.addEventListener("keydown", (event) => {
102+
if (event.key === "Enter") {
103+
this.processUserInteraction(event).then(() => {
104+
console.log("Enter key processed");
105+
}).catch((error) => {
106+
console.error("Error processing user interaction:", error);
107+
});
108+
}
109+
});
110+
}
111+
112+
private async processUserInteraction(event: Event) {
113+
const element = event.target as HTMLElement;
114+
const elementTag = element?.tagName;
115+
const parent = element?.parentElement;
116+
const parentTag = parent?.tagName;
117+
if (!(elementTag && TrackingEventElements.includes(elementTag)) && !(parentTag && TrackingEventElements.includes(parentTag))) {
118+
return;
119+
}
120+
121+
const eventAction = element.attributes.getNamedItem(USER_ACTION)?.value;
122+
const eventMessage = {
123+
elementId: element.id
124+
};
125+
126+
let navigation = ((elementTag === "A" && element) || (parentTag === "A" && parent)) as HTMLAnchorElement;
127+
128+
if (navigation && navigation.href) {
129+
eventMessage["navigationTo"] = navigation.href;
130+
eventMessage["navigationText"] = navigation.innerText;
131+
}
132+
133+
if (!eventAction && !navigation) {
134+
return;
135+
}
136+
137+
if (eventAction) {
138+
eventMessage["eventAction"] = eventAction;
139+
}
140+
141+
const logger = this.injector.resolve<Logger>("logger");
142+
await logger.trackEvent("UserEvent", eventMessage);
143+
}
144+
}

src/utils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ArmResource } from "./contracts/armResource";
33
import { JwtToken } from "./contracts/jwtToken";
44
import { js } from "js-beautify";
55
import { NameValuePair } from "./contracts/nameValuePair";
6+
import { USER_ID, USER_SESSION } from "./constants";
67

78

89
export class Utils {
@@ -420,4 +421,42 @@ export class Utils {
420421
public static isXmlContentType(contentType: string): boolean {
421422
return /\bxml\b/i.test(contentType.toLocaleLowerCase());
422423
}
424+
425+
public static getCookie(name: string): { name: string, value: string, expiresInDays?: number } | null {
426+
const value = `; ${document.cookie}`;
427+
const parts = value.split(`; ${name}=`);
428+
if (parts.length === 2) {
429+
const cookieValue = parts.pop().split(';').shift();
430+
const cookieParts = document.cookie.split(';').map(cookie => cookie.trim());
431+
const cookieString = cookieParts.find(cookie => cookie.startsWith(`${name}=`));
432+
let expiresInDays;
433+
434+
if (cookieString) {
435+
const expiresPart = cookieString.split(';').find(part => part.trim().startsWith('expires='));
436+
if (expiresPart) {
437+
const expiresDate = new Date(expiresPart.split('=')[1]);
438+
const currentDate = new Date();
439+
expiresInDays = Math.ceil((expiresDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24));
440+
}
441+
}
442+
443+
return { name, value: cookieValue, expiresInDays };
444+
}
445+
return null;
446+
}
447+
448+
public static setCookie(name: string, value: string, days?: number): void {
449+
let expires = "";
450+
if (days) {
451+
expires = `; expires=${new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()}`;
452+
}
453+
document.cookie = `${name}=${value}${expires}; path=/`;
454+
}
455+
456+
public static getUserData(): { userId: string; sessionId: string; } {
457+
return {
458+
userId: Utils.getCookie(USER_ID)?.value,
459+
sessionId: sessionStorage.getItem(USER_SESSION)
460+
};
461+
}
423462
}

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"target": "es6",
44
"lib": [
55
"dom",
6-
"es2019"
6+
"es2019",
7+
"webworker"
78
],
89
"module": "commonjs",
910
"moduleResolution": "node",

webpack.runtime.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const runtimeConfig = {
99
mode: "development",
1010
target: "web",
1111
entry: {
12-
"scripts/theme": ["./src/startup.runtime.ts"]
12+
"scripts/theme": ["./src/startup.runtime.ts"],
13+
"serviceWorker": ["./src/telemetry/serviceWorker.ts"]
1314
},
1415
output: {
1516
filename: "./[name].js",

0 commit comments

Comments
 (0)