diff --git a/app/client/src/ce/sagas/userSagas.tsx b/app/client/src/ce/sagas/userSagas.tsx index 65831655ff29..fd0a01fd4e44 100644 --- a/app/client/src/ce/sagas/userSagas.tsx +++ b/app/client/src/ce/sagas/userSagas.tsx @@ -247,6 +247,7 @@ export function* runUserSideEffectsSaga() { if (enableTelemetry) { yield fork(initTrackers, currentUser); } else { + yield call(AnalyticsUtil.avoidTracking); yield put(segmentInitSuccess()); } diff --git a/app/client/src/ce/utils/AnalyticsUtil.tsx b/app/client/src/ce/utils/AnalyticsUtil.tsx index f7ea262255ac..e3afcc5699c0 100644 --- a/app/client/src/ce/utils/AnalyticsUtil.tsx +++ b/app/client/src/ce/utils/AnalyticsUtil.tsx @@ -131,6 +131,12 @@ function reset() { segmentAnalytics && segmentAnalytics.reset(); } +function avoidTracking() { + segmentAnalytics = SegmentSingleton.getInstance(); + + segmentAnalytics.avoidTracking(); +} + export { initialize, logEvent, @@ -141,4 +147,5 @@ export { reset, getEventExtraProperties, initLicense, + avoidTracking, }; diff --git a/app/client/src/utils/Analytics/segment.test.ts b/app/client/src/utils/Analytics/segment.test.ts new file mode 100644 index 000000000000..3a67b1c2a797 --- /dev/null +++ b/app/client/src/utils/Analytics/segment.test.ts @@ -0,0 +1,223 @@ +import SegmentSingleton from "./segment"; +import { getAppsmithConfigs } from "ee/configs"; +import log from "loglevel"; + +// Mock external dependencies +jest.mock("ee/configs"); +jest.mock("loglevel"); +jest.mock("@segment/analytics-next", () => ({ + AnalyticsBrowser: { + load: jest.fn(), + }, +})); + +// Mock implementations +const mockAnalytics = { + track: jest.fn(), + identify: jest.fn(), + addSourceMiddleware: jest.fn(), + reset: jest.fn(), + user: jest.fn(), +}; + +const mockAnalyticsBrowser = { + load: jest.fn().mockResolvedValue([mockAnalytics]), +}; + +// Setup before each test +beforeEach(() => { + jest.clearAllMocks(); + + // Reset singleton instance + (SegmentSingleton as unknown as { instance: unknown }).instance = undefined; + + // Default mock for getAppsmithConfigs + (getAppsmithConfigs as jest.Mock).mockReturnValue({ + segment: { + enabled: true, + apiKey: "test-api-key", + ceKey: "test-ce-key", + }, + }); + + // Set up AnalyticsBrowser mock + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("@segment/analytics-next").AnalyticsBrowser = mockAnalyticsBrowser; +}); + +describe("SegmentSingleton", () => { + describe("getInstance", () => { + it("should return the same instance when called multiple times", () => { + const instance1 = SegmentSingleton.getInstance(); + const instance2 = SegmentSingleton.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); + + describe("init", () => { + it("should initialize successfully with API key", async () => { + const segment = SegmentSingleton.getInstance(); + const result = await segment.init(); + + expect(result).toBe(true); + expect(mockAnalyticsBrowser.load).toHaveBeenCalledWith( + { writeKey: "test-api-key" }, + expect.any(Object), + ); + }); + + it("should not initialize when segment is disabled", async () => { + (getAppsmithConfigs as jest.Mock).mockReturnValue({ + segment: { enabled: false }, + }); + + const segment = SegmentSingleton.getInstance(); + const result = await segment.init(); + + expect(result).toBe(true); + expect(mockAnalyticsBrowser.load).not.toHaveBeenCalled(); + }); + + it("should use ceKey when apiKey is not available", async () => { + (getAppsmithConfigs as jest.Mock).mockReturnValue({ + segment: { + enabled: true, + apiKey: "", + ceKey: "test-ce-key", + }, + }); + + const segment = SegmentSingleton.getInstance(); + const result = await segment.init(); + + expect(result).toBe(true); + expect(mockAnalyticsBrowser.load).toHaveBeenCalledWith( + { writeKey: "test-ce-key" }, + expect.any(Object), + ); + }); + }); + + describe("track", () => { + it("should queue events when not initialized", () => { + const segment = SegmentSingleton.getInstance(); + const eventData = { test: "data" }; + + segment.track("test-event", eventData); + + expect(mockAnalytics.track).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith( + "Event queued for later processing", + "test-event", + eventData, + ); + }); + + it("should process queued events after initialization", async () => { + const segment = SegmentSingleton.getInstance(); + const eventData = { test: "data" }; + + segment.track("test-event", eventData); + await segment.init(); + + expect(mockAnalytics.track).toHaveBeenCalledWith("test-event", eventData); + }); + + it("should track events directly when initialized", async () => { + const segment = SegmentSingleton.getInstance(); + + await segment.init(); + + const eventData = { test: "data" }; + + segment.track("test-event", eventData); + + expect(mockAnalytics.track).toHaveBeenCalledWith("test-event", eventData); + }); + }); + + describe("identify", () => { + it("should call analytics identify when initialized", async () => { + const segment = SegmentSingleton.getInstance(); + + await segment.init(); + + const userId = "test-user"; + const traits = { name: "Test User" }; + + await segment.identify(userId, traits); + + expect(mockAnalytics.identify).toHaveBeenCalledWith(userId, traits); + }); + }); + + describe("reset", () => { + it("should call analytics reset when initialized", async () => { + const segment = SegmentSingleton.getInstance(); + + await segment.init(); + + segment.reset(); + + expect(mockAnalytics.reset).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle initialization failure", async () => { + mockAnalyticsBrowser.load.mockRejectedValueOnce(new Error("Init failed")); + + const segment = SegmentSingleton.getInstance(); + const result = await segment.init(); + + expect(result).toBe(false); + expect(log.error).toHaveBeenCalledWith( + "Failed to initialize Segment:", + expect.any(Error), + ); + }); + }); + describe("avoidTracking", () => { + it("should not track events after avoidTracking is called", async () => { + const segment = SegmentSingleton.getInstance(); + + await segment.init(); + + // Track an event before calling avoidTracking + segment.track("pre-avoid-event", { data: "value" }); + expect(mockAnalytics.track).toHaveBeenCalledTimes(1); + + // Call avoidTracking + segment.avoidTracking(); + + // Track an event after calling avoidTracking + segment.track("post-avoid-event", { data: "value" }); + + // Should still have only been called once (from the first event) + expect(mockAnalytics.track).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith( + expect.stringContaining("Event fired locally"), + "post-avoid-event", + { data: "value" }, + ); + }); + + it("should flush queued events when avoidTracking is called before initialization", async () => { + const segment = SegmentSingleton.getInstance(); + + // Queue some events + segment.track("queued-event-1", { data: "value1" }); + segment.track("queued-event-2", { data: "value2" }); + + // Call avoidTracking before initialization + segment.avoidTracking(); + + // Initialize + await segment.init(); + + // Analytics track should not be called since we're avoiding tracking + expect(mockAnalytics.track).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/client/src/utils/Analytics/segment.ts b/app/client/src/utils/Analytics/segment.ts index 2aa73671174b..fa00f034b599 100644 --- a/app/client/src/utils/Analytics/segment.ts +++ b/app/client/src/utils/Analytics/segment.ts @@ -7,9 +7,18 @@ import { import { getAppsmithConfigs } from "ee/configs"; import log from "loglevel"; +enum InitializationStatus { + WAITING = "waiting", + INITIALIZED = "initialized", + FAILED = "failed", + NOT_REQUIRED = "not_required", +} + class SegmentSingleton { private static instance: SegmentSingleton; private analytics: Analytics | null = null; + private eventQueue: Array<{ name: string; data: EventProperties }> = []; + private initState: InitializationStatus = InitializationStatus.WAITING; public static getInstance(): SegmentSingleton { if (!SegmentSingleton.instance) { @@ -43,6 +52,8 @@ class SegmentSingleton { const { segment } = getAppsmithConfigs(); if (!segment.enabled) { + this.avoidTracking(); + return true; } @@ -56,6 +67,7 @@ class SegmentSingleton { if (!writeKey) { log.error("Segment key was not found."); + this.avoidTracking(); return true; } @@ -80,24 +92,53 @@ class SegmentSingleton { ); this.analytics = analytics; + this.initState = InitializationStatus.INITIALIZED; + // Process queued events after successful initialization + this.processEventQueue(); return true; } catch (error) { log.error("Failed to initialize Segment:", error); + // Clear the queue if error occurred in init + this.flushEventQueue(); + this.initState = InitializationStatus.FAILED; return false; } } + private processEventQueue() { + while (this.eventQueue.length > 0) { + const event = this.eventQueue.shift(); + + if (event) { + this.track(event.name, event.data); + } + } + } + + private flushEventQueue() { + this.eventQueue = []; + } + public track(eventName: string, eventData: EventProperties) { - // In scenarios where segment was never initialised, we are logging the event locally - // This is done so that we can debug event logging locally - if (this.analytics) { - log.debug("Event fired", eventName, eventData); - this.analytics.track(eventName, eventData); - } else { + if (this.initState === InitializationStatus.WAITING) { + // Only queue events if we're in WAITING state + this.eventQueue.push({ name: eventName, data: eventData }); + log.debug("Event queued for later processing", eventName, eventData); + } + + if ( + this.initState === InitializationStatus.NOT_REQUIRED || + !this.analytics + ) { log.debug("Event fired locally", eventName, eventData); + + return; } + + log.debug("Event fired", eventName, eventData); + this.analytics.track(eventName, eventData); } public async identify(userId: string, traits: UserTraits) { @@ -112,6 +153,11 @@ class SegmentSingleton { } } + public avoidTracking() { + this.initState = InitializationStatus.NOT_REQUIRED; + this.flushEventQueue(); + } + public reset() { if (this.analytics) { this.analytics.reset();