diff --git a/app/client/public/index.html b/app/client/public/index.html
index 356c1c9019..2ca3be33cb 100755
--- a/app/client/public/index.html
+++ b/app/client/public/index.html
@@ -46,7 +46,6 @@
}
-
diff --git a/app/client/public/logger.js b/app/client/public/logger.js
deleted file mode 100644
index 13b24c245b..0000000000
--- a/app/client/public/logger.js
+++ /dev/null
@@ -1,93 +0,0 @@
-// Gets the current Unix timestamp
-function getCurrentUTCTimestamp(date) {
- return Math.floor((date || new Date()).getTime() / 1000);
-}
-
-// Gets the unix timestamp of the hour
-// For a current time of 19:15, returns the timestamp of 19:00
-function getCurrentUTCHourTimestamp() {
- const date = new Date();
- date.setUTCMinutes(0);
- date.setUTCSeconds(0);
- date.setUTCMilliseconds(0);
- return getCurrentUTCTimestamp(date);
-}
-
-const PULSE_API_ENDPOINT = "/api/v1/usage-pulse";
-
-/**
- * Sends HTTP pulse to the server, when beaconAPI is not available.
- * Fire and forget.
- */
-function sendHTTPPulse() {
- fetch(PULSE_API_ENDPOINT, {
- method: "POST",
- credentials: "same-origin",
- })
- .then(() => {
- // Fire and forget
- })
- .catch(() => {
- // Ignore errors; fire and forget
- });
-}
-
-/**
- * Sends a usage-pulse to the server using the Beacon API.
- * If the Beacon API is not available, falls back to a standard fetch.
- * Note: Only sends pulse when user is on "/app/" pages: editor and viewer.
- */
-function sendPulse() {
- if (window.location.href.includes("/app/")) {
- navigator.sendBeacon(PULSE_API_ENDPOINT, "") || sendHTTPPulse();
- }
-}
-
-// Checks if the it is time to send another pulse
-function shouldSendPulse() {
- const timestamp = getCurrentUTCTimestamp();
- return NEXT_LOGGING_HOUR < timestamp;
-}
-
-function addActivityListener() {
- window.document.body.addEventListener("pointerdown", punchIn);
-}
-function removeActivityListener() {
- window.document.body.removeEventListener("pointerdown", punchIn);
-}
-
-// Removes event listeners and adds them just in time for the next pulse
-function scheduleNextPunchIn() {
- const timestamp = getCurrentUTCTimestamp();
- const startListentingIn = NEXT_LOGGING_HOUR - timestamp - 2;
-
- // If we don't have much time until TTL expires;
- // Don't bother removing listener
- if (startListentingIn <= 10) return;
-
- // Remove all listeners for now.
- removeActivityListener();
-
- // Add listeners 2 seconds before the next hour begins
- setTimeout(addActivityListener, startListentingIn * 1000);
-}
-
-LAST_LOGGED_HOUR = 0; // The last time we logged
-NEXT_LOGGING_HOUR = 0; // The next time we should log
-
-function punchIn() {
- if (!LAST_LOGGED_HOUR) {
- // When this is the first time we're logging
- LAST_LOGGED_HOUR = getCurrentUTCHourTimestamp();
- NEXT_LOGGING_HOUR = LAST_LOGGED_HOUR + 3600;
- } else {
- // Make sure it is time to send the pulse again
- if (!shouldSendPulse) return;
- LAST_LOGGED_HOUR = NEXT_LOGGING_HOUR;
- NEXT_LOGGING_HOUR = LAST_LOGGED_HOUR + 3600;
- }
- sendPulse();
- scheduleNextPunchIn();
-}
-
-window.addEventListener("DOMContentLoaded", punchIn);
diff --git a/app/client/src/RouteBuilder.ts b/app/client/src/RouteBuilder.ts
index 4685dac00f..ca219dd6b3 100644
--- a/app/client/src/RouteBuilder.ts
+++ b/app/client/src/RouteBuilder.ts
@@ -2,6 +2,8 @@ import {
ADMIN_SETTINGS_PATH,
GEN_TEMPLATE_FORM_ROUTE,
GEN_TEMPLATE_URL,
+ getViewerCustomPath,
+ getViewerPath,
TEMPLATES_PATH,
} from "constants/routes";
import { APP_MODE } from "entities/App";
@@ -26,8 +28,9 @@ export const fillPathname = (
page: Page,
) => {
const replaceValue = page.customSlug
- ? `/app/${page.customSlug}-${page.pageId}`
- : `/app/${application.slug}/${page.slug}-${page.pageId}`;
+ ? getViewerCustomPath(page.customSlug, page.pageId)
+ : getViewerPath(application.slug, page.slug, page.pageId);
+
return pathname.replace(
`/applications/${application.id}/pages/${page.pageId}`,
replaceValue,
diff --git a/app/client/src/ce/sagas/userSagas.tsx b/app/client/src/ce/sagas/userSagas.tsx
index 419e2fe36d..ed27e49c58 100644
--- a/app/client/src/ce/sagas/userSagas.tsx
+++ b/app/client/src/ce/sagas/userSagas.tsx
@@ -55,7 +55,10 @@ import {
getFirstTimeUserOnboardingApplicationId,
getFirstTimeUserOnboardingIntroModalVisibility,
} from "utils/storage";
-import { initializeAnalyticsAndTrackers } from "utils/AppsmithUtils";
+import {
+ initializeAnalyticsAndTrackers,
+ initializeSegmentWithoutTracking,
+} from "utils/AppsmithUtils";
import { getAppsmithConfigs } from "ce/configs";
import { getSegmentState } from "selectors/analyticsSelectors";
import {
@@ -64,6 +67,7 @@ import {
} from "actions/analyticsActions";
import { SegmentState } from "reducers/uiReducers/analyticsReducer";
import FeatureFlags from "entities/FeatureFlags";
+import UsagePulse from "usagePulse";
export function* createUserSaga(
action: ReduxActionWithPromise,
@@ -134,19 +138,35 @@ export function* getCurrentUserSaga() {
if (isValidResponse) {
//@ts-expect-error: response is of type unknown
const { enableTelemetry } = response.data;
+
if (enableTelemetry) {
const promise = initializeAnalyticsAndTrackers();
+
if (promise instanceof Promise) {
const result: boolean = yield promise;
+
if (result) {
yield put(segmentInitSuccess());
} else {
yield put(segmentInitUncertain());
}
}
+ } else if (
+ //@ts-expect-error: response is of type unknown
+ response.data.isAnonymous &&
+ //@ts-expect-error: response is of type unknown
+ response.data.username === ANONYMOUS_USERNAME
+ ) {
+ /*
+ * We're initializing the segment api regardless of the enableTelemetry flag
+ * So we can use segement Id to fingerprint anonymous user in usage pulse call
+ */
+ yield initializeSegmentWithoutTracking();
}
- yield put(initAppLevelSocketConnection());
- yield put(initPageLevelSocketConnection());
+
+ //To make sure that we're not tracking from previous session.
+ UsagePulse.stopTrackingActivity();
+
if (
//@ts-expect-error: response is of type unknown
!response.data.isAnonymous &&
@@ -155,15 +175,28 @@ export function* getCurrentUserSaga() {
) {
//@ts-expect-error: response is of type unknown
enableTelemetry && AnalyticsUtil.identifyUser(response.data);
+ } else {
+ UsagePulse.userAnonymousId = AnalyticsUtil.getAnonymousId();
+
+ if (!enableTelemetry) {
+ AnalyticsUtil.removeAnalytics();
+ }
}
+
+ UsagePulse.startTrackingActivity();
+
+ yield put(initAppLevelSocketConnection());
+ yield put(initPageLevelSocketConnection());
yield put({
type: ReduxActionTypes.FETCH_USER_DETAILS_SUCCESS,
payload: response.data,
});
+
//@ts-expect-error: response is of type unknown
if (response.data.emptyInstance) {
history.replace(SETUP);
}
+
PerformanceTracker.stopAsyncTracking(
PerformanceTransactionName.USER_ME_API,
);
@@ -421,6 +454,7 @@ export function* logoutSaga(action: ReduxAction<{ redirectURL: string }>) {
const response: ApiResponse = yield call(UserApi.logoutUser);
const isValidResponse: boolean = yield validateResponse(response);
if (isValidResponse) {
+ UsagePulse.stopTrackingActivity();
AnalyticsUtil.reset();
const currentUser: User | undefined = yield select(getCurrentUser);
yield put(logoutUserSuccess(!!currentUser?.emptyInstance));
diff --git a/app/client/src/constants/routes/appRoutes.ts b/app/client/src/constants/routes/appRoutes.ts
index 8c48b19ffd..9a348e0859 100644
--- a/app/client/src/constants/routes/appRoutes.ts
+++ b/app/client/src/constants/routes/appRoutes.ts
@@ -3,12 +3,22 @@
// All solutions from closed issues on their repo have been tried. Ref: https://github.com/pillarjs/path-to-regexp/issues/193
const { match } = require("path-to-regexp");
+export const BUILDER_VIEWER_PATH_PREFIX = "/app/";
+export const BUILDER_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:applicationSlug/:pageSlug(.*\-):pageId/edit`;
+export const BUILDER_CUSTOM_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:customSlug(.*\-):pageId/edit`;
+export const VIEWER_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:applicationSlug/:pageSlug(.*\-):pageId`;
+export const VIEWER_CUSTOM_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:customSlug(.*\-):pageId`;
+export const getViewerPath = (
+ applicationSlug: string,
+ pageSlug: string,
+ pageId: string,
+) => `${BUILDER_VIEWER_PATH_PREFIX}${applicationSlug}/${pageSlug}-${pageId}`;
+export const getViewerCustomPath = (customSlug: string, pageId: string) =>
+ `${BUILDER_VIEWER_PATH_PREFIX}${customSlug}-${pageId}`;
export const BUILDER_PATH_DEPRECATED = `/applications/:applicationId/pages/:pageId/edit`;
-export const BUILDER_PATH = `/app/:applicationSlug/:pageSlug(.*\-):pageId/edit`;
-export const VIEWER_PATH = `/app/:applicationSlug/:pageSlug(.*\-):pageId`;
-export const BUILDER_CUSTOM_PATH = `/app/:customSlug(.*\-):pageId/edit`;
-export const VIEWER_CUSTOM_PATH = `/app/:customSlug(.*\-):pageId`;
export const VIEWER_PATH_DEPRECATED = `/applications/:applicationId/pages/:pageId`;
+export const VIEWER_PATH_DEPRECATED_REGEX = /\/applications\/[^/]+\/pages\/[^/]+/;
+
export const VIEWER_FORK_PATH = `/fork`;
export const INTEGRATION_EDITOR_PATH = `/datasources/:selectedTab`;
export const API_EDITOR_BASE_PATH = `/api`;
diff --git a/app/client/src/index.tsx b/app/client/src/index.tsx
index 1b7838cd2a..3b555f9186 100755
--- a/app/client/src/index.tsx
+++ b/app/client/src/index.tsx
@@ -3,6 +3,7 @@ import "./wdyr";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import "./index.css";
+import "./usagePulse";
import { ThemeProvider } from "constants/DefaultTheme";
import { appInitializer } from "utils/AppUtils";
import { Slide } from "react-toastify";
diff --git a/app/client/src/usagePulse/index.ts b/app/client/src/usagePulse/index.ts
new file mode 100644
index 0000000000..1ec4f3f330
--- /dev/null
+++ b/app/client/src/usagePulse/index.ts
@@ -0,0 +1,118 @@
+import {
+ BUILDER_VIEWER_PATH_PREFIX,
+ VIEWER_PATH_DEPRECATED_REGEX,
+} from "constants/routes";
+import { noop } from "lodash";
+import history from "utils/history";
+
+const PULSE_API_ENDPOINT = "/api/v1/usage-pulse";
+const PULSE_INTERVAL = 3600; /* 1 hour in seconds */
+const USER_ACTIVITY_LISTENER_EVENTS = ["pointerdown", "keydown"];
+class UsagePulse {
+ static userAnonymousId: string | undefined;
+ static Timer: number;
+ static unlistenRouteChange: () => void;
+
+ /*
+ * Function to check if the given URL is trakable or not.
+ * app builder and viewer urls are trackable
+ */
+ static isTrackableUrl(url: string) {
+ return (
+ url.includes(BUILDER_VIEWER_PATH_PREFIX) ||
+ VIEWER_PATH_DEPRECATED_REGEX.test(url)
+ );
+ }
+
+ static sendPulse() {
+ const data: Record = {
+ viewMode: !window.location.href.endsWith("/edit"),
+ };
+
+ if (UsagePulse.userAnonymousId) {
+ data["anonymousUserId"] = UsagePulse.userAnonymousId;
+ }
+
+ fetch(PULSE_API_ENDPOINT, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ keepalive: true,
+ }).catch(noop);
+ }
+
+ static registerActivityListener() {
+ USER_ACTIVITY_LISTENER_EVENTS.forEach((event) => {
+ window.document.body.addEventListener(
+ event,
+ UsagePulse.startTrackingActivity,
+ );
+ });
+ }
+
+ static deregisterActivityListener() {
+ USER_ACTIVITY_LISTENER_EVENTS.forEach((event) => {
+ window.document.body.removeEventListener(
+ event,
+ UsagePulse.startTrackingActivity,
+ );
+ });
+ }
+
+ /*
+ * Function to register a history change event and trigger
+ * a callback and unlisten when the user goes to a trackable URL
+ */
+ static watchForTrackableUrl(callback: () => void) {
+ UsagePulse.unlistenRouteChange = history.listen(() => {
+ if (UsagePulse.isTrackableUrl(window.location.href)) {
+ UsagePulse.unlistenRouteChange();
+ setTimeout(callback, 0);
+ }
+ });
+
+ UsagePulse.deregisterActivityListener();
+ }
+
+ /*
+ * Function that suspends active tracking listeners
+ * and schedules when next listeners should be registered.
+ */
+ static scheduleNextActivityListeners() {
+ UsagePulse.deregisterActivityListener();
+
+ UsagePulse.Timer = setTimeout(
+ UsagePulse.registerActivityListener,
+ PULSE_INTERVAL * 1000,
+ );
+ }
+
+ /*
+ * Point of entry for the user tracking
+ * triggers a pulse and schedules the pulse , if user is on a trackable url, otherwise
+ * registers listeners to wait for the user to go to a trackable url
+ */
+ static startTrackingActivity() {
+ if (UsagePulse.isTrackableUrl(window.location.href)) {
+ UsagePulse.sendPulse();
+ UsagePulse.scheduleNextActivityListeners();
+ } else {
+ UsagePulse.watchForTrackableUrl(UsagePulse.startTrackingActivity);
+ }
+ }
+
+ /*
+ * Function to cleanup states and listeners
+ */
+ static stopTrackingActivity() {
+ UsagePulse.userAnonymousId = undefined;
+ clearTimeout(UsagePulse.Timer);
+ UsagePulse.unlistenRouteChange && UsagePulse.unlistenRouteChange();
+ UsagePulse.deregisterActivityListener();
+ }
+}
+
+export default UsagePulse;
diff --git a/app/client/src/usagePulse/usagePulse.test.ts b/app/client/src/usagePulse/usagePulse.test.ts
new file mode 100644
index 0000000000..1f2b4aad74
--- /dev/null
+++ b/app/client/src/usagePulse/usagePulse.test.ts
@@ -0,0 +1,32 @@
+import UsagePulse from "usagePulse";
+
+describe("Usage pulse", () => {
+ describe("isTrackableUrl", () => {
+ it("should return true when called with trackable URL", () => {
+ // All application URLS are trackable.
+
+ [
+ "https://dev.appsmith.com/app/test/mypage-123123/edit",
+ "https://dev.appsmith.com/app/test/mypage-123123",
+ "https://dev.appsmith.com/app/test-123123/edit",
+ "https://dev.appsmith.com/app/test-123123",
+ "https://dev.appsmith.com/applications/123123test/pages/123123test/edit",
+ "https://dev.appsmith.com/applications/123123test/pages/123123test",
+ ].forEach((url) => {
+ expect(UsagePulse.isTrackableUrl(url)).toBeTruthy();
+ });
+ });
+
+ it("should return false when called with untrackable URL", () => {
+ [
+ "https://dev.appsmith.com/applications",
+ "https://dev.appsmith.com/login",
+ "https://dev.appsmith.com/signup",
+ "https://dev.appsmith.com/settings",
+ "https://dev.appsmith.com/generate-page",
+ ].forEach((url) => {
+ expect(UsagePulse.isTrackableUrl(url)).toBeFalsy();
+ });
+ });
+ });
+});
diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx
index 2008468ec6..088eb7e921 100644
--- a/app/client/src/utils/AnalyticsUtil.tsx
+++ b/app/client/src/utils/AnalyticsUtil.tsx
@@ -317,11 +317,17 @@ class AnalyticsUtil {
static cachedAnonymoustId: string;
static cachedUserId: string;
static user?: User = undefined;
+ static blockTrackEvent: boolean | undefined;
static initializeSmartLook(id: string) {
smartlookClient.init(id);
}
+ static initializeSegmentWithoutTracking(key: string) {
+ AnalyticsUtil.blockTrackEvent = true;
+ return AnalyticsUtil.initializeSegment(key);
+ }
+
static initializeSegment(key: string) {
const initPromise = new Promise((resolve) => {
(function init(window: any) {
@@ -388,6 +394,10 @@ class AnalyticsUtil {
}
static logEvent(eventName: EventName, eventData: any = {}) {
+ if (AnalyticsUtil.blockTrackEvent) {
+ return;
+ }
+
const windowDoc: any = window;
let finalEventData = eventData;
const userData = AnalyticsUtil.user;
@@ -485,6 +495,8 @@ class AnalyticsUtil {
username: userData.username,
});
}
+
+ AnalyticsUtil.blockTrackEvent = false;
}
static getAnonymousId() {
@@ -506,6 +518,11 @@ class AnalyticsUtil {
windowDoc.mixpanel && windowDoc.mixpanel.reset();
window.zipy && window.zipy.anonymize();
}
+
+ static removeAnalytics() {
+ AnalyticsUtil.blockTrackEvent = false;
+ (window as any).analytics = undefined;
+ }
}
export default AnalyticsUtil;
diff --git a/app/client/src/utils/AppsmithUtils.tsx b/app/client/src/utils/AppsmithUtils.tsx
index abe7de71ca..bf8289c558 100644
--- a/app/client/src/utils/AppsmithUtils.tsx
+++ b/app/client/src/utils/AppsmithUtils.tsx
@@ -84,6 +84,24 @@ export const initializeAnalyticsAndTrackers = () => {
}
};
+export const initializeSegmentWithoutTracking = () => {
+ const appsmithConfigs = getAppsmithConfigs();
+
+ if (appsmithConfigs.segment.apiKey) {
+ // This value is only enabled for Appsmith's cloud hosted version. It is not set in self-hosted environments
+ return AnalyticsUtil.initializeSegmentWithoutTracking(
+ appsmithConfigs.segment.apiKey,
+ );
+ } else if (appsmithConfigs.segment.ceKey) {
+ // This value is set in self-hosted environments. But if the analytics are disabled, it's never used.
+ return AnalyticsUtil.initializeSegmentWithoutTracking(
+ appsmithConfigs.segment.ceKey,
+ );
+ } else {
+ return Promise.resolve();
+ }
+};
+
export const mapToPropList = (map: Record): Property[] => {
return _.map(map, (value, key) => {
return { key: key, value: value };