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:
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;
}