Skip to content

Commit

Permalink
Merge pull request #9122 from LedgerHQ/feat/live-15765-data-tracking-…
Browse files Browse the repository at this point in the history
…in-the-swap-flow

feat(lld): data tracking in the exchange flow
  • Loading branch information
fAnselmi-Ledger authored Feb 13, 2025
2 parents 235155c + 3d77058 commit 6b7a7bd
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 8 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,116 @@
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), {
//@ts-expect-error location is not 'Exchange'
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: HOOKS_TRACKING_LOCATIONS.exchange | 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,11 @@ export enum CONNECTION_TYPES {
USB = "USB",
BLE = "BLE",
}

export enum HOOKS_TRACKING_LOCATIONS {
ledgerSync = "Ledger Sync",
addAccountModal = "Add account modal",
managerDashboard = "Manager Dashboard",
receiveModal = "Receive Modal",
exchange = "Exchange",
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ 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";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";

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

Expand Down Expand Up @@ -159,7 +161,7 @@ type InnerProps<P> = {
analyticsPropertyFlow?: string;
overridesPreferredDeviceModel?: DeviceModelId;
inlineRetry?: boolean; // Set to false if the retry mechanism is handled externally.
location?: string;
location?: HOOKS_TRACKING_LOCATIONS;
};

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

useTrackExchangeFlow({
location: location === HOOKS_TRACKING_LOCATIONS.exchange ? location : undefined,
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 +356,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 { Exchange, isExchangeSwap } from "@ledgerhq/live-common/exchange/types";
import { HardwareUpdate, renderLoading } from "./DeviceAction/rendering";
import { createCustomErrorClass } from "@ledgerhq/errors";
import { getCurrentDevice } from "~/renderer/reducers/devices";
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 @@ -189,6 +190,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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StepProps } from "..";
import { getEnv } from "@ledgerhq/live-env";
import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock";
import connectApp from "@ledgerhq/live-common/hw/connectApp";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";
const action = createAction(getEnv("MOCK") ? mockedEventEmitter : connectApp);
const StepConnectDevice = ({ currency, transitionTo, flow }: StepProps) => {
invariant(currency, "No crypto asset given");
Expand Down Expand Up @@ -40,7 +41,7 @@ const StepConnectDevice = ({ currency, transitionTo, flow }: StepProps) => {
transitionTo("import");
}}
analyticsPropertyFlow={flow}
location="Add account modal"
location={HOOKS_TRACKING_LOCATIONS.addAccountModal}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import connectApp from "@ledgerhq/live-common/hw/connectApp";
import { StepProps } from "../Body";
import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock";
import { getEnv } from "@ledgerhq/live-env";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";
const action = createAction(getEnv("MOCK") ? mockedEventEmitter : connectApp);
export default function StepConnectDevice({
account,
Expand All @@ -34,7 +35,7 @@ export default function StepConnectDevice({
request={request}
onResult={() => transitionTo("receive")}
analyticsPropertyFlow="receive"
location="Receive Modal"
location={HOOKS_TRACKING_LOCATIONS.receiveModal}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import getDeviceNameMaxLength from "@ledgerhq/live-common/hw/getDeviceNameMaxLen
import renameDevice from "@ledgerhq/live-common/hw/renameDevice";
import { withV3StyleProvider } from "~/renderer/styles/StyleProviderV3";
import { DeviceInfo } from "@ledgerhq/types-live";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";

const action = createAction(renameDevice);

Expand Down Expand Up @@ -144,7 +145,7 @@ const EditDeviceName: React.FC<Props> = ({
inlineRetry={false}
onResult={onSuccess}
onError={(error: Error) => setActionError(error)}
location="Manager Dashboard"
location={HOOKS_TRACKING_LOCATIONS.managerDashboard}
/>
</Flex>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { clearLastSeenCustomImage } from "~/renderer/actions/settings";
import { ImageDoesNotExistOnDevice } from "@ledgerhq/live-common/errors";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";

const action = createAction(removeImage);

Expand Down Expand Up @@ -101,7 +102,7 @@ const RemoveCustomImage: React.FC<Props> = ({ onClose, onRemoved }) => {
action={action}
onResult={onSuccess}
onError={onError}
location="Manager Dashboard"
location={HOOKS_TRACKING_LOCATIONS.managerDashboard}
/>
</Flex>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useDispatch } from "react-redux";
import { context } from "~/renderer/drawers/Provider";
import { useDeviceSessionRefresherToggle } from "@ledgerhq/live-dmk";
import { useFeature } from "@ledgerhq/live-common/featureFlags/index";
import { HOOKS_TRACKING_LOCATIONS } from "~/renderer/analytics/hooks/variables";

const action = createAction(getEnv("MOCK") ? mockedEventEmitter : connectManager);
const Manager = () => {
Expand Down Expand Up @@ -73,7 +74,7 @@ const Manager = () => {
onResult={onResult}
action={action}
request={null}
location="Manager Dashboard"
location={HOOKS_TRACKING_LOCATIONS.managerDashboard}
/>
) : (
<Disconnected onTryAgain={setHasReset} />
Expand Down

0 comments on commit 6b7a7bd

Please sign in to comment.