Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion src/apim.runtime.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ import { TagService } from "./services/tagService";
import { TenantService } from "./services/tenantService";
import { UsersService } from "./services/usersService";
import { TraceClick } from "./bindingHandlers/traceClick";
import { ClientLogger } from "./logging/clientLogger";

export class ApimRuntimeModule implements IInjectorModule {
public register(injector: IInjector): void {
injector.bindSingleton("logger", ConsoleLogger);
// injector.bindSingleton("logger", ConsoleLogger);
injector.bindSingleton("logger", ClientLogger);
injector.bindSingleton("traceClick", TraceClick);
injector.bindToCollection("autostart", UnhandledErrorHandler);
injector.bindToCollection("autostart", BalloonBindingHandler);
Expand Down
10 changes: 9 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,12 @@ export const smallMobileBreakpoint = 400;
/**
* Key of the default admin user
*/
export const integrationUserId = '/users/integration';
export const integrationUserId = '/users/integration';

/**
* Cookie name of the user session
* This is used to store the unique user in cookies and identify the user session in client telemetry.
*/
export const USER_SESSION = "userSessionId";
export const USER_ID = "userId";
export const USER_ACTION = "data-action";
11 changes: 11 additions & 0 deletions src/logging/clientLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ISettingsProvider } from "@paperbits/common/configuration";
import { ClientEvent } from "../models/logging/clientEvent";
import { v4 as uuidv4 } from "uuid";
import * as Constants from "../constants";
import { Utils } from "../utils";

export enum eventTypes {
error = "Error",
Expand Down Expand Up @@ -33,6 +34,7 @@ export class ClientLogger implements Logger {

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

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

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

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

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

devPortalEvent.eventType = viewName;
devPortalEvent.message = properties?.message;
Expand All @@ -70,6 +74,13 @@ export class ClientLogger implements Logger {
// Not implemented
}

private addUserDataToEventData(eventData?: Bag<string>) {
const userData = Utils.getUserData();
eventData = eventData || {};
eventData[Constants.USER_ID] = userData.userId;
eventData[Constants.USER_SESSION] = userData.sessionId;
}

private async traceEvent(clientEvent: ClientEvent) {
const datetime = new Date();

Expand Down
7 changes: 6 additions & 1 deletion src/startup.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { staticDataEnvironment } from "./../environmentConstants";
import { define } from "mime";
import { TraceClick } from "./bindingHandlers/traceClick";
import { Logger } from "@paperbits/common/logging";
import { TelemetryConfigurator } from "./telemetry/telemetryConfigurator";
import { Utils } from "./utils";

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

Expand All @@ -25,13 +27,16 @@ document.addEventListener("DOMContentLoaded", () => {
traceClick.setupBinding();
});

let telemetryConfigurator = new TelemetryConfigurator(injector);
telemetryConfigurator.configure();

window.onbeforeunload = () => {
if (!location.pathname.startsWith("/signin-sso") &&
!location.pathname.startsWith("/signup") &&
!location.pathname.startsWith("/signin")) {
const rest = location.href.split(location.pathname)[1];
const returnUrl = location.pathname + rest;
sessionStorage.setItem("returnUrl", returnUrl);
document.cookie = `returnUrl=${returnUrl}`; // for delegation
Utils.setCookie("returnUrl", returnUrl); // for delegation
}
};
47 changes: 47 additions & 0 deletions src/telemetry/serviceWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Bag } from "@paperbits/common/bag";
declare const clients: any;

function sendMessageToClients(message: Bag<string>): void {
clients.matchAll().then((items: any[]) => {
if (items.length > 0) {
const client = items[0];
client.postMessage(message);
}
});
}

addEventListener("fetch", (event: FetchEvent) => {
const request = event.request;

event.respondWith(
(async () => {
const response = await fetch(request);

if (request.url.endsWith("/trace")) {
return response;
}

const telemetryData = {
url: request.url,
method: request.method.toUpperCase(),
status: response.status.toString(),
responseHeaders: ""
};

const headers: { [key: string]: string } = {};
response.headers.forEach((value, key) => {
if (key.toLocaleLowerCase() === "authorization") {
return;
}
headers[key] = value;
});
telemetryData.responseHeaders = JSON.stringify(headers);

sendMessageToClients(telemetryData);

return response;
})()
);
});

console.log("Telemetry worker started.");
144 changes: 144 additions & 0 deletions src/telemetry/telemetryConfigurator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { IInjector } from "@paperbits/common/injection";
import { Logger } from "@paperbits/common/logging";
import { Utils } from "../utils";
import { USER_ACTION, USER_ID, USER_SESSION } from "../constants";

const TrackingEventElements = ["BUTTON", "A"];

export class TelemetryConfigurator {

constructor(private injector: IInjector) {
// required for user session init.
console.log("TelemetryConfigurator initialized with userId: " + this.userId + " and sessionId: " + this.sessionId);
}

public get userId(): string {
const uniqueUser = localStorage.getItem(USER_ID);
if(uniqueUser) {
return uniqueUser;
} else {
const newId = Utils.guid();
localStorage.setItem(USER_ID, newId);
return newId;
}
}

public get sessionId(): string {
const sessionId = sessionStorage.getItem(USER_SESSION);
if (sessionId) {
return sessionId;
} else {
const newId = Utils.guid();
sessionStorage.setItem(USER_SESSION, newId);
return newId;
}
}

public configure(): void {
const logger = this.injector.resolve<Logger>("logger");
// Register service worker for network telemetry.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/serviceWorker.js", { scope: "/" }).then(registration => {
console.log("Service Worker registered with scope:", registration.scope);
}).catch(error => {
console.error("Service Worker registration failed:", error);
logger.trackError(error);
});

// Listen for messages from the service worker
navigator.serviceWorker.addEventListener("message", (event) => {
console.log("Received message from Service Worker:", event.data);
if (event.data) {
logger.trackEvent("NetworkRequest", event.data);
} else {
console.error("No telemetry data received from Service Worker.");
}
});
}

// Init page load telemetry.
window.onload = () => {
if (logger) {
const observer = new PerformanceObserver((list: PerformanceObserverEntryList) => {
const timing = list.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
if (timing) {
const location = window.location;
const screenSize = {
width: window.innerWidth.toString(),
height: window.innerHeight.toString()
};
const pageLoadTime = timing.loadEventEnd - timing.loadEventStart;
const domRenderingTime = timing.domComplete - timing.domInteractive;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
const jsCssResources = resources.filter(resource => {
return resource.initiatorType === "script" || resource.initiatorType === "link";
});
const stats = {
pageLoadTime,
domRenderingTime,
jsCssResources: jsCssResources.map(resource => ({
name: resource.name,
duration: resource.duration
}))
};
logger.trackEvent("PageLoad", { host: location.host, pathName: location.pathname, total: timing.loadEventEnd.toString(), pageLoadStats: JSON.stringify(stats), ...screenSize });
}
});
observer.observe({ type: "navigation", buffered: true });
} else {
console.error("Logger is not available");
}
}

document.addEventListener("click", (event) => {
this.processUserInteraction(event).then(() => {
console.log("Click processed");
}).catch((error) => {
console.error("Error processing user interaction:", error);
});
});

document.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
this.processUserInteraction(event).then(() => {
console.log("Enter key processed");
}).catch((error) => {
console.error("Error processing user interaction:", error);
});
}
});
}

private async processUserInteraction(event: Event) {
const element = event.target as HTMLElement;
const elementTag = element?.tagName;
const parent = element?.parentElement;
const parentTag = parent?.tagName;
if (!(elementTag && TrackingEventElements.includes(elementTag)) && !(parentTag && TrackingEventElements.includes(parentTag))) {
return;
}

const eventAction = element.attributes.getNamedItem(USER_ACTION)?.value;
const eventMessage = {
elementId: element.id
};

const navigation = ((elementTag === "A" && element) || (parentTag === "A" && parent)) as HTMLAnchorElement;

if (navigation?.href) {
eventMessage["navigationTo"] = navigation.href;
eventMessage["navigationText"] = navigation.innerText;
}

if (!eventAction && !navigation) {
return;
}

if (eventAction) {
eventMessage["eventAction"] = eventAction;
}

const logger = this.injector.resolve<Logger>("logger");
await logger.trackEvent("UserEvent", eventMessage);
}
}
39 changes: 39 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ArmResource } from "./contracts/armResource";
import { JwtToken } from "./contracts/jwtToken";
import { js } from "js-beautify";
import { NameValuePair } from "./contracts/nameValuePair";
import { USER_ID, USER_SESSION } from "./constants";


export class Utils {
Expand Down Expand Up @@ -420,4 +421,42 @@ export class Utils {
public static isXmlContentType(contentType: string): boolean {
return /\bxml\b/i.test(contentType.toLocaleLowerCase());
}

public static getCookie(name: string): { name: string, value: string, expiresInDays?: number } | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const cookieValue = parts.pop().split(';').shift();
const cookieParts = document.cookie.split(';').map(cookie => cookie.trim());
const cookieString = cookieParts.find(cookie => cookie.startsWith(`${name}=`));
let expiresInDays;

if (cookieString) {
const expiresPart = cookieString.split(';').find(part => part.trim().startsWith('expires='));
if (expiresPart) {
const expiresDate = new Date(expiresPart.split('=')[1]);
const currentDate = new Date();
expiresInDays = Math.ceil((expiresDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24));
}
}

return { name, value: cookieValue, expiresInDays };
}
return null;
}

public static setCookie(name: string, value: string, days?: number): void {
let expires = "";
if (days) {
expires = `; expires=${new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()}`;
}
document.cookie = `${name}=${value}${expires}; path=/`;
}

public static getUserData(): { userId: string; sessionId: string; } {
return {
userId: Utils.getCookie(USER_ID)?.value,
sessionId: sessionStorage.getItem(USER_SESSION)
};
}
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"target": "es6",
"lib": [
"dom",
"es2019"
"es2019",
"webworker"
],
"module": "commonjs",
"moduleResolution": "node",
Expand Down
3 changes: 2 additions & 1 deletion webpack.runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const runtimeConfig = {
mode: "development",
target: "web",
entry: {
"scripts/theme": ["./src/startup.runtime.ts"]
"scripts/theme": ["./src/startup.runtime.ts"],
"serviceWorker": ["./src/telemetry/serviceWorker.ts"]
},
output: {
filename: "./[name].js",
Expand Down
Loading