Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement an abstraction for analytics, rework google-analytics module #1500

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
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
Loading