Skip to content

Commit

Permalink
feat(lld): data tracking in the exchange flow
Browse files Browse the repository at this point in the history
  • Loading branch information
fAnselmi-Ledger committed Feb 7, 2025
1 parent 4c07450 commit 2491b7a
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/slow-icons-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ledger-live-desktop": minor
---

Add data tracking in the exchange flow
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { renderHook } from "tests/testUtils";
import { useTrackExchangeFlow, UseTrackExchangeFlow } from "./useTrackExchangeFlow";
import { track } from "../segment";
import { UserRefusedAllowManager, UserRefusedOnDevice } from "@ledgerhq/errors";
import { CONNECTION_TYPES, HOOKS_TRACKING_LOCATIONS } from "./variables";

jest.mock("../segment", () => ({
track: jest.fn(),
setAnalyticsFeatureFlagMethod: jest.fn(),
}));

describe("useTrackExchangeFlow", () => {
const deviceMock = {
modelId: "stax",
wired: true,
};

const defaultArgs: UseTrackExchangeFlow = {
location: HOOKS_TRACKING_LOCATIONS.exchange,
device: deviceMock,
error: null,
isTrackingEnabled: true,
isRequestOpenAppExchange: null,
};

afterEach(() => {
jest.clearAllMocks();
});

it("should track 'Open app denied' when UserRefusedOnDevice error is thrown", () => {
const error = new UserRefusedOnDevice();

renderHook((props: UseTrackExchangeFlow) => useTrackExchangeFlow(props), {
initialProps: { ...defaultArgs, error },
});

expect(track).toHaveBeenCalledWith(
"Open app denied",
expect.objectContaining({
deviceType: "stax",
connectionType: CONNECTION_TYPES.USB,
platform: "LLD",
page: HOOKS_TRACKING_LOCATIONS.exchange,
}),
true,
);
});

it("should track 'Secure Channel denied' when UserRefusedAllowManager error is thrown", () => {
const error = new UserRefusedAllowManager();

renderHook((props: UseTrackExchangeFlow) => useTrackExchangeFlow(props), {
initialProps: { ...defaultArgs, error },
});

expect(track).toHaveBeenCalledWith(
"Secure Channel denied",
expect.objectContaining({
deviceType: "stax",
connectionType: CONNECTION_TYPES.USB,
platform: "LLD",
page: HOOKS_TRACKING_LOCATIONS.exchange,
}),
true,
);
});

it("should track 'Open app performed' when isRequestOpenAppExchange changes from true to false", () => {
const { rerender } = renderHook((props: UseTrackExchangeFlow) => useTrackExchangeFlow(props), {
initialProps: { ...defaultArgs, isRequestOpenAppExchange: true },
});

rerender({ ...defaultArgs, isRequestOpenAppExchange: false });

expect(track).toHaveBeenCalledWith(
"Open app performed",
expect.objectContaining({
deviceType: "stax",
connectionType: CONNECTION_TYPES.USB,
platform: "LLD",
page: HOOKS_TRACKING_LOCATIONS.exchange,
}),
true,
);
});

it("should not track events if location is not 'Exchange'", () => {
renderHook((props: UseTrackExchangeFlow) => useTrackExchangeFlow(props), {
initialProps: { ...defaultArgs, location: "NOT Exchange" },
});

expect(track).not.toHaveBeenCalled();
});

it("should correctly determine connection type as 'BLE' when device.wired is false", () => {
const bluetoothDeviceMock = { modelId: "stax", wired: false };

const { rerender } = renderHook((props: UseTrackExchangeFlow) => useTrackExchangeFlow(props), {
initialProps: { ...defaultArgs, device: deviceMock },
});

rerender({ ...defaultArgs, device: bluetoothDeviceMock, error: new UserRefusedOnDevice() });

expect(track).toHaveBeenCalledWith(
"Open app denied",
expect.objectContaining({
deviceType: "stax",
connectionType: CONNECTION_TYPES.BLE,
platform: "LLD",
page: HOOKS_TRACKING_LOCATIONS.exchange,
}),
true,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useRef, useEffect } from "react";
import { UserRefusedAllowManager, UserRefusedOnDevice } from "@ledgerhq/errors";
import { track } from "../segment";
import { Device } from "@ledgerhq/types-devices";
import { LedgerError } from "~/renderer/components/DeviceAction";
import { CONNECTION_TYPES, HOOKS_TRACKING_LOCATIONS } from "./variables";

export type UseTrackExchangeFlow = {
location: string | undefined;
device: Device;
error:
| (LedgerError & {
name?: string;
managerAppName?: string;
})
| undefined
| null;
isTrackingEnabled: boolean;
isRequestOpenAppExchange: boolean | null;
};

/**
* a custom hook to track events in the Exchange flow.
* tracks user interactions in the Exchange flow based on state changes and errors.
*
* @param location - current location in the app (expected "Exchange" from HOOKS_TRACKING_LOCATIONS enum).
* @param device - the connected device information.
* @param error - current error state.
* @param isTrackingEnabled - flag indicating if tracking is enabled.
* @param isRequestOpenAppExchange - flag indicating if LLD requested to open the exchange app.
*/
export const useTrackExchangeFlow = ({
location,
device,
error,
isTrackingEnabled,
isRequestOpenAppExchange,
}: UseTrackExchangeFlow) => {
const previousIsRequestOpenAppExchange = useRef<boolean | null>(null);

useEffect(() => {
if (location !== HOOKS_TRACKING_LOCATIONS.exchange) return;

const defaultPayload = {
deviceType: device?.modelId,
connectionType: device?.wired ? CONNECTION_TYPES.USB : CONNECTION_TYPES.BLE,
platform: "LLD",
page: "Exchange",
};

if ((error as unknown) instanceof UserRefusedOnDevice) {
// user refused to open exchange app
track("Open app denied", defaultPayload, isTrackingEnabled);
} else if ((error as unknown) instanceof UserRefusedAllowManager) {
// user refused secure channel
track("Secure Channel denied", defaultPayload, isTrackingEnabled);
}

if (previousIsRequestOpenAppExchange.current === true && isRequestOpenAppExchange === false) {
// user opened exchange app
track("Open app performed", defaultPayload, isTrackingEnabled);
}

previousIsRequestOpenAppExchange.current = isRequestOpenAppExchange;
}, [error, location, isTrackingEnabled, device, isRequestOpenAppExchange]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export type UseTrackManagerSectionEvents = {
* @param allowManagerRequested - flag indicating if the user has allowed the Manager app.
* @param clsImageRemoved - flag indicating if the user has removed the custom lock screen image.
* @param error - current error state.
* @param parentHookState - state from the parent hook, particularly allowManagerRequested.
* @param isTrackingEnabled - flag indicating if tracking is enabled.
*/
export const useTrackManagerSectionEvents = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ export enum CONNECTION_TYPES {
USB = "USB",
BLE = "BLE",
}

export enum HOOKS_TRACKING_LOCATIONS {
exchange = "Exchange",
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { walletSelector } from "~/renderer/reducers/wallet";
import { useTrackManagerSectionEvents } from "~/renderer/analytics/hooks/useTrackManagerSectionEvents";
import { useTrackReceiveFlow } from "~/renderer/analytics/hooks/useTrackReceiveFlow";
import { useTrackAddAccountModal } from "~/renderer/analytics/hooks/useTrackAddAccountModal";
import { useTrackExchangeFlow } from "~/renderer/analytics/hooks/useTrackExchangeFlow";

export type LedgerError = InstanceType<LedgerErrorConstructor<{ [key: string]: unknown }>>;

Expand Down Expand Up @@ -268,6 +269,14 @@ export const DeviceActionDefaultRendering = <R, H extends States, P>({
isLocked,
});

useTrackExchangeFlow({
location,
device,
error,
isTrackingEnabled: useSelector(trackingEnabledSelector),
isRequestOpenAppExchange: requestOpenApp === "Exchange",
});

const type = useTheme().colors.palette.type;

const modelId = device ? device.modelId : overridesPreferredDeviceModel || preferredDeviceModel;
Expand Down Expand Up @@ -346,7 +355,6 @@ export const DeviceActionDefaultRendering = <R, H extends States, P>({
if (languageInstallationRequested) {
return renderAllowLanguageInstallation({ modelId, type, t });
}

if (imageRemoveRequested) {
const refused = error instanceof UserRefusedOnDevice;
const noImage = error instanceof ImageDoesNotExistOnDevice;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { HardwareUpdate, renderLoading } from "./DeviceAction/rendering";
import { createCustomErrorClass } from "@ledgerhq/errors";
import { getCurrentDevice } from "~/renderer/reducers/devices";
import { ExchangeSwap } from "@ledgerhq/live-common/exchange/swap/types";
import { HOOKS_TRACKING_LOCATIONS } from "../analytics/hooks/variables";

const Divider = styled(Box)`
border: 1px solid ${p => p.theme.colors.palette.divider};
Expand Down Expand Up @@ -234,6 +235,7 @@ export const LiveAppDrawer = () => {
action={action}
request={data}
Result={() => renderLoading()}
location={HOOKS_TRACKING_LOCATIONS.exchange}
onResult={result => {
if ("startExchangeResult" in result) {
data.onResult(result.startExchangeResult);
Expand Down

0 comments on commit 2491b7a

Please sign in to comment.