Skip to content

Commit

Permalink
feat: implement an abstraction for analytics, rework google-analytics…
Browse files Browse the repository at this point in the history
… module (#1500)

Co-authored-by: Kutasina Elena <62027488+ekuvirto@users.noreply.github.com>
  • Loading branch information
ivan-kalachikov and ekuvirto authored Jan 6, 2025
1 parent c6a8377 commit 74ac5fb
Show file tree
Hide file tree
Showing 28 changed files with 737 additions and 147 deletions.
9 changes: 4 additions & 5 deletions client-app/app-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DefaultApolloClient } from "@vue/apollo-composable";
import { createApp, h, provide } from "vue";
import { getEpParam, isPreviewMode as isPageBuilderPreviewMode } from "@/builder-preview/utils";
import { apolloClient, getStore } from "@/core/api/graphql";
import { useCurrency, useThemeContext, useGoogleAnalytics, useWhiteLabeling, useNavigations } from "@/core/composables";
import { useCurrency, useThemeContext, useWhiteLabeling, useNavigations } from "@/core/composables";
import { useHotjar } from "@/core/composables/useHotjar";
import { useLanguages } from "@/core/composables/useLanguages";
import { FALLBACK_LOCALE, IS_DEVELOPMENT } from "@/core/constants";
Expand All @@ -12,6 +12,7 @@ import { applicationInsightsPlugin, authPlugin, configPlugin, contextPlugin, per
import { extractHostname, getBaseUrl, Logger } from "@/core/utilities";
import { createI18n } from "@/i18n";
import { init as initCustomerReviews } from "@/modules/customer-reviews";
import { init as initializeGoogleAnalytics } from "@/modules/google-analytics";
import { initialize as initializePurchaseRequests } from "@/modules/purchase-requests";
import { init as initPushNotifications } from "@/modules/push-messages";
import { init as initModuleQuotes } from "@/modules/quotes";
Expand Down Expand Up @@ -66,7 +67,6 @@ export default async () => {
mergeLocales,
} = useLanguages();
const { currentCurrency } = useCurrency();
const { init: initializeGoogleAnalytics } = useGoogleAnalytics();
const { init: initializeHotjar } = useHotjar();
const { fetchMenus } = useNavigations();
const { themePresetName, fetchWhiteLabelingSettings } = useWhiteLabeling();
Expand All @@ -90,9 +90,6 @@ export default async () => {

await Promise.all([fetchThemeContext(store), fetchUser(), fallback.setMessage()]);

void initializeGoogleAnalytics();
void initializeHotjar();

// priority rule: pinedLocale > contactLocale > urlLocale > storeLocale
const twoLetterAppLocale = detectLocale([
pinedLocale.value,
Expand Down Expand Up @@ -136,6 +133,8 @@ export default async () => {
void initModuleQuotes(router, i18n);
void initCustomerReviews(i18n);
void initializePurchaseRequests(router, i18n);
void initializeGoogleAnalytics();
void initializeHotjar();

if (themePresetName.value) {
await fetchThemeContext(store, themePresetName.value);
Expand Down
2 changes: 1 addition & 1 deletion client-app/core/composables/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export * from "./useAnalytics";
export * from "./useAuth";
export * from "./useBreadcrumbs";
export * from "./useCategoriesRoutes";
export * from "./useCountries";
export * from "./useCurrency";
export * from "./useErrorsTranslator";
export * from "./useGoogleAnalytics";
export * from "./useHistoricalEvents";
export * from "./useImpersonate";
export * from "./useMutationBatcher";
Expand Down
277 changes: 277 additions & 0 deletions client-app/core/composables/useAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Logger } from "@/core/utilities";
import type { CustomerOrderType, LineItemType, Product } from "../api/graphql/types";
import type { useAnalytics as useAnalyticsType } from "@/core/composables/useAnalytics";
import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "@/core/types/analytics";

vi.mock("@/core/utilities/logger", () => ({
Logger: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

vi.mock("@/core/constants", () => ({
IS_DEVELOPMENT: false,
}));

const mockedProduct = {
id: "123",
} as Product;

const mockedCustomerOrder = {
id: "123",
} as CustomerOrderType;

const arbitraryParam = { someParam: "value" };

describe("useAnalytics", () => {
let analyticsInstance: ReturnType<typeof useAnalyticsType>;
let addTracker: ReturnType<typeof useAnalyticsType>["addTracker"];
let analytics: ReturnType<typeof useAnalyticsType>["analytics"];
let mockTracker1: TackerType;
let mockTracker2: TackerType;

beforeEach(async () => {
vi.resetModules();
vi.doUnmock("@/core/constants");
vi.clearAllMocks();

const { useAnalytics } = await import("@/core/composables/useAnalytics");

analyticsInstance = useAnalytics();
addTracker = analyticsInstance.addTracker;
analytics = analyticsInstance.analytics;

mockTracker1 = {
viewItemList: vi.fn(),
selectItem: vi.fn(),
viewItem: vi.fn(),
addItemToWishList: vi.fn(),
addItemToCart: vi.fn(),
addItemsToCart: vi.fn(),
removeItemsFromCart: vi.fn(),
viewCart: vi.fn(),
clearCart: vi.fn(),
beginCheckout: vi.fn(),
addShippingInfo: vi.fn(),
addPaymentInfo: vi.fn(),
purchase: vi.fn(),
placeOrder: vi.fn(),
search: vi.fn(),
};

mockTracker2 = {
viewItemList: vi.fn(),
selectItem: vi.fn(),
viewItem: vi.fn(),
addItemToWishList: vi.fn(),
addItemToCart: vi.fn(),
addItemsToCart: vi.fn(),
removeItemsFromCart: vi.fn(),
viewCart: vi.fn(),
clearCart: vi.fn(),
beginCheckout: vi.fn(),
addShippingInfo: vi.fn(),
addPaymentInfo: vi.fn(),
purchase: vi.fn(),
placeOrder: vi.fn(),
search: vi.fn(),
};
});

it("should dispatch events to a single tracker", () => {
addTracker(mockTracker1);

const event: AnalyticsEventNameType = "viewItemList";
const args: IAnalyticsEventMap["viewItemList"] = [[{ code: "item1" }], arbitraryParam];

analytics(event, ...args);

expect(mockTracker1.viewItemList).toHaveBeenCalledWith(...args);
expect(Logger.debug).not.toHaveBeenCalled();
expect(Logger.warn).not.toHaveBeenCalled();
});

it("should dispatch events to multiple trackers", () => {
addTracker(mockTracker1);
addTracker(mockTracker2);

const event: AnalyticsEventNameType = "selectItem";
const args: IAnalyticsEventMap["selectItem"] = [{ productId: "123" } as LineItemType, arbitraryParam];

analytics(event, ...args);

expect(mockTracker1.selectItem).toHaveBeenCalledWith(...args);
expect(mockTracker2.selectItem).toHaveBeenCalledWith(...args);
expect(Logger.debug).not.toHaveBeenCalled();
expect(Logger.warn).not.toHaveBeenCalled();
});

it("should handle multiple dispatches of the same event", () => {
addTracker(mockTracker1);

const event: AnalyticsEventNameType = "viewItem";
const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }];
const args2: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value2" }];

analytics(event, ...args1);
analytics(event, ...args2);

expect(mockTracker1.viewItem).toHaveBeenCalledTimes(2);
expect(mockTracker1.viewItem).toHaveBeenCalledWith(...args1);
expect(mockTracker1.viewItem).toHaveBeenCalledWith(...args2);
expect(Logger.debug).not.toHaveBeenCalled();
expect(Logger.warn).not.toHaveBeenCalled();
});

it("should log a warning if a tracker does not handle the event", () => {
addTracker(mockTracker1);

delete mockTracker1.purchase;

const event: AnalyticsEventNameType = "purchase";
const args: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", arbitraryParam];

analytics(event, ...args);

expect(mockTracker1.purchase).toBeUndefined();
expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.');
});

it("should handle adding the same tracker multiple times", () => {
addTracker(mockTracker1);
addTracker(mockTracker1);

const event: AnalyticsEventNameType = "search";
const args: IAnalyticsEventMap["search"] = ["query", [{ code: "item1" }], 1];

analytics(event, ...args);

expect(mockTracker1.search).toHaveBeenCalledTimes(1);
expect(mockTracker1.search).toHaveBeenCalledWith(...args);
});

it("should handle trackers with partial event support gracefully", () => {
const partialTracker: TackerType = {
viewItem: vi.fn(),
search: vi.fn(),
};

addTracker(partialTracker);

const event1: AnalyticsEventNameType = "viewItem";
const args1: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: "value1" }];

analytics(event1, ...args1);

expect(partialTracker.viewItem).toHaveBeenCalledWith(...args1);
expect(Logger.warn).not.toHaveBeenCalled();

const event2: AnalyticsEventNameType = "purchase";
const args2: IAnalyticsEventMap["purchase"] = [mockedCustomerOrder, "txn123", { someParam: "value2" }];

analytics(event2, ...args2);

expect(partialTracker.purchase).toBeUndefined();
expect(Logger.warn).toHaveBeenCalledWith('useAnalytics, unsupported event: "purchase" in tracker.');
});

it("should continue dispatching events even if one tracker throws an error", () => {
const faultyTracker: TackerType = {
viewItem: vi.fn(() => {
throw new Error("Tracker error");
}),
};

const normalTracker: TackerType = {
viewItem: vi.fn(),
};

addTracker(faultyTracker);
addTracker(normalTracker);

const event: AnalyticsEventNameType = "viewItem";
const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam];

const loggerErrorSpy = vi.spyOn(Logger, "error");

analytics(event, ...args);

expect(faultyTracker.viewItem).toHaveBeenCalledWith(...args);
expect(normalTracker.viewItem).toHaveBeenCalledWith(...args);
expect(loggerErrorSpy).toHaveBeenCalled();
});

it("should not dispatch events and not log warnings when no trackers are added", () => {
const event: AnalyticsEventNameType = "viewItem";
const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam];

analytics(event, ...args);

expect(mockTracker1.viewItem).not.toHaveBeenCalled();
expect(Logger.warn).not.toHaveBeenCalled();
expect(Logger.debug).not.toHaveBeenCalled();
});

it("should handle a high volume of events and multiple trackers without issues", () => {
const numTrackers = 10;
const trackers: TackerType[] = Array.from({ length: numTrackers }, () => ({
viewItem: vi.fn(),
search: vi.fn(),
}));

trackers.forEach(addTracker);

const numEvents = 100;
for (let i = 0; i < numEvents; i++) {
const event: AnalyticsEventNameType = "viewItem";
const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, { someParam: `value${i}` }];
analytics(event, ...args);
}

trackers.forEach((tracker) => {
expect(tracker.viewItem).toHaveBeenCalledTimes(numEvents);
for (let i = 0; i < numEvents; i++) {
expect(tracker.viewItem).toHaveBeenNthCalledWith(i + 1, mockedProduct, { someParam: `value${i}` });
}
});

expect(Logger.warn).not.toHaveBeenCalled();
expect(Logger.debug).not.toHaveBeenCalled();
});

it("should not dispatch events and log debug in development mode", async () => {
vi.resetModules();
vi.doUnmock("@/core/constants");
vi.doMock("@/core/constants", () => ({
IS_DEVELOPMENT: true,
}));

const { useAnalytics: useAnalyticsDev } = await import("@/core/composables/useAnalytics");
const { addTracker: addTrackerDev, analytics: analyticsDev } = useAnalyticsDev();

addTrackerDev(mockTracker1);

const event: AnalyticsEventNameType = "addItemToCart";
const args: IAnalyticsEventMap["addItemToCart"] = [mockedProduct, 2, arbitraryParam];

analyticsDev(event, ...args);

expect(mockTracker1.addItemToCart).not.toHaveBeenCalled();
expect(Logger.debug).toHaveBeenCalledWith("useAnalytics, can't track event in development mode");
expect(Logger.warn).not.toHaveBeenCalled();
});

it("should not dispatch events and not log warnings when no trackers are added", () => {
const event: AnalyticsEventNameType = "viewItem";
const args: IAnalyticsEventMap["viewItem"] = [mockedProduct, arbitraryParam];

analytics(event, ...args);

expect(mockTracker1.viewItem).not.toHaveBeenCalled();
expect(Logger.warn).not.toHaveBeenCalled();
expect(Logger.debug).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions client-app/core/composables/useAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createGlobalState } from "@vueuse/core";
import { IS_DEVELOPMENT } from "@/core/constants";
import { Logger } from "@/core/utilities/logger";
import type { AnalyticsEventNameType, IAnalyticsEventMap, TackerType } from "../types/analytics";

function _useAnalytics() {
const trackers: Set<TackerType> = new Set();

function addTracker(tracker: TackerType): void {
trackers.add(tracker);
}

function analytics<E extends AnalyticsEventNameType>(event: E, ...args: IAnalyticsEventMap[E]): void {
if (IS_DEVELOPMENT) {
Logger.debug("useAnalytics, can't track event in development mode");
return;
}
trackers.forEach((tracker) => {
const handler = tracker[event];
if (handler) {
try {
handler(...args);
} catch (error) {
Logger.error(`useAnalytics, error calling event: "${event}" in tracker.`, error);
}
} else {
Logger.warn(`useAnalytics, unsupported event: "${event}" in tracker.`);
}
});
}

return {
addTracker,
analytics,
};
}

export const useAnalytics = createGlobalState(_useAnalytics);
Loading

0 comments on commit 74ac5fb

Please sign in to comment.