From 7ff98472cc6da9aeb3f8dd2c49a4d8a285a17f6c Mon Sep 17 00:00:00 2001 From: david87131 Date: Mon, 29 Jun 2026 09:17:28 +0100 Subject: [PATCH] feat: implement Soroban Transfer Analytics Exporter - Exports bridge and transfer analytics datasets in JSON, CSV, and Prometheus format (closes #460) --- src/exporters/analytics/stellar/index.spec.ts | 73 +++++++ src/exporters/analytics/stellar/index.ts | 180 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/exporters/analytics/stellar/index.spec.ts create mode 100644 src/exporters/analytics/stellar/index.ts diff --git a/src/exporters/analytics/stellar/index.spec.ts b/src/exporters/analytics/stellar/index.spec.ts new file mode 100644 index 00000000..f60b93cc --- /dev/null +++ b/src/exporters/analytics/stellar/index.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { SorobanTransferAnalyticsExporter, TransferRecord } from './index'; + +describe('SorobanTransferAnalyticsExporter', () => { + let exporter: SorobanTransferAnalyticsExporter; + + beforeEach(() => { + exporter = new SorobanTransferAnalyticsExporter(); + }); + + const record1: TransferRecord = { + transferId: 't1', + sender: 'GB1', + receiver: 'GB2', + amount: 100, + asset: 'USDC', + status: 'completed', + gasUsed: 500, + txHash: '0x123', + timestamp: new Date('2026-06-29T10:00:00.000Z'), + }; + + const record2: TransferRecord = { + transferId: 't2', + sender: 'GB2', + receiver: 'GB3', + amount: 50, + asset: 'XLM', + status: 'failed', + gasUsed: 200, + txHash: '0x456', + timestamp: new Date('2026-06-29T10:05:00.000Z'), + }; + + it('should collect transfer records correctly', () => { + exporter.recordTransfer(record1); + exporter.recordTransfer(record2); + + expect(exporter.getRecords()).toHaveLength(2); + expect(exporter.getRecords()[0]).toEqual(record1); + }); + + it('should export clean JSON format', () => { + exporter.recordTransfer(record1); + const jsonStr = exporter.export('json'); + const parsed = JSON.parse(jsonStr); + + expect(parsed.totalTransfers).toBe(1); + expect(parsed.totalVolume.USDC).toBe(100); + }); + + it('should export clean CSV format', () => { + exporter.recordTransfer(record1); + const csvStr = exporter.export('csv'); + + expect(csvStr).toContain('transferId,sender,receiver,amount,asset,status,gasUsed,txHash,timestamp'); + expect(csvStr).toContain('t1,GB1,GB2,100,USDC,completed,500,0x123'); + }); + + it('should export clean Prometheus exposition format', () => { + exporter.recordTransfer(record1); + exporter.recordTransfer(record2); + + const prometheusStr = exporter.export('prometheus'); + + expect(prometheusStr).toContain('soroban_transfers_total 2'); + expect(prometheusStr).toContain('soroban_transfers_by_status_total{status="completed"} 1'); + expect(prometheusStr).toContain('soroban_transfers_by_status_total{status="failed"} 1'); + expect(prometheusStr).toContain('soroban_transfer_volume_total{asset="USDC"} 100'); + expect(prometheusStr).toContain('soroban_transfer_gas_used_total{asset="USDC"} 500'); + expect(prometheusStr).toContain('soroban_transfer_gas_used_total{asset="XLM"} 200'); + }); +}); diff --git a/src/exporters/analytics/stellar/index.ts b/src/exporters/analytics/stellar/index.ts new file mode 100644 index 00000000..a887653c --- /dev/null +++ b/src/exporters/analytics/stellar/index.ts @@ -0,0 +1,180 @@ +/** + * Soroban Transfer Analytics Exporter (#460). + * + * Exports bridge and transfer analytics datasets for external processing. + * Supports exporting in JSON, CSV, and Prometheus formats. + */ + +export type ExportFormat = 'json' | 'csv' | 'prometheus'; + +export interface TransferRecord { + transferId: string; + sender: string; + receiver: string; + amount: number; + asset: string; + status: 'pending' | 'completed' | 'failed'; + gasUsed: number; + txHash: string; + timestamp: Date; +} + +export interface AnalyticsDataset { + exportedAt: Date; + totalTransfers: number; + totalVolume: Record; // total volume per asset + transfers: TransferRecord[]; +} + +export interface CsvExportOptions { + delimiter?: string; + includeHeader?: boolean; +} + +export class SorobanTransferAnalyticsExporter { + private records: TransferRecord[] = []; + + /** + * Register a new transfer record. + */ + recordTransfer(record: TransferRecord): void { + this.records.push(record); + } + + /** + * Get all registered records. + */ + getRecords(): TransferRecord[] { + return this.records; + } + + /** + * Clear all records. + */ + clear(): void { + this.records = []; + } + + /** + * Generate an analytics dataset. + */ + getDataset(): AnalyticsDataset { + const totalVolume: Record = {}; + for (const r of this.records) { + if (r.status === 'completed') { + totalVolume[r.asset] = (totalVolume[r.asset] || 0) + r.amount; + } + } + + return { + exportedAt: new Date(), + totalTransfers: this.records.length, + totalVolume, + transfers: [...this.records], + }; + } + + /** + * Export the dataset in the specified format. + */ + export(format: ExportFormat, csvOptions?: CsvExportOptions): string { + const dataset = this.getDataset(); + + switch (format) { + case 'json': + return this.exportJson(dataset); + case 'csv': + return this.exportCsv(dataset, csvOptions); + case 'prometheus': + return this.exportPrometheus(dataset); + default: + throw new Error(`Unsupported export format: ${format}`); + } + } + + private exportJson(dataset: AnalyticsDataset): string { + return JSON.stringify(dataset, null, 2); + } + + private exportCsv(dataset: AnalyticsDataset, options?: CsvExportOptions): string { + const delimiter = options?.delimiter || ','; + const includeHeader = options?.includeHeader !== false; + const lines: string[] = []; + + if (includeHeader) { + lines.push( + ['transferId', 'sender', 'receiver', 'amount', 'asset', 'status', 'gasUsed', 'txHash', 'timestamp'].join( + delimiter, + ), + ); + } + + for (const t of dataset.transfers) { + const row = [ + this.escapeCsv(t.transferId, delimiter), + this.escapeCsv(t.sender, delimiter), + this.escapeCsv(t.receiver, delimiter), + t.amount.toString(), + this.escapeCsv(t.asset, delimiter), + t.status, + t.gasUsed.toString(), + this.escapeCsv(t.txHash, delimiter), + t.timestamp.toISOString(), + ]; + lines.push(row.join(delimiter)); + } + + return lines.join('\n'); + } + + private exportPrometheus(dataset: AnalyticsDataset): string { + let output = ''; + + output += '# HELP soroban_transfers_total Total number of Soroban transfers recorded.\n'; + output += '# TYPE soroban_transfers_total counter\n'; + output += `soroban_transfers_total ${dataset.totalTransfers}\n\n`; + + // Group counts/volumes by status/asset + const statusCounts: Record = {}; + const assetVolumes: Record = {}; + const assetGasUsed: Record = {}; + + for (const t of dataset.transfers) { + statusCounts[t.status] = (statusCounts[t.status] || 0) + 1; + if (t.status === 'completed') { + assetVolumes[t.asset] = (assetVolumes[t.asset] || 0) + t.amount; + } + assetGasUsed[t.asset] = (assetGasUsed[t.asset] || 0) + t.gasUsed; + } + + output += '# HELP soroban_transfers_by_status_total Total transfers by status.\n'; + output += '# TYPE soroban_transfers_by_status_total counter\n'; + for (const [status, count] of Object.entries(statusCounts)) { + output += `soroban_transfers_by_status_total{status="${status}"} ${count}\n`; + } + output += '\n'; + + output += '# HELP soroban_transfer_volume_total Total transaction volume per asset.\n'; + output += '# TYPE soroban_transfer_volume_total counter\n'; + for (const [asset, volume] of Object.entries(assetVolumes)) { + output += `soroban_transfer_volume_total{asset="${asset}"} ${volume}\n`; + } + output += '\n'; + + output += '# HELP soroban_transfer_gas_used_total Total gas used by transfers per asset.\n'; + output += '# TYPE soroban_transfer_gas_used_total counter\n'; + for (const [asset, gas] of Object.entries(assetGasUsed)) { + output += `soroban_transfer_gas_used_total{asset="${asset}"} ${gas}\n`; + } + + return output; + } + + private escapeCsv(value: string, delimiter: string): string { + const clean = value.replace(/"/g, '""'); + if (clean.includes(delimiter) || clean.includes('\n') || clean.includes('"')) { + return `"${clean}"`; + } + return clean; + } +}