972 lines
29 KiB
TypeScript
972 lines
29 KiB
TypeScript
/*
|
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
|
import { IContent } from "matrix-js-sdk/src/models/event";
|
|
import { sleep } from "matrix-js-sdk/src/utils";
|
|
|
|
import { getCurrentLanguage } from './languageHandler';
|
|
import PlatformPeg from './PlatformPeg';
|
|
import SdkConfig from './SdkConfig';
|
|
import { MatrixClientPeg } from "./MatrixClientPeg";
|
|
import RoomViewStore from "./stores/RoomViewStore";
|
|
import { Action } from "./dispatcher/actions";
|
|
|
|
const INACTIVITY_TIME = 20; // seconds
|
|
const HEARTBEAT_INTERVAL = 5_000; // ms
|
|
const SESSION_UPDATE_INTERVAL = 60; // seconds
|
|
const MAX_PENDING_EVENTS = 1000;
|
|
|
|
enum Orientation {
|
|
Landscape = "landscape",
|
|
Portrait = "portrait",
|
|
}
|
|
|
|
/* eslint-disable camelcase */
|
|
interface IMetrics {
|
|
_resolution?: string;
|
|
_app_version?: string;
|
|
_density?: number;
|
|
_ua?: string;
|
|
_locale?: string;
|
|
}
|
|
|
|
interface IEvent {
|
|
key: string;
|
|
count: number;
|
|
sum?: number;
|
|
dur?: number;
|
|
segmentation?: Record<string, unknown>;
|
|
timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp
|
|
hour?: unknown;
|
|
dow?: unknown;
|
|
}
|
|
|
|
interface IViewEvent extends IEvent {
|
|
key: "[CLY]_view";
|
|
}
|
|
|
|
interface IOrientationEvent extends IEvent {
|
|
key: "[CLY]_orientation";
|
|
segmentation: {
|
|
mode: Orientation;
|
|
};
|
|
}
|
|
|
|
interface IStarRatingEvent extends IEvent {
|
|
key: "[CLY]_star_rating";
|
|
segmentation: {
|
|
// we just care about collecting feedback, no need to associate with a feedback widget
|
|
widget_id?: string;
|
|
contactMe?: boolean;
|
|
email?: string;
|
|
rating: 1 | 2 | 3 | 4 | 5;
|
|
comment: string;
|
|
};
|
|
}
|
|
|
|
type Value = string | number | boolean;
|
|
|
|
interface IOperationInc {
|
|
"$inc": number;
|
|
}
|
|
interface IOperationMul {
|
|
"$mul": number;
|
|
}
|
|
interface IOperationMax {
|
|
"$max": number;
|
|
}
|
|
interface IOperationMin {
|
|
"$min": number;
|
|
}
|
|
interface IOperationSetOnce {
|
|
"$setOnce": Value;
|
|
}
|
|
interface IOperationPush {
|
|
"$push": Value | Value[];
|
|
}
|
|
interface IOperationAddToSet {
|
|
"$addToSet": Value | Value[];
|
|
}
|
|
interface IOperationPull {
|
|
"$pull": Value | Value[];
|
|
}
|
|
|
|
type Operation =
|
|
IOperationInc |
|
|
IOperationMul |
|
|
IOperationMax |
|
|
IOperationMin |
|
|
IOperationSetOnce |
|
|
IOperationPush |
|
|
IOperationAddToSet |
|
|
IOperationPull;
|
|
|
|
interface IUserDetails {
|
|
name?: string;
|
|
username?: string;
|
|
email?: string;
|
|
organization?: string;
|
|
phone?: string;
|
|
picture?: string;
|
|
gender?: string;
|
|
byear?: number;
|
|
custom?: Record<string, Value | Operation>; // `.` and `$` will be stripped out
|
|
}
|
|
|
|
interface ICrash {
|
|
_resolution?: string;
|
|
_app_version: string;
|
|
|
|
_ram_current?: number;
|
|
_ram_total?: number;
|
|
_disk_current?: number;
|
|
_disk_total?: number;
|
|
_orientation?: Orientation;
|
|
|
|
_online?: boolean;
|
|
_muted?: boolean;
|
|
_background?: boolean;
|
|
_view?: string;
|
|
|
|
_name?: string;
|
|
_error: string;
|
|
_nonfatal?: boolean;
|
|
_logs?: string;
|
|
_run?: number;
|
|
|
|
_custom?: Record<string, string>;
|
|
}
|
|
|
|
interface IParams {
|
|
// APP_KEY of an app for which to report
|
|
app_key: string;
|
|
// User identifier
|
|
device_id: string;
|
|
|
|
// Should provide value 1 to indicate session start
|
|
begin_session?: number;
|
|
// JSON object as string to provide metrics to track with the user
|
|
metrics?: string;
|
|
// Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds
|
|
session_duration?: number;
|
|
// Should provide value 1 to indicate session end
|
|
end_session?: number;
|
|
|
|
// 10 digit UTC timestamp for recording past data.
|
|
timestamp?: number;
|
|
// current user local hour (0 - 23)
|
|
hour?: number;
|
|
// day of the week (0-sunday, 1 - monday, ... 6 - saturday)
|
|
dow?: number;
|
|
|
|
// JSON array as string containing event objects
|
|
events?: string; // IEvent[]
|
|
// JSON object as string containing information about users
|
|
user_details?: string;
|
|
|
|
// provide when changing device ID, so server would merge the data
|
|
old_device_id?: string;
|
|
|
|
// See ICrash
|
|
crash?: string;
|
|
}
|
|
|
|
interface IRoomSegments extends Record<string, Value> {
|
|
room_id: string; // hashed
|
|
num_users: number;
|
|
is_encrypted: boolean;
|
|
is_public: boolean;
|
|
}
|
|
|
|
interface ISendMessageEvent extends IEvent {
|
|
key: "send_message";
|
|
dur: number; // how long it to send (until remote echo)
|
|
segmentation: IRoomSegments & {
|
|
is_edit: boolean;
|
|
is_reply: boolean;
|
|
msgtype: string;
|
|
format?: string;
|
|
};
|
|
}
|
|
|
|
interface IRoomDirectoryEvent extends IEvent {
|
|
key: "room_directory";
|
|
}
|
|
|
|
interface IRoomDirectoryDoneEvent extends IEvent {
|
|
key: "room_directory_done";
|
|
dur: number; // time spent in the room directory modal
|
|
}
|
|
|
|
interface IRoomDirectorySearchEvent extends IEvent {
|
|
key: "room_directory_search";
|
|
sum: number; // number of search results
|
|
segmentation: {
|
|
query_length: number;
|
|
query_num_words: number;
|
|
};
|
|
}
|
|
|
|
interface IStartCallEvent extends IEvent {
|
|
key: "start_call";
|
|
segmentation: IRoomSegments & {
|
|
is_video: boolean;
|
|
is_jitsi: boolean;
|
|
};
|
|
}
|
|
|
|
interface IJoinCallEvent extends IEvent {
|
|
key: "join_call";
|
|
segmentation: IRoomSegments & {
|
|
is_video: boolean;
|
|
is_jitsi: boolean;
|
|
};
|
|
}
|
|
|
|
interface IBeginInviteEvent extends IEvent {
|
|
key: "begin_invite";
|
|
segmentation: IRoomSegments;
|
|
}
|
|
|
|
interface ISendInviteEvent extends IEvent {
|
|
key: "send_invite";
|
|
sum: number; // quantity that was invited
|
|
segmentation: IRoomSegments;
|
|
}
|
|
|
|
interface ICreateRoomEvent extends IEvent {
|
|
key: "create_room";
|
|
dur: number; // how long it took to create (until remote echo)
|
|
segmentation: {
|
|
room_id: string; // hashed
|
|
num_users: number;
|
|
is_encrypted: boolean;
|
|
is_public: boolean;
|
|
};
|
|
}
|
|
|
|
interface IJoinRoomEvent extends IEvent {
|
|
key: Action.JoinRoom;
|
|
dur: number; // how long it took to join (until remote echo)
|
|
segmentation: {
|
|
room_id: string; // hashed
|
|
num_users: number;
|
|
is_encrypted: boolean;
|
|
is_public: boolean;
|
|
type: "room_directory" | "slash_command" | "link" | "invite";
|
|
};
|
|
}
|
|
/* eslint-enable camelcase */
|
|
|
|
const hashHex = async (input: string): Promise<string> => {
|
|
const buf = new TextEncoder().encode(input);
|
|
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
|
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
|
};
|
|
|
|
const knownScreens = new Set([
|
|
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
|
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
|
]);
|
|
|
|
interface IViewData {
|
|
name: string;
|
|
url: string;
|
|
meta: Record<string, string>;
|
|
}
|
|
|
|
// Apply fn to all hash path parts after the 1st one
|
|
async function getViewData(anonymous = true): Promise<IViewData> {
|
|
const rand = randomString(8);
|
|
const { origin, hash } = window.location;
|
|
let { pathname } = window.location;
|
|
|
|
// Redact paths which could contain unexpected PII
|
|
if (origin.startsWith('file://')) {
|
|
pathname = `/<redacted_${rand}>/`; // XXX: inject rand because Count.ly doesn't like X->X transitions
|
|
}
|
|
|
|
let [_, screen, ...parts] = hash.split("/");
|
|
|
|
if (!knownScreens.has(screen)) {
|
|
screen = `<redacted_${rand}>`;
|
|
}
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
parts[i] = anonymous ? `<redacted_${rand}>` : await hashHex(parts[i]);
|
|
}
|
|
|
|
const hashStr = `${_}/${screen}/${parts.join("/")}`;
|
|
const url = origin + pathname + hashStr;
|
|
|
|
const meta = {};
|
|
|
|
let name = "$/" + hash;
|
|
switch (screen) {
|
|
case "room": {
|
|
name = "view_room";
|
|
const roomId = RoomViewStore.getRoomId();
|
|
name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions
|
|
meta["room_id"] = parts[0];
|
|
Object.assign(meta, getRoomStats(roomId));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { name, url, meta };
|
|
}
|
|
|
|
const getRoomStats = (roomId: string) => {
|
|
const cli = MatrixClientPeg.get();
|
|
const room = cli?.getRoom(roomId);
|
|
|
|
return {
|
|
"num_users": room?.getJoinedMemberCount(),
|
|
"is_encrypted": cli?.isRoomEncrypted(roomId),
|
|
// eslint-disable-next-line camelcase
|
|
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
|
|
};
|
|
};
|
|
|
|
// async wrapper for regex-powered String.prototype.replace
|
|
const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise<string>) => {
|
|
const promises: Promise<string>[] = [];
|
|
// dry-run to calculate the replace values
|
|
str.replace(regex, (...args: string[]) => {
|
|
promises.push(fn(...args));
|
|
return "";
|
|
});
|
|
const values = await Promise.all(promises);
|
|
return str.replace(regex, () => values.shift());
|
|
};
|
|
|
|
export default class CountlyAnalytics {
|
|
private baseUrl: URL = null;
|
|
private appKey: string = null;
|
|
private userKey: string = null;
|
|
private anonymous: boolean;
|
|
private appPlatform: string;
|
|
private appVersion = "unknown";
|
|
|
|
private initTime = CountlyAnalytics.getTimestamp();
|
|
private firstPage = true;
|
|
private heartbeatIntervalId: number;
|
|
private activityIntervalId: number;
|
|
private trackTime = true;
|
|
private lastBeat: number;
|
|
private storedDuration = 0;
|
|
private lastView: string;
|
|
private lastViewTime = 0;
|
|
private lastViewStoredDuration = 0;
|
|
private sessionStarted = false;
|
|
private heartbeatEnabled = false;
|
|
private inactivityCounter = 0;
|
|
private pendingEvents: IEvent[] = [];
|
|
|
|
private static internalInstance = new CountlyAnalytics();
|
|
|
|
public static get instance(): CountlyAnalytics {
|
|
return CountlyAnalytics.internalInstance;
|
|
}
|
|
|
|
public get disabled() {
|
|
return !this.baseUrl;
|
|
}
|
|
|
|
public canEnable() {
|
|
const config = SdkConfig.get();
|
|
return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey);
|
|
}
|
|
|
|
private async changeUserKey(userKey: string, merge = false) {
|
|
const oldUserKey = this.userKey;
|
|
this.userKey = userKey;
|
|
if (oldUserKey && merge) {
|
|
await this.request({ old_device_id: oldUserKey });
|
|
}
|
|
}
|
|
|
|
public async enable(anonymous = true) {
|
|
if (!this.disabled && this.anonymous === anonymous) return;
|
|
if (!this.canEnable()) return;
|
|
|
|
if (!this.disabled) {
|
|
// flush request queue as our userKey is going to change, no need to await it
|
|
this.request();
|
|
}
|
|
|
|
const config = SdkConfig.get();
|
|
this.baseUrl = new URL("/i", config.countly.url);
|
|
this.appKey = config.countly.appKey;
|
|
|
|
this.anonymous = anonymous;
|
|
if (anonymous) {
|
|
await this.changeUserKey(randomString(64));
|
|
} else {
|
|
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
|
|
}
|
|
|
|
const platform = PlatformPeg.get();
|
|
this.appPlatform = platform.getHumanReadableName();
|
|
try {
|
|
this.appVersion = await platform.getAppVersion();
|
|
} catch (e) {
|
|
console.warn("Failed to get app version, using 'unknown'");
|
|
}
|
|
|
|
// start heartbeat
|
|
this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL);
|
|
this.trackSessions();
|
|
this.trackErrors();
|
|
}
|
|
|
|
public async disable() {
|
|
if (this.disabled) return;
|
|
await this.track("Opt-Out" );
|
|
this.endSession();
|
|
window.clearInterval(this.heartbeatIntervalId);
|
|
window.clearTimeout(this.activityIntervalId);
|
|
this.baseUrl = null;
|
|
// remove listeners bound in trackSessions()
|
|
window.removeEventListener("beforeunload", this.endSession);
|
|
window.removeEventListener("unload", this.endSession);
|
|
window.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
window.removeEventListener("mousemove", this.onUserActivity);
|
|
window.removeEventListener("click", this.onUserActivity);
|
|
window.removeEventListener("keydown", this.onUserActivity);
|
|
window.removeEventListener("scroll", this.onUserActivity);
|
|
}
|
|
|
|
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
|
|
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
|
|
}
|
|
|
|
public trackPageChange(generationTimeMs?: number) {
|
|
if (this.disabled) return;
|
|
// TODO use generationTimeMs
|
|
this.trackPageView();
|
|
}
|
|
|
|
private async trackPageView() {
|
|
this.reportViewDuration();
|
|
|
|
await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one
|
|
const viewData = await getViewData(this.anonymous);
|
|
|
|
const page = viewData.name;
|
|
this.lastView = page;
|
|
this.lastViewTime = CountlyAnalytics.getTimestamp();
|
|
const segments = {
|
|
...viewData.meta,
|
|
name: page,
|
|
visit: 1,
|
|
domain: window.location.hostname,
|
|
view: viewData.url,
|
|
segment: this.appPlatform,
|
|
start: this.firstPage,
|
|
};
|
|
|
|
if (this.firstPage) {
|
|
this.firstPage = false;
|
|
}
|
|
|
|
this.track<IViewEvent>("[CLY]_view", segments);
|
|
}
|
|
|
|
public static getTimestamp() {
|
|
return Math.floor(new Date().getTime() / 1000);
|
|
}
|
|
|
|
// store the last ms timestamp returned
|
|
// we do this to prevent the ts from ever decreasing in the case of system time changing
|
|
private lastMsTs = 0;
|
|
|
|
private getMsTimestamp() {
|
|
const ts = new Date().getTime();
|
|
if (this.lastMsTs >= ts) {
|
|
// increment ts as to keep our data points well-ordered
|
|
this.lastMsTs++;
|
|
} else {
|
|
this.lastMsTs = ts;
|
|
}
|
|
return this.lastMsTs;
|
|
}
|
|
|
|
public async recordError(err: Error | string, fatal = false) {
|
|
if (this.disabled || this.anonymous) return;
|
|
|
|
let error = "";
|
|
if (typeof err === "object") {
|
|
if (typeof err.stack !== "undefined") {
|
|
error = err.stack;
|
|
} else {
|
|
if (typeof err.name !== "undefined") {
|
|
error += err.name + ":";
|
|
}
|
|
if (typeof err.message !== "undefined") {
|
|
error += err.message + "\n";
|
|
}
|
|
if (typeof err.fileName !== "undefined") {
|
|
error += "in " + err.fileName + "\n";
|
|
}
|
|
if (typeof err.lineNumber !== "undefined") {
|
|
error += "on " + err.lineNumber;
|
|
}
|
|
if (typeof err.columnNumber !== "undefined") {
|
|
error += ":" + err.columnNumber;
|
|
}
|
|
}
|
|
} else {
|
|
error = err + "";
|
|
}
|
|
|
|
// sanitize the error from identifiers
|
|
error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => {
|
|
return glyph + await hashHex(substring.substring(1));
|
|
});
|
|
|
|
const metrics = this.getMetrics();
|
|
const ob: ICrash = {
|
|
_resolution: metrics?._resolution,
|
|
_error: error,
|
|
_app_version: this.appVersion,
|
|
_run: CountlyAnalytics.getTimestamp() - this.initTime,
|
|
_nonfatal: !fatal,
|
|
_view: this.lastView,
|
|
};
|
|
|
|
if (typeof navigator.onLine !== "undefined") {
|
|
ob._online = navigator.onLine;
|
|
}
|
|
|
|
ob._background = document.hasFocus();
|
|
|
|
this.request({ crash: JSON.stringify(ob) });
|
|
}
|
|
|
|
private trackErrors() {
|
|
//override global uncaught error handler
|
|
window.onerror = (msg, url, line, col, err) => {
|
|
if (typeof err !== "undefined") {
|
|
this.recordError(err, false);
|
|
} else {
|
|
let error = "";
|
|
if (typeof msg !== "undefined") {
|
|
error += msg + "\n";
|
|
}
|
|
if (typeof url !== "undefined") {
|
|
error += "at " + url;
|
|
}
|
|
if (typeof line !== "undefined") {
|
|
error += ":" + line;
|
|
}
|
|
if (typeof col !== "undefined") {
|
|
error += ":" + col;
|
|
}
|
|
error += "\n";
|
|
|
|
try {
|
|
const stack = [];
|
|
// eslint-disable-next-line no-caller
|
|
let f = arguments.callee.caller;
|
|
while (f) {
|
|
stack.push(f.name);
|
|
f = f.caller;
|
|
}
|
|
error += stack.join("\n");
|
|
} catch (ex) {
|
|
//silent error
|
|
}
|
|
this.recordError(error, false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true);
|
|
});
|
|
}
|
|
|
|
private heartbeat() {
|
|
const args: Pick<IParams, "session_duration"> = {};
|
|
|
|
// extend session if needed
|
|
if (this.sessionStarted && this.trackTime) {
|
|
const last = CountlyAnalytics.getTimestamp();
|
|
if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) {
|
|
args.session_duration = last - this.lastBeat;
|
|
this.lastBeat = last;
|
|
}
|
|
}
|
|
|
|
// process event queue
|
|
if (this.pendingEvents.length > 0 || args.session_duration) {
|
|
this.request(args);
|
|
}
|
|
}
|
|
|
|
private async request(
|
|
args: Omit<IParams, "app_key" | "device_id" | "timestamp" | "hour" | "dow">
|
|
& Partial<Pick<IParams, "device_id">> = {},
|
|
) {
|
|
const request: IParams = {
|
|
app_key: this.appKey,
|
|
device_id: this.userKey,
|
|
...this.getTimeParams(),
|
|
...args,
|
|
};
|
|
|
|
if (this.pendingEvents.length > 0) {
|
|
const EVENT_BATCH_SIZE = 10;
|
|
const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE);
|
|
request.events = JSON.stringify(events);
|
|
}
|
|
|
|
const params = new URLSearchParams(request as {});
|
|
|
|
try {
|
|
await window.fetch(this.baseUrl.toString(), {
|
|
method: "POST",
|
|
mode: "no-cors",
|
|
cache: "no-cache",
|
|
redirect: "follow",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: params,
|
|
});
|
|
} catch (e) {
|
|
console.error("Analytics error: ", e);
|
|
}
|
|
}
|
|
|
|
private getTimeParams(): Pick<IParams, "timestamp" | "hour" | "dow"> {
|
|
const date = new Date();
|
|
return {
|
|
timestamp: this.getMsTimestamp(),
|
|
hour: date.getHours(),
|
|
dow: date.getDay(),
|
|
};
|
|
}
|
|
|
|
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
|
|
const { count = 1, ...rest } = args;
|
|
const ev = {
|
|
...this.getTimeParams(),
|
|
...rest,
|
|
count,
|
|
platform: this.appPlatform,
|
|
app_version: this.appVersion,
|
|
};
|
|
|
|
this.pendingEvents.push(ev);
|
|
if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
|
|
this.pendingEvents.shift();
|
|
}
|
|
}
|
|
|
|
private getOrientation = (): Orientation => {
|
|
return window.matchMedia("(orientation: landscape)").matches
|
|
? Orientation.Landscape
|
|
: Orientation.Portrait;
|
|
};
|
|
|
|
private reportOrientation = () => {
|
|
this.track<IOrientationEvent>("[CLY]_orientation", {
|
|
mode: this.getOrientation(),
|
|
});
|
|
};
|
|
|
|
private startTime() {
|
|
if (!this.trackTime) {
|
|
this.trackTime = true;
|
|
this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration;
|
|
this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration;
|
|
this.lastViewStoredDuration = 0;
|
|
}
|
|
}
|
|
|
|
private stopTime() {
|
|
if (this.trackTime) {
|
|
this.trackTime = false;
|
|
this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat;
|
|
this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime;
|
|
}
|
|
}
|
|
|
|
private getMetrics(): IMetrics {
|
|
if (this.anonymous) return undefined;
|
|
const metrics: IMetrics = {};
|
|
|
|
// getting app version
|
|
metrics._app_version = this.appVersion;
|
|
metrics._ua = navigator.userAgent;
|
|
|
|
// getting resolution
|
|
if (screen.width && screen.height) {
|
|
metrics._resolution = `${screen.width}x${screen.height}`;
|
|
}
|
|
|
|
// getting density ratio
|
|
if (window.devicePixelRatio) {
|
|
metrics._density = window.devicePixelRatio;
|
|
}
|
|
|
|
// getting locale
|
|
metrics._locale = getCurrentLanguage();
|
|
|
|
return metrics;
|
|
}
|
|
|
|
private async beginSession(heartbeat = true) {
|
|
if (!this.sessionStarted) {
|
|
this.reportOrientation();
|
|
window.addEventListener("resize", this.reportOrientation);
|
|
|
|
this.lastBeat = CountlyAnalytics.getTimestamp();
|
|
this.sessionStarted = true;
|
|
this.heartbeatEnabled = heartbeat;
|
|
|
|
const userDetails: IUserDetails = {
|
|
custom: {
|
|
"home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash?
|
|
"anonymous": this.anonymous,
|
|
},
|
|
};
|
|
|
|
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
|
|
begin_session: 1,
|
|
user_details: JSON.stringify(userDetails),
|
|
};
|
|
|
|
const metrics = this.getMetrics();
|
|
if (metrics) {
|
|
request.metrics = JSON.stringify(metrics);
|
|
}
|
|
|
|
await this.request(request);
|
|
}
|
|
}
|
|
|
|
private reportViewDuration() {
|
|
if (this.lastView) {
|
|
this.track<IViewEvent>("[CLY]_view", {
|
|
name: this.lastView,
|
|
}, null, {
|
|
dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration,
|
|
});
|
|
this.lastView = null;
|
|
}
|
|
}
|
|
|
|
private endSession = () => {
|
|
if (this.sessionStarted) {
|
|
window.removeEventListener("resize", this.reportOrientation);
|
|
|
|
this.reportViewDuration();
|
|
this.request({
|
|
end_session: 1,
|
|
session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat,
|
|
});
|
|
}
|
|
this.sessionStarted = false;
|
|
};
|
|
|
|
private onVisibilityChange = () => {
|
|
if (document.hidden) {
|
|
this.stopTime();
|
|
} else {
|
|
this.startTime();
|
|
}
|
|
};
|
|
|
|
private onUserActivity = () => {
|
|
if (this.inactivityCounter >= INACTIVITY_TIME) {
|
|
this.startTime();
|
|
}
|
|
this.inactivityCounter = 0;
|
|
};
|
|
|
|
private trackSessions() {
|
|
this.beginSession();
|
|
this.startTime();
|
|
|
|
window.addEventListener("beforeunload", this.endSession);
|
|
window.addEventListener("unload", this.endSession);
|
|
window.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
window.addEventListener("mousemove", this.onUserActivity);
|
|
window.addEventListener("click", this.onUserActivity);
|
|
window.addEventListener("keydown", this.onUserActivity);
|
|
// Using the passive option to not block the main thread
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
|
window.addEventListener("scroll", this.onUserActivity, { passive: true });
|
|
|
|
this.activityIntervalId = setInterval(() => {
|
|
this.inactivityCounter++;
|
|
if (this.inactivityCounter >= INACTIVITY_TIME) {
|
|
this.stopTime();
|
|
}
|
|
}, 60_000);
|
|
}
|
|
|
|
public trackBeginInvite(roomId: string) {
|
|
this.track<IBeginInviteEvent>("begin_invite", {}, roomId);
|
|
}
|
|
|
|
public trackSendInvite(startTime: number, roomId: string, qty: number) {
|
|
this.track<ISendInviteEvent>("send_invite", {}, roomId, {
|
|
dur: CountlyAnalytics.getTimestamp() - startTime,
|
|
sum: qty,
|
|
});
|
|
}
|
|
|
|
public async trackRoomCreate(startTime: number, roomId: string) {
|
|
if (this.disabled) return;
|
|
|
|
let endTime = CountlyAnalytics.getTimestamp();
|
|
const cli = MatrixClientPeg.get();
|
|
if (!cli.getRoom(roomId)) {
|
|
await new Promise<void>(resolve => {
|
|
const handler = (room) => {
|
|
if (room.roomId === roomId) {
|
|
cli.off("Room", handler);
|
|
resolve();
|
|
}
|
|
};
|
|
cli.on("Room", handler);
|
|
});
|
|
endTime = CountlyAnalytics.getTimestamp();
|
|
}
|
|
|
|
this.track<ICreateRoomEvent>("create_room", {}, roomId, {
|
|
dur: endTime - startTime,
|
|
});
|
|
}
|
|
|
|
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
|
|
this.track<IJoinRoomEvent>(Action.JoinRoom, { type }, roomId, {
|
|
dur: CountlyAnalytics.getTimestamp() - startTime,
|
|
});
|
|
}
|
|
|
|
public async trackSendMessage(
|
|
startTime: number,
|
|
// eslint-disable-next-line camelcase
|
|
sendPromise: Promise<{event_id: string}>,
|
|
roomId: string,
|
|
isEdit: boolean,
|
|
isReply: boolean,
|
|
content: IContent,
|
|
) {
|
|
if (this.disabled) return;
|
|
const cli = MatrixClientPeg.get();
|
|
const room = cli.getRoom(roomId);
|
|
|
|
const eventId = (await sendPromise).event_id;
|
|
let endTime = CountlyAnalytics.getTimestamp();
|
|
|
|
if (!room.findEventById(eventId)) {
|
|
await new Promise<void>(resolve => {
|
|
const handler = (ev) => {
|
|
if (ev.getId() === eventId) {
|
|
room.off("Room.localEchoUpdated", handler);
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
room.on("Room.localEchoUpdated", handler);
|
|
});
|
|
endTime = CountlyAnalytics.getTimestamp();
|
|
}
|
|
|
|
this.track<ISendMessageEvent>("send_message", {
|
|
is_edit: isEdit,
|
|
is_reply: isReply,
|
|
msgtype: content.msgtype,
|
|
format: content.format,
|
|
}, roomId, {
|
|
dur: endTime - startTime,
|
|
});
|
|
}
|
|
|
|
public trackStartCall(roomId: string, isVideo = false, isJitsi = false) {
|
|
this.track<IStartCallEvent>("start_call", {
|
|
is_video: isVideo,
|
|
is_jitsi: isJitsi,
|
|
}, roomId);
|
|
}
|
|
|
|
public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) {
|
|
this.track<IJoinCallEvent>("join_call", {
|
|
is_video: isVideo,
|
|
is_jitsi: isJitsi,
|
|
}, roomId);
|
|
}
|
|
|
|
public trackRoomDirectoryBegin() {
|
|
this.track<IRoomDirectoryEvent>("room_directory");
|
|
}
|
|
|
|
public trackRoomDirectory(startTime: number) {
|
|
this.track<IRoomDirectoryDoneEvent>("room_directory_done", {}, null, {
|
|
dur: CountlyAnalytics.getTimestamp() - startTime,
|
|
});
|
|
}
|
|
|
|
public trackRoomDirectorySearch(numResults: number, query: string) {
|
|
this.track<IRoomDirectorySearchEvent>("room_directory_search", {
|
|
query_length: query.length,
|
|
query_num_words: query.split(" ").length,
|
|
}, null, {
|
|
sum: numResults,
|
|
});
|
|
}
|
|
|
|
public async track<E extends IEvent>(
|
|
key: E["key"],
|
|
segments?: Omit<E["segmentation"], "room_id" | "num_users" | "is_encrypted" | "is_public">,
|
|
roomId?: string,
|
|
args?: Partial<Pick<E, "dur" | "sum" | "timestamp">>,
|
|
anonymous = false,
|
|
) {
|
|
if (this.disabled && !anonymous) return;
|
|
|
|
let segmentation = segments || {};
|
|
|
|
if (roomId) {
|
|
segmentation = {
|
|
room_id: await hashHex(roomId),
|
|
...getRoomStats(roomId),
|
|
...segments,
|
|
};
|
|
}
|
|
|
|
this.queue({
|
|
key,
|
|
count: 1,
|
|
segmentation,
|
|
...args,
|
|
});
|
|
|
|
// if this event can be sent anonymously and we are disabled then dispatch it right away
|
|
if (this.disabled && anonymous) {
|
|
await this.request({ device_id: randomString(64) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// expose on window for easy access from the console
|
|
window.mxCountlyAnalytics = CountlyAnalytics;
|