@ledgerhq/coin-modules-monitoring
Version:
Push monitoring metrics to Datadog
270 lines (233 loc) • 8.6 kB
text/typescript
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;
}