taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit f04c2049796eb7d74106b7c8f720351230694357
parent 9a206aed32d3eae48ecbd6f2863afac70c9e43dc
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 28 Jan 2026 19:28:38 +0100

wallet-core: implement performance stats

Diffstat:
Mpackages/taler-util/src/index.ts | 1+
Mpackages/taler-util/src/notifications.ts | 14++++++++++++++
Mpackages/taler-util/src/observability.ts | 11+++++++++--
Apackages/taler-util/src/performance.ts | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/timer.ts | 2++
Mpackages/taler-util/src/types-taler-wallet.ts | 15+++++++++++++++
Mpackages/taler-wallet-core/src/observable-wrappers.ts | 27+++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/shepherd.ts | 11+++++++++--
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 10++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 32++++++++++++++++++++++++++++++--
10 files changed, 317 insertions(+), 6 deletions(-)

diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -46,6 +46,7 @@ export { } from "./nacl-fast.js"; export * from "./notifications.js"; export * from "./observability.js"; +export * from "./performance.js"; export * from "./operation.js"; export * from "./payto.js"; export * from "./promises.js"; diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -255,6 +255,7 @@ export type ObservabilityEvent = type: ObservabilityEventType.HttpFetchFinishSuccess; url: string; status: number; + durationMs: number; } | { id: string; @@ -262,6 +263,7 @@ export type ObservabilityEvent = type: ObservabilityEventType.HttpFetchFinishError; url: string; error: TalerErrorDetail; + durationMs: number; } | { type: ObservabilityEventType.DbQueryStart; @@ -272,22 +274,30 @@ export type ObservabilityEvent = type: ObservabilityEventType.DbQueryFinishSuccess; name: string; location: string; + durationMs: number; } | { type: ObservabilityEventType.DbQueryFinishError; name: string; location: string; error: TalerErrorDetail; + durationMs: number; } | { type: ObservabilityEventType.RequestStart; + name: string; } | { type: ObservabilityEventType.RequestFinishSuccess; + operation: string; + requestId: string; durationMs: number; } | { type: ObservabilityEventType.RequestFinishError; + operation: string; + requestId: string; + durationMs: number; } | { type: ObservabilityEventType.TaskStart; @@ -312,14 +322,18 @@ export type ObservabilityEvent = | { type: ObservabilityEventType.CryptoFinishSuccess; operation: string; + durationMs: number; } | { type: ObservabilityEventType.CryptoFinishError; operation: string; + durationMs: number; } | { type: ObservabilityEventType.ShepherdTaskResult; + taskId: string; resultType: string; + durationMs: number; } | { type: ObservabilityEventType.Message; diff --git a/packages/taler-util/src/observability.ts b/packages/taler-util/src/observability.ts @@ -23,6 +23,7 @@ import { ObservabilityEvent, ObservabilityEventType } from "./notifications.js"; import { getErrorDetailFromException } from "./errors.js"; import { CancellationToken } from "./CancellationToken.js"; import { AbsoluteTime } from "./time.js"; +import { performanceDelta, performanceNow } from "./timer.js"; /** * Observability sink can be passed into various operations (HTTP requests, DB access) @@ -69,23 +70,29 @@ export class ObservableHttpClientLibrary implements HttpRequestLibrary { const optsWithCancel = opt ?? {}; optsWithCancel.cancellationToken = cancelator.token; + const start = performanceNow(); try { const res = await this.impl.fetch(url, optsWithCancel); - this.oc.observe({ + const end = performanceNow(); + const event: ObservabilityEvent = { id, when: AbsoluteTime.now(), type: ObservabilityEventType.HttpFetchFinishSuccess, url, status: res.status, - }); + durationMs: performanceDelta(start, end), + }; + this.oc.observe(event); return res; } catch (e) { + const end = performanceNow(); this.oc.observe({ id, when: AbsoluteTime.now(), type: ObservabilityEventType.HttpFetchFinishError, url, error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, end), }); throw e; } finally { diff --git a/packages/taler-util/src/performance.ts b/packages/taler-util/src/performance.ts @@ -0,0 +1,200 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Type and schema definitions for performance stats from the wallet to + * clients of the wallet. + */ + +/** + * Imports. + */ +import { assertUnreachable } from "./index.node.js"; +import { + ObservabilityEventType, + ObservabilityEvent, +} from "./notifications.js"; + +export enum PerformanceStatType { + HttpFetch = "http-fetch", + DbQuery = "db-query", + Crypto = "crypto", + WalletRequest = "wallet-request", + WalletTask = "wallet-task", +} + +export type PerformanceStat = + | { + type: PerformanceStatType.HttpFetch; + url: string; + durationMs: number; + } | { + type: PerformanceStatType.DbQuery; + name: string, + location: string, + durationMs: number; + } | { + type: PerformanceStatType.Crypto; + operation: string; + durationMs: number; + } | { + type: PerformanceStatType.WalletRequest; + operation: string; + requestId: string; + durationMs: number; + } | { + type: PerformanceStatType.WalletTask; + taskId: string; + durationMs: number; + }; + +export namespace PerformanceStat { + export function fromNotification( + evt: ObservabilityEvent & { durationMs: number }, + ): PerformanceStat | undefined { + if (evt.type === ObservabilityEventType.HttpFetchFinishSuccess + || evt.type === ObservabilityEventType.HttpFetchFinishError + ) { + return { + type: PerformanceStatType.HttpFetch, + url: evt.url, + durationMs: evt.durationMs, + }; + } else if (evt.type === ObservabilityEventType.DbQueryFinishSuccess + || evt.type === ObservabilityEventType.DbQueryFinishError + ) { + return { + type: PerformanceStatType.DbQuery, + name: evt.name, + location: evt.location, + durationMs: evt.durationMs, + }; + } else if (evt.type === ObservabilityEventType.CryptoFinishSuccess + || evt.type === ObservabilityEventType.CryptoFinishError + ) { + return { + type: PerformanceStatType.Crypto, + operation: evt.operation, + durationMs: evt.durationMs, + }; + } else if (evt.type === ObservabilityEventType.RequestFinishSuccess + || evt.type === ObservabilityEventType.RequestFinishError + ) { + return { + type: PerformanceStatType.WalletRequest, + operation: evt.operation, + requestId: evt.requestId, + durationMs: evt.durationMs, + }; + } else if (evt.type === ObservabilityEventType.ShepherdTaskResult) { + return { + type: PerformanceStatType.WalletTask, + taskId: evt.taskId, + durationMs: evt.durationMs, + }; + } + + return undefined; + } + + export function equals( + a: PerformanceStat, + b: PerformanceStat, + ): boolean { + if (a.type !== b.type) return false; + if (a.type === PerformanceStatType.HttpFetch) { + return a.url === b["url" as keyof typeof b]; + } else if (a.type === PerformanceStatType.DbQuery) { + return a.name === b["name" as keyof typeof b] + && a.location === b["location" as keyof typeof b]; + } else if (a.type === PerformanceStatType.Crypto) { + return a.operation === b["operation" as keyof typeof b]; + } else if (a.type === PerformanceStatType.WalletRequest) { + return a.requestId === b["requestId" as keyof typeof b]; + } else if (a.type === PerformanceStatType.WalletTask) { + return a.taskId === b["taskId" as keyof typeof b]; + } else { + assertUnreachable(a); + } + } +} + +export type PerformanceTable = { + [key in PerformanceStatType]?: PerformanceStat[]; +}; + +export namespace PerformanceTable { + export function insertEvent(tab: PerformanceTable, evt: ObservabilityEvent) { + if ("durationMs" in evt && typeof evt.durationMs === 'number') { + const stat = PerformanceStat.fromNotification(evt); + if (!stat) return; + if (!tab[stat.type]) { + tab[stat.type] = []; + } + + tab[stat.type]?.push(stat); + + // rotate observable stats to keep each event type at 1000 entries + if (tab[stat.type]!!.length > 1000) { + tab[stat.type]?.splice(0, 1); + } + } + } + + export function compactSort( + tab: PerformanceTable, + ): PerformanceTable { + const compacted: PerformanceTable = {}; + for (const key of Object.keys(tab)) { + const groupedIdx: number[] = []; + const subtable = tab[key as keyof typeof tab]; + if (!subtable) continue; + + // compare events against each other, + // except the ones already in a group. + for (let a = 0; a < subtable.length; a++) { + if (groupedIdx.includes(a)) continue; + let eventA = subtable[a]; + let totalDuration = eventA.durationMs; + for (let b = 0; b < subtable.length; b++) { + if (b === a) continue; + if (groupedIdx.includes(b)) continue; + let eventB = subtable[b]; + if (PerformanceStat.equals(eventA, eventB)) { + eventA.durationMs += eventB.durationMs; + groupedIdx.push(b); + } + } + + groupedIdx.push(a); + + if (!compacted[key as keyof typeof tab]) { + compacted[key as keyof typeof tab] = []; + } + + // insert in descending order according to durationMs + let insertIndex = compacted[key as keyof typeof tab]!! + .findIndex((el) => el.durationMs <= eventA.durationMs); + if (insertIndex === -1) { + insertIndex = compacted[key as keyof typeof tab]!!.length; + } + compacted[key as keyof typeof tab]!!.splice(insertIndex, 0, eventA); + } + } + + return compacted; + } +}; diff --git a/packages/taler-util/src/timer.ts b/packages/taler-util/src/timer.ts @@ -97,6 +97,8 @@ export const performanceNow: () => bigint = (() => { return () => BigInt(new Date().getTime()) * BigInt(1000) * BigInt(1000); })(); +export const performanceDelta = (start: bigint, end: bigint) => Number((end - start) / 1000n / 1000n); + const nullTimerHandle = { clear() { // do nothing diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -49,6 +49,7 @@ import { } from "./codec.js"; import { canonicalizeBaseUrl } from "./helpers.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; +import { PerformanceTable } from "./performance.js"; import { QrCodeSpec } from "./qr.js"; import { AgeCommitmentProof } from "./taler-crypto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; @@ -4501,6 +4502,20 @@ export interface GetBankingChoicesForPaytoResponse { choices: BankingChoiceSpec[]; } +export interface GetPerformanceStatsRequest { + limit?: number; +} + +export const codecForGetPerformanceStatsRequest = + (): Codec<GetPerformanceStatsRequest> => + buildCodecForObject<GetPerformanceStatsRequest>() + .property("limit", codecOptional(codecForNumber())) + .build("GetPerformanceStatsRequest"); + +export interface GetPerformanceStatsResponse { + stats: PerformanceTable; +} + export type EmptyObject = Record<string, never>; export const codecForEmptyObject = (): Codec<EmptyObject> => diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts @@ -26,6 +26,8 @@ import { getErrorDetailFromException, ObservabilityContext, ObservabilityEventType, + performanceDelta, + performanceNow, } from "@gnu-taler/taler-util"; import { TaskIdStr } from "./common.js"; import { TalerCryptoInterface } from "./index.js"; @@ -150,20 +152,25 @@ export class ObservableDbAccess<Stores extends StoreMap> implements DbAccess<Sto name: "<unknown>", location, }); + const start = performanceNow(); try { const ret = await this.impl.runAllStoresReadWriteTx(options, txf); + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishSuccess, name: "<unknown>", location, + durationMs: performanceDelta(start, end), }); return ret; } catch (e) { + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishError, name: "<unknown>", location, error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, end), }); throw e; } @@ -183,20 +190,25 @@ export class ObservableDbAccess<Stores extends StoreMap> implements DbAccess<Sto name: options.label ?? "<unknown>", location, }); + const start = performanceNow(); try { const ret = await this.impl.runAllStoresReadOnlyTx(options, txf); + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishSuccess, name: options.label ?? "<unknown>", location, + durationMs: performanceDelta(start, end), }); return ret; } catch (e) { + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishError, name: options.label ?? "<unknown>", location, error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, end), }); throw e; } @@ -215,20 +227,25 @@ export class ObservableDbAccess<Stores extends StoreMap> implements DbAccess<Sto name: opts.label ?? "<unknown>", location, }); + const start = performanceNow(); try { const ret = await this.impl.runReadWriteTx(opts, txf); + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishSuccess, name: opts.label ?? "<unknown>", location, + durationMs: performanceDelta(start, end), }); return ret; } catch (e) { + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishError, name: opts.label ?? "<unknown>", location, error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, end), }); throw e; } @@ -242,6 +259,7 @@ export class ObservableDbAccess<Stores extends StoreMap> implements DbAccess<Sto txf: (tx: DbReadOnlyTransaction<Stores, StoreNameArray>) => Promise<T>, ): Promise<T> { const location = getCallerInfo(); + const start = performanceNow(); try { this.oc.observe({ type: ObservabilityEventType.DbQueryStart, @@ -249,18 +267,22 @@ export class ObservableDbAccess<Stores extends StoreMap> implements DbAccess<Sto location, }); const ret = await this.impl.runReadOnlyTx(opts, txf); + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishSuccess, name: opts.label ?? "<unknown>", location, + durationMs: performanceDelta(start, end), }); return ret; } catch (e) { + const end = performanceNow(); this.oc.observe({ type: ObservabilityEventType.DbQueryFinishError, name: opts.label ?? "<unknown>", location, error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, end), }); throw e; } @@ -280,17 +302,22 @@ export function observeTalerCrypto( type: ObservabilityEventType.CryptoStart, operation: name, }); + const start = performanceNow(); try { const res = await (impl as any)[name](req); + const end = performanceNow(); oc.observe({ type: ObservabilityEventType.CryptoFinishSuccess, operation: name, + durationMs: performanceDelta(start, end), }); return res; } catch (e) { + const end = performanceNow(); oc.observe({ type: ObservabilityEventType.CryptoFinishError, operation: name, + durationMs: performanceDelta(start, end), }); throw e; } diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -27,6 +27,7 @@ import { NotificationType, ObservabilityContext, ObservabilityEventType, + PerformanceTable, TalerErrorDetail, TaskThrottler, TransactionIdStr, @@ -413,9 +414,16 @@ export class TaskSchedulerImpl implements TaskScheduler { return; } logger.trace(`Shepherd for ${taskId} got result ${res.type}`); + const endTime = AbsoluteTime.now(); + const taskDuration = AbsoluteTime.difference(endTime, startTime); + if (taskDuration.d_ms === "forever") { + throw Error("assertion failed"); + } wex.oc.observe({ type: ObservabilityEventType.ShepherdTaskResult, + taskId, resultType: res.type, + durationMs: taskDuration.d_ms, }); switch (res.type) { case TaskRunResultType.Error: { @@ -462,8 +470,6 @@ export class TaskSchedulerImpl implements TaskScheduler { case TaskRunResultType.LongpollReturnedPending: { await storeTaskProgress(this.ws, taskId); // Make sure that we are waiting a bit if long-polling returned too early. - const endTime = AbsoluteTime.now(); - const taskDuration = AbsoluteTime.difference(endTime, startTime); if ( Duration.cmp(taskDuration, Duration.fromSpec({ seconds: 20 })) < 0 ) { @@ -608,6 +614,7 @@ function getWalletExecutionContextForTask( oc = { observe(evt) { if (ws.config.testing.emitObservabilityEvents) { + PerformanceTable.insertEvent(ws.performanceStats, evt); ws.notify({ type: NotificationType.TaskObservabilityEvent, taskId, diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -115,6 +115,8 @@ import { GetMaxDepositAmountResponse, GetMaxPeerPushDebitAmountRequest, GetMaxPeerPushDebitAmountResponse, + GetPerformanceStatsRequest, + GetPerformanceStatsResponse, GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, GetTransactionsV2Request, @@ -320,6 +322,7 @@ export enum WalletApiOperation { GetBankingChoicesForPayto = "getBankingChoicesForPayto", ConvertIbanAccountFieldToPayto = "convertIbanAccountFieldToPayto", ConvertIbanPaytoToAccountField = "convertIbanPaytoToAccountField", + GetPerformanceStats = "getPerformanceStats", // Tokens and token families ListDiscounts = "listDiscounts", @@ -1504,6 +1507,12 @@ export type GetActiveTasksOp = { response: GetActiveTasksResponse; }; +export type GetPerformanceStatsOp = { + op: WalletApiOperation.GetPerformanceStats; + request: GetPerformanceStatsRequest; + response: GetPerformanceStatsResponse; +}; + /** * Dump all coins of the wallet in a simple JSON format. */ @@ -1785,6 +1794,7 @@ export type WalletOperations = { [WalletApiOperation.ConvertIbanAccountFieldToPayto]: ConvertIbanAccountFieldToPaytoOp; [WalletApiOperation.ConvertIbanPaytoToAccountField]: ConvertIbanPaytoToAccountFieldOp; [WalletApiOperation.TestingGetDiagnostics]: TestingGetDiagnosticsOp; + [WalletApiOperation.GetPerformanceStats]: GetPerformanceStatsOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -88,6 +88,8 @@ import { GetDepositWireTypesResponse, GetExchangeTosRequest, GetExchangeTosResult, + GetPerformanceStatsRequest, + GetPerformanceStatsResponse, GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, HintNetworkAvailabilityRequest, @@ -114,6 +116,7 @@ import { OpenedPromise, PartialWalletRunConfig, Paytos, + PerformanceTable, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, @@ -268,6 +271,7 @@ import { parseIban, parsePaytoUri, parseTalerUri, + performanceDelta, performanceNow, safeStringifyException, setDangerousTimetravel, @@ -2057,6 +2061,16 @@ export async function handleGetDiagnostics( }; } +export async function handleGetPerformanceStats( + wex: WalletExecutionContext, + req: GetPerformanceStatsRequest, +): Promise<GetPerformanceStatsResponse> { + return { + // TODO: implement limit of elements on each table + stats: PerformanceTable.compactSort(wex.ws.performanceStats), + }; +} + interface HandlerWithValidator<Tag extends WalletApiOperation> { codec: Codec<WalletCoreRequestType<Tag>>; handler: ( @@ -2078,6 +2092,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForConvertIbanPaytoToAccountFieldRequest(), handler: handleConvertIbanPaytoToAccountField, }, + [WalletApiOperation.GetPerformanceStats]: { + codec: codecForEmptyObject(), + handler: handleGetPerformanceStats, + }, [WalletApiOperation.TestingWaitExchangeState]: { codec: codecForAny(), handler: handleTestingWaitExchangeState, @@ -2831,6 +2849,7 @@ async function dispatchWalletCoreApiRequest( if (ws.initCalled && ws.config.testing.emitObservabilityEvents) { oc = { observe(evt) { + PerformanceTable.insertEvent(ws.performanceStats, evt); ws.notify({ type: NotificationType.RequestObservabilityEvent, operation, @@ -2848,11 +2867,12 @@ async function dispatchWalletCoreApiRequest( wex = getNormalWalletExecutionContext(ws, cts.token, cts, oc); } + const start = performanceNow(); try { - const start = performanceNow(); await ws.ensureWalletDbOpen(); oc.observe({ type: ObservabilityEventType.RequestStart, + name: operation, }); const result = await dispatchRequestInternal( wex, @@ -2862,7 +2882,9 @@ async function dispatchWalletCoreApiRequest( const end = performanceNow(); oc.observe({ type: ObservabilityEventType.RequestFinishSuccess, - durationMs: Number((end - start) / 1000n / 1000n), + operation: operation, + requestId: id, + durationMs: performanceDelta(start, end), }); return { type: "response", @@ -2875,8 +2897,12 @@ async function dispatchWalletCoreApiRequest( logger.info( `finished wallet core request ${operation} with error: ${j2s(err)}`, ); + const end = performanceNow(); oc.observe({ type: ObservabilityEventType.RequestFinishError, + operation: operation, + requestId: id, + durationMs: performanceDelta(start, end), }); return { type: "error", @@ -3174,6 +3200,8 @@ export class InternalWalletState { private loadingDbCond: AsyncCondition = new AsyncCondition(); + performanceStats: PerformanceTable = {}; + public get idbFactory(): BridgeIDBFactory { return this.dbImplementation.idbFactory; }