Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
41 changes: 39 additions & 2 deletions src/startup.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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";
import { ISettingsProvider } from "@paperbits/common/configuration/ISettingsProvider";
import { FEATURE_CLIENT_TELEMETRY, FEATURE_FLAGS } from "./constants";

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

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

initFeatures();

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
}
};

function initFeatures() {
const logger = injector.resolve<Logger>("logger");
const settingsProvider = injector.resolve<ISettingsProvider>("settingsProvider");
checkIsFeatureEnabled(FEATURE_CLIENT_TELEMETRY, settingsProvider, logger)
.then((isEnabled) => {
logger.trackEvent("FeatureFlag", { feature: FEATURE_CLIENT_TELEMETRY, enabled: isEnabled.toString() });
let telemetryConfigurator = new TelemetryConfigurator(injector);
if (isEnabled) {
telemetryConfigurator.configure();
} else {
telemetryConfigurator.cleanUp();
}
});
}

async function checkIsFeatureEnabled(featureFlagName: string, settingsProvider: ISettingsProvider, logger: Logger): Promise<boolean> {
try {
const settingsObject = await settingsProvider.getSetting(FEATURE_FLAGS);

const featureFlags = new Map(Object.entries(settingsObject ?? {}));
if (!featureFlags || !featureFlags.has(featureFlagName)) {
return false;
}

return featureFlags.get(featureFlagName) == true;
} catch (error) {
logger?.trackEvent("FeatureFlag", { message: "Feature flag check failed", data: error.message });
return false;
}
};
}
67 changes: 67 additions & 0 deletions src/telemetry/serviceWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Bag } from "@paperbits/common/bag";
declare const clients: any;

const allowedList = ["state", "session_state"];

function sendMessageToClients(message: Bag<string>): void {
clients.matchAll().then((items: any[]) => {
if (items.length > 0) {
items.forEach(client => 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 cleanedUrl = request.url.indexOf("#code=") > -1 ? cleanUpUrlParams(request) : request.url;

const telemetryData = {
url: cleanedUrl,
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.");

function cleanUpUrlParams(request: Request): string {
const url = new URL(request.url);
const hash = url.hash.substring(1); // Remove the leading '#'
const params = new URLSearchParams(hash);

// Remove all parameters except those in the allowedList
for (const key of params.keys()) {
if (!allowedList.includes(key)) {
// Replace the 'code' parameter value
params.set(key, "xxxxxxxxxx");
}
}

url.hash = params.toString();
return url.toString();
}
169 changes: 169 additions & 0 deletions src/telemetry/telemetryConfigurator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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.
const userId = this.userId
const 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);
});
}
});
}

public cleanUp() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration) {
registration.unregister().then((boolean) => {
if (boolean) {
console.log('Service Worker unregistered successfully.');
} else {
console.log('Service Worker unregistering failed.');
}
}).catch((error) => {
console.error('Error unregistering Service Worker:', error);
});
} else {
console.log('No Service Worker to unregister.');
}
}).catch((error) => {
console.error('Error getting Service Worker registration:', error);
});
} else {
console.log('Service Worker not registered.');
}
}

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);
}
}
Loading
Loading