UNPKG

@ledgerhq/coin-modules-monitoring

Version:
270 lines (233 loc) 8.6 kB
import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import type { Account } from "@ledgerhq/types-live"; import { getAccountBridgeByFamily } from "@ledgerhq/live-common/bridge/impl"; import { setupCalClientStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers"; import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; import { firstValueFrom, reduce } from "rxjs"; import { decodeAccountId, encodeAccountId, } from "@ledgerhq/ledger-wallet-framework/account/accountId"; import { LiveConfig } from "@ledgerhq/live-config/LiveConfig"; import { liveConfig } from "@ledgerhq/live-common/config/sharedConfig"; import { registerAllCoins } from "@ledgerhq/live-common/coin-modules/load-all-coins"; import currencies, { AccountInfo, AccountType } from "./currencies"; import { LogEntry, submitLogs } from "./datadog"; import { Dist, measureCalls } from "./measure"; interface RunResult { entries: LogEntry[]; failed: boolean; } function formatDist(dist: Dist, unit = "", decimals = 3) { const round = (v: number) => Math.round(v * Math.pow(10, decimals)) / Math.pow(10, decimals); return `min=${round(dist.min)}${unit}, average=${round(dist.average)}${unit}, max=${round(dist.max)}${unit}, p90=${round(dist.p90)}${unit}, p99=${round(dist.p99)}${unit}`; } function formatCalls(total: number, calls: Record<string, number>) { return ( `Total: ${total} ` + Object.entries(calls) .map(([domain, nbOfCalls]) => `- ${domain}: ${nbOfCalls}`) .join(" ") ); } function formatDuration(ms: number): string { if (ms < 1000) return `${ms.toFixed(0)} ms`; const totalSeconds = Math.floor(ms / 1000); const remainingMs = ms % 1000; const seconds = totalSeconds % 60; const minutes = Math.floor(totalSeconds / 60) % 60; const hours = Math.floor(totalSeconds / 3600); const parts: string[] = []; if (hours) parts.push(`${hours}h`); if (minutes) parts.push(`${minutes}m`); if (seconds || (!hours && !minutes)) { if (remainingMs > 0 && ms < 60_000) { parts.push(`${seconds}.${remainingMs.toString().padStart(3, "0")}s`); } else { parts.push(`${seconds}s`); } } return parts.join(" "); } function hasCauseProperty(object: unknown): object is { cause: object } { return ( typeof object === "object" && object !== null && "cause" in object && typeof object.cause === "object" && object.cause !== null ); } function objectWithoutKeys(obj: object, excludeKeys: string[]): Record<string, unknown> { const set = new Set(excludeKeys); return Object.fromEntries(Object.entries(obj).filter(([key]) => !set.has(key))); } function logErrorProperties(error: Error): void { const errorProperties = objectWithoutKeys(error, ["message", "stack", "cause"]); console.error(` └─ Error properties:`, errorProperties); if (hasCauseProperty(error)) { const causeProperties = objectWithoutKeys(error.cause, ["stack", "cause"]); console.error(` └─ Error cause properties:`, causeProperties); } } function prettyLog( i: number, nbOfAccounts: number, scanDuration: number, syncDuration: number, scanCalls: number, scanRoutes: Record<string, number>, scanCpu: Dist, scanMem: Dist, syncCalls: number, syncRoutes: Record<string, number>, syncCpu: Dist, syncMem: Dist, ) { const totalDuration = scanDuration + syncDuration; console.log(`\n[${i} / ${nbOfAccounts}] ✅ Completed in ${formatDuration(totalDuration)}`); console.log(` ┌─ 🔎 Scan`); console.log(` │ • Calls: ${formatCalls(scanCalls, scanRoutes)}`); console.log(` │ • CPU : ${formatDist(scanCpu, "%")}`); console.log(` │ • MEM : ${formatDist(scanMem, " MB")}`); console.log(` └─ 🔄 Sync`); console.log(` • Calls: ${formatCalls(syncCalls, syncRoutes)}`); console.log(` • CPU : ${formatDist(syncCpu, "%")}`); console.log(` • MEM : ${formatDist(syncMem, " MB")}\n`); } function toEmptyAccount(currency: CryptoCurrency, info: AccountInfo): Account { const id = encodeAccountId({ type: "js", version: "2", currencyId: currency.id, xpubOrAddress: info.xpub ?? info.address, derivationMode: info.derivationMode ?? "", }); return { id, currency, freshAddress: info.address, xpub: info.xpub, derivationMode: info.derivationMode ?? "", operations: [], pendingOperations: [], balanceHistoryCache: {}, blockHeight: 0, } as unknown as Account; } function getSync(currency: CryptoCurrency) { const bridge = getAccountBridgeByFamily(currency.family); return async (account: Account) => firstValueFrom( (await bridge) .sync(account, { paginationConfig: {} }) .pipe(reduce((a, f: (arg0: Account) => Account) => f(a), account)), ); } export default async function (currencyIds: string[], accountTypes: AccountType[]) { registerAllCoins(); LiveConfig.setConfig(liveConfig); // Setup CAL client store for monitoring (automatically set as global store) setupCalClientStore(); const result: RunResult = { entries: [], failed: false, }; const nbOfAccounts = currencyIds .flatMap(currencyId => Object.keys(currencies[currencyId].accounts)) .filter(currencyAccountType => accountTypes.some(type => type === currencyAccountType)).length; let i = 0; console.log(`Monitoring ${nbOfAccounts} account(s) within ${currencyIds.join(", ")}`); for (const currencyId of currencyIds) { const monitored = currencies[currencyId]; const currency = getCryptoCurrencyById(currencyId); const sync = getSync(currency); for (const accountType of accountTypes) { const info = monitored.accounts[accountType]; if (!info) { console.log(`\nSkipping currency = "${currencyId}", no account = "${accountType}"`); continue; } console.log( `\n[${++i} / ${nbOfAccounts}] Start (currency = "${currencyId}" account = "${accountType}")`, ); try { const startScan = Date.now(); const { result: initialAccount, totalCalls: scanCalls, callsByDomain: scanRoutes, cpu: scanCpu, memory: scanMem, } = await measureCalls(() => sync(toEmptyAccount(currency, info))); const endScan = Date.now(); const startSync = Date.now(); const { totalCalls: syncCalls, callsByDomain: syncRoutes, cpu: syncCpu, memory: syncMem, } = await measureCalls(() => sync(initialAccount)); const endSync = Date.now(); const scanDuration = endScan - startScan; const syncDuration = endSync - startSync; const { xpubOrAddress } = decodeAccountId(initialAccount.id); prettyLog( i, nbOfAccounts, scanDuration, syncDuration, scanCalls, scanRoutes, scanCpu, scanMem, syncCalls, syncRoutes, syncCpu, syncMem, ); result.entries.push( { duration: scanDuration, currencyName: currency.id, coinModuleName: currency.family, operationType: "scan", accountType: accountType as AccountType, transactions: initialAccount.operationsCount, accountAddressOrXpub: xpubOrAddress, totalNetworkCalls: scanCalls, networkCallsByDomain: scanRoutes, cpu: scanCpu, memory: scanMem, }, { duration: syncDuration, currencyName: currency.id, coinModuleName: currency.family, operationType: "sync", accountType: accountType as AccountType, transactions: initialAccount.operationsCount, accountAddressOrXpub: xpubOrAddress, totalNetworkCalls: syncCalls, networkCallsByDomain: syncRoutes, cpu: syncCpu, memory: syncMem, }, ); } catch (err) { console.error( `Skipping failing run. Error: ${err instanceof Error ? err.stack ?? err.message : err}`, ); // We may miss some parameters added to the error on runtime // We display only the message on the top for convenience and better readability // So we added this log to have the full object if (err instanceof Error) { logErrorProperties(err); } result.failed = true; } } } await submitLogs(result.entries); return result; }