Skip to content

Commit d9d0daa

Browse files
committed
feat(observability): emit earn_transaction_* from the bridge seam
Every route resolves its bridge through getAccountBridge, so wrapAccountBridge — which already hosts the sanctioned-address check — is the one place that sees them all. It now decorates signOperation (classified failure, original error re-raised untouched) and broadcast (success or classified failure). The device-action layer adds the signal the bridge cannot see: closing the sign prompt is an unsubscribe, not an error. Desktop and mobile each register a Segment observer; track already self-gates on analytics consent.
1 parent a0e605e commit d9d0daa

15 files changed

Lines changed: 744 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@ledgerhq/live-common": minor
3+
"ledger-live-desktop": minor
4+
"live-mobile": minor
5+
---
6+
7+
Emit `earn_transaction_completed` / `earn_transaction_failed` for native staking, from the account-bridge seam.
8+
9+
Every transaction route resolves its bridge through `getAccountBridge`, so `wrapAccountBridge` — which already hosts the sanctioned-address check — is the one place that sees them all. It now decorates `signOperation` (emitting a classified failure, then re-raising the original error untouched) and `broadcast` (success or classified failure). The device-action layer adds the one signal the bridge cannot see: closing the sign prompt is an unsubscribe rather than an error, so abandonment is reported from there.
10+
11+
This replaces UI-inferred bottom-of-funnel tracking for staking, where a user reaching the final screen was counted as converted whether or not a transaction ever landed. No *analytics* event is produced for non-staking transactions. The seam observes every sign and broadcast outcome, and the Segment mapping is what drops the ones with no derived staking action — so plain sends and swaps reach no analytics sink, and no currency allowlist is needed.
12+
13+
Desktop and mobile each register a Segment observer at startup; `track` already self-gates on analytics consent. Desktop also registers a dev-only console observer so the whole seam can be watched locally across every staking route and coin. The existing Datadog `useBroadcast` path is untouched.

apps/ledger-live-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
"@ledgerhq/lumen-ui-react": "catalog:",
156156
"@ledgerhq/lumen-ui-react-visualization": "catalog:",
157157
"@ledgerhq/react-ui": "workspace:^",
158+
"@ledgerhq/transaction-observability": "workspace:^",
158159
"@ledgerhq/types-devices": "workspace:^",
159160
"@ledgerhq/types-live": "workspace:^",
160161
"@ledgerhq/wallet-analytics": "workspace:^",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { setTransactionObserver, toSegmentTrackEvent } from "@ledgerhq/transaction-observability";
2+
import { track } from "./segment";
3+
4+
/**
5+
* Forwards every transaction (sign/broadcast) log event from the bridge seam to
6+
* Segment/Mixpanel. Additive — the Datadog path (useBroadcast → broadcastLogger) is
7+
* untouched. `track` self-gates on analytics consent, so no extra gating is needed here.
8+
*
9+
* Registered at module load, like mobile's equivalent: the bridge is resolved through the
10+
* global `getAccountBridge` rather than React context, so there is nothing to hang it off.
11+
*/
12+
setTransactionObserver(event => {
13+
const mapped = toSegmentTrackEvent(event);
14+
if (mapped) track(mapped.event, mapped.properties);
15+
});
16+
17+
// Dev-only: makes the whole seam visible locally, across every staking route and coin.
18+
if (process.env.NODE_ENV !== "production") {
19+
setTransactionObserver(event => {
20+
// eslint-disable-next-line no-console
21+
console.log(`[tx-observability] ${event.stage}/${event.status}`, {
22+
flow: event.flow,
23+
currencyId: event.currencyId,
24+
rawTransactionType: event.rawTransactionType,
25+
earnTransactionType: event.earnTransactionType,
26+
dataSource: event.dataSource,
27+
...(event.status === "failure"
28+
? { errorCategory: event.errorCategory, errorName: event.error.name }
29+
: { validators: event.validators, manifestId: event.manifestId }),
30+
});
31+
});
32+
}

apps/ledger-live-desktop/src/renderer/init.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import "~/renderer/styles/global";
2121
import { registerTransportModules } from "~/renderer/live-common-setup";
2222
import { getLocalStorageEnvs } from "~/renderer/experimental";
2323
import "~/renderer/i18n/init";
24+
import "~/renderer/analytics/registerTransactionObserver";
2425
import { hydrateCurrency } from "~/renderer/bridge/cache";
2526
import { setupCryptoAssetsStore } from "~/config/bridge-setup";
2627
import { setSwapQuotesStore } from "@ledgerhq/live-common/wallet-api/Exchange/quotes/state-manager/store";

apps/ledger-live-mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
"@ledgerhq/lumen-ui-rnative": "catalog:",
182182
"@ledgerhq/lumen-ui-rnative-visualization": "catalog:",
183183
"@ledgerhq/native-ui": "workspace:^",
184+
"@ledgerhq/transaction-observability": "workspace:^",
184185
"@ledgerhq/types-devices": "workspace:^",
185186
"@ledgerhq/types-live": "workspace:^",
186187
"@ledgerhq/wallet-analytics": "workspace:^",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { setTransactionObserver, toSegmentTrackEvent } from "@ledgerhq/transaction-observability";
2+
import { track } from "./segment";
3+
4+
/**
5+
* Forwards every transaction (sign/broadcast) log event from the bridge seam to
6+
* Segment/Mixpanel. Additive — the Datadog path (useBroadcast → broadcastLogger) is
7+
* untouched. `track` self-gates on analytics consent, so no extra gating is needed here.
8+
*/
9+
setTransactionObserver(event => {
10+
const mapped = toSegmentTrackEvent(event);
11+
if (mapped) track(mapped.event, mapped.properties);
12+
});

apps/ledger-live-mobile/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { log } from "@ledgerhq/logs";
1414
import { checkLibs } from "@ledgerhq/live-common/sanityChecks";
1515
import "./config/configInit";
1616
import "./config/bridge-setup";
17+
import "./analytics/registerTransactionObserver";
1718
import Config from "react-native-config";
1819
import useEnv from "@features/platform-env";
1920
import { init } from "~/e2e/bridge/client";

libs/ledger-live-common/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@
363363
"@ledgerhq/live-signer-zcash": "workspace:^",
364364
"@ledgerhq/logs": "catalog:",
365365
"@ledgerhq/speculos-transport": "workspace:^",
366+
"@ledgerhq/transaction-observability": "workspace:^",
366367
"@ledgerhq/wallet-api-acre-module": "workspace:^",
367368
"@ledgerhq/wallet-api-client": "catalog:",
368369
"@ledgerhq/wallet-api-core": "catalog:",

libs/ledger-live-common/src/bridge/impl.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
CurrencyBridge,
1414
ResolvedAccountBridge,
1515
TransactionCommon,
16+
TransactionSource,
1617
TransactionStatusCommon,
1718
} from "@ledgerhq/types-live";
1819
import { getCoinFrameworkAccountBridge } from "./generic-coin-framework/accountBridge";
@@ -26,6 +27,17 @@ import {
2627
} from "../coin-modules/registry";
2728
import { defaultBridgeExtensions } from "./defaultBridgeExtensions";
2829
import { isZcashShieldedEnabled } from "./zcashRouting";
30+
import { throwError } from "rxjs";
31+
import { catchError } from "rxjs/operators";
32+
import {
33+
buildBroadcastCommonEvent,
34+
buildSignCommonEvent,
35+
buildTransactionFailureEvent,
36+
buildTransactionSuccessEvent,
37+
emitTransactionEvent,
38+
TransactionFlow,
39+
TransactionStage,
40+
} from "@ledgerhq/transaction-observability";
2941

3042
// The family owning a currency's bridge is `currency.family`, except zcash:
3143
// `zcashShielded` routes it to the standalone "zcash" family
@@ -184,7 +196,31 @@ export function getAccountBridge(
184196
return getAccountBridgeByFamily(family, mainAccount.id);
185197
}
186198

187-
async function wrapAccountBridge<T extends TransactionCommon>(
199+
/**
200+
* Attributes a broadcast to a {@link TransactionFlow} and a live-app manifest id from
201+
* `broadcastConfig.source`. `manifestId` is only set for live-app / dApp sources — a
202+
* "coin-module" source's `name` is the host app, not a manifest.
203+
*/
204+
function attributeBroadcastSource(source?: TransactionSource): {
205+
flow: TransactionFlow;
206+
manifestId?: string;
207+
} {
208+
switch (source?.type) {
209+
case "dApp":
210+
return { flow: TransactionFlow.Dapp, manifestId: source.name };
211+
case "live-app":
212+
return { flow: TransactionFlow.WalletApiSignAndBroadcast, manifestId: source.name };
213+
case "coin-module":
214+
return { flow: TransactionFlow.Send };
215+
case "swap":
216+
return { flow: TransactionFlow.Swap };
217+
default:
218+
return { flow: TransactionFlow.Unknown };
219+
}
220+
}
221+
222+
// Exported for unit testing the transaction-observability seam.
223+
export async function wrapAccountBridge<T extends TransactionCommon>(
188224
bridge: AccountBridge<T>,
189225
family: string,
190226
): Promise<ResolvedAccountBridge<T>> {
@@ -204,6 +240,61 @@ async function wrapAccountBridge<T extends TransactionCommon>(
204240
const commonTransactionStatus = await commonGetTransactionStatus(...args);
205241
return mergeResults(blockchainTransactionStatus, commonTransactionStatus);
206242
},
243+
/**
244+
* Transaction observability, sign stage. Only failures: a success here is not an outcome
245+
* the funnel cares about, and abandoning the prompt is an unsubscribe rather than an
246+
* error, so the device-action layer reports that instead.
247+
*
248+
* The rich transaction is available (hence the exact action and the validators) but
249+
* `broadcastConfig` is not, so the originating route is unknown until broadcast.
250+
*/
251+
signOperation: (arg0: Parameters<typeof bridge.signOperation>[0]) =>
252+
bridge.signOperation(arg0).pipe(
253+
catchError(error => {
254+
emitTransactionEvent(
255+
buildTransactionFailureEvent(
256+
buildSignCommonEvent({
257+
account: arg0.account,
258+
mainAccount: arg0.account,
259+
flow: TransactionFlow.Unknown,
260+
transaction: arg0.transaction,
261+
}),
262+
{ stage: TransactionStage.Sign, error },
263+
),
264+
);
265+
return throwError(() => error);
266+
}),
267+
),
268+
/**
269+
* Transaction observability, broadcast stage — where a staking transaction's success is
270+
* actually known. Fully attributed via `broadcastConfig.source`, but the action has to be
271+
* read off the optimistic operation since the transaction is not passed here.
272+
*/
273+
broadcast: async (arg0: Parameters<typeof bridge.broadcast>[0]) => {
274+
const { flow, manifestId } = attributeBroadcastSource(arg0.broadcastConfig?.source);
275+
const common = buildBroadcastCommonEvent({
276+
account: arg0.account,
277+
mainAccount: arg0.account,
278+
flow,
279+
manifestId,
280+
source: arg0.broadcastConfig?.source,
281+
operation: arg0.signedOperation.operation,
282+
});
283+
try {
284+
const operation = await bridge.broadcast(arg0);
285+
emitTransactionEvent(buildTransactionSuccessEvent(common));
286+
return operation;
287+
} catch (error) {
288+
emitTransactionEvent(
289+
buildTransactionFailureEvent(common, {
290+
stage: TransactionStage.Broadcast,
291+
error,
292+
signedOperation: arg0.signedOperation,
293+
}),
294+
);
295+
throw error;
296+
}
297+
},
207298
} as ResolvedAccountBridge<T>;
208299
}
209300

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { getCryptoCurrencyById } from "@domain/entity-currency-crypto";
2+
import { GENERIC_TRANSACTION_MODE } from "./generic-coin-framework/types";
3+
import { MODE_TRAITS, RESOURCE_STAKING_OPERATION_TYPES } from "@ledgerhq/coin-tron/logic/modes";
4+
import {
5+
deriveEarnTransactionType,
6+
deriveFromOperationType,
7+
} from "@ledgerhq/transaction-observability";
8+
9+
/**
10+
* Drift guard for the earn funnel's staking vocabulary.
11+
*
12+
* `@ledgerhq/transaction-observability` classifies transactions by matching family-specific
13+
* wording, but it cannot import the coin layer — live-common depends on it, so the edge would
14+
* be a cycle. Its own tests therefore assert its maps against hardcoded expectations, which
15+
* proves they are self-consistent and nothing more: rename a mode upstream and those tests
16+
* still pass while production silently classifies nothing.
17+
*
18+
* This test closes that hole from the side that *can* see both. It reads the real, runtime
19+
* vocabularies and fails when one gains a value the classifier does not know — so a coin
20+
* module change surfaces here rather than as a hole in the funnel.
21+
*
22+
* Add a family here whenever it exposes its modes as runtime values (most declare them as
23+
* types only, which cannot be enumerated).
24+
*/
25+
26+
// Modes that move funds rather than stake them. Listed explicitly so that adding a mode
27+
// upstream fails the test until someone decides which side it belongs on.
28+
const GENERIC_NON_STAKING = new Set(["send", "changeTrust", "send-legacy", "send-eip1559"]);
29+
const TRON_NON_STAKING = new Set(["send"]);
30+
31+
describe("staking vocabulary drift", () => {
32+
describe("the generic coin framework", () => {
33+
it("classifies every staking mode it defines", () => {
34+
const unclassified = GENERIC_TRANSACTION_MODE.filter(
35+
mode =>
36+
!GENERIC_NON_STAKING.has(mode) &&
37+
deriveEarnTransactionType("newchain", mode) === undefined,
38+
);
39+
expect(unclassified).toEqual([]);
40+
});
41+
42+
it("claims nothing for the modes that are not staking", () => {
43+
for (const mode of GENERIC_NON_STAKING) {
44+
expect(deriveEarnTransactionType("newchain", mode)).toBeUndefined();
45+
}
46+
});
47+
});
48+
49+
// Tron is the family that has already migrated onto the generic framework while keeping its
50+
// own wording, so it is the live example of the drift this guard exists for.
51+
describe("tron", () => {
52+
it("classifies every mode tron supports", () => {
53+
const unclassified = Object.keys(MODE_TRAITS).filter(
54+
mode =>
55+
!TRON_NON_STAKING.has(mode) && deriveEarnTransactionType("tron", mode) === undefined,
56+
);
57+
expect(unclassified).toEqual([]);
58+
});
59+
60+
// Its mode -> OperationType table is exported at runtime, so the broadcast-stage map can be
61+
// checked against the real thing instead of a copy of it.
62+
it("agrees with tron's own mode-to-operation-type table", () => {
63+
const disagreements = [...RESOURCE_STAKING_OPERATION_TYPES.entries()]
64+
.map(([mode, operationType]) => ({
65+
mode,
66+
operationType,
67+
fromMode: deriveEarnTransactionType("tron", mode),
68+
fromOperationType: deriveFromOperationType("tron", operationType),
69+
}))
70+
.filter(row => row.fromMode !== row.fromOperationType);
71+
expect(disagreements).toEqual([]);
72+
});
73+
});
74+
});
75+
76+
/**
77+
* Currency id -> the family it resolves to -> a staking mode that family accepts.
78+
*
79+
* The classifier keys on **family**, which is what lets one cosmos row cover osmo, dydx and the
80+
* rest. That only holds while these ids keep resolving to the family the classifier has a map
81+
* for, and the classifier itself cannot check it: the currency registry lives in
82+
* `@domain/entity-currency-crypto`, which `@ledgerhq/transaction-observability` does not depend
83+
* on. So it is checked here, against the real registry, end to end — id, family, action.
84+
*
85+
* A currency re-homed to another family (as tron was, onto the generic coin framework) fails
86+
* this rather than silently dropping out of the funnel.
87+
*/
88+
const STAKING_CURRENCIES: Array<[string, string, string]> = [
89+
["cardano", "cardano", "delegate"],
90+
["celo", "celo", "vote"],
91+
["cosmos", "cosmos", "delegate"],
92+
["osmo", "cosmos", "delegate"],
93+
["dydx", "cosmos", "delegate"],
94+
["injective", "cosmos", "delegate"],
95+
["mantra", "cosmos", "delegate"],
96+
["zenrock", "cosmos", "delegate"],
97+
["xion", "cosmos", "delegate"],
98+
["axelar", "cosmos", "delegate"],
99+
["quicksilver", "cosmos", "delegate"],
100+
["persistence", "cosmos", "delegate"],
101+
["sei_evm", "evm", "delegate"],
102+
["monad", "evm", "delegate"],
103+
["somnia", "evm", "delegate"],
104+
["zero_gravity", "evm", "delegate"],
105+
["hedera", "hedera", "delegate"],
106+
["elrond", "multiversx", "delegate"],
107+
["near", "near", "stake"],
108+
["polkadot", "polkadot", "bond"],
109+
["solana", "solana", "stake.createAccount"],
110+
["sui", "sui", "delegate"],
111+
["tezos", "tezos", "delegate"],
112+
["tron", "tron", "freeze"],
113+
];
114+
115+
describe("staking currencies still resolve to a family the classifier knows", () => {
116+
it.each(STAKING_CURRENCIES)("%s is a %s currency", (currencyId, family) => {
117+
expect(getCryptoCurrencyById(currencyId).family).toBe(family);
118+
});
119+
120+
it.each(STAKING_CURRENCIES)(
121+
"%s reaches a staking action through its real family",
122+
(currencyId, _family, mode) => {
123+
const family = getCryptoCurrencyById(currencyId).family;
124+
expect(deriveEarnTransactionType(family, mode)).toBeDefined();
125+
},
126+
);
127+
});

0 commit comments

Comments
 (0)