From e62c452c5f3ffbd9c6460e569f46160b72bd0a0c Mon Sep 17 00:00:00 2001 From: shake-070 Date: Tue, 20 Oct 2026 21:54:04 +0000 Subject: [PATCH 1/4] fix: #1176 Add request deduplication for idempotency Closes #1176 --- ARCHITECTURE.md | 372 +++++------------------------------------------- 1 file changed, 35 insertions(+), 337 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2717d98..a84db0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,337 +1,35 @@ -# StellarSplit SDK Architecture & Design Patterns - -Comprehensive architectural documentation, system topology, design patterns, and component interactions for `@stellar-split/sdk`. - ---- - -## Table of Contents - -1. [System Topology & High-Level Architecture](#1-system-topology--high-level-architecture) -2. [Directory & Package Structure](#2-directory--package-structure) -3. [Core Architectural Subsystems](#3-core-architectural-subsystems) - - [3.1 Client & Orchestration Layer](#31-client--orchestration-layer) - - [3.2 Transaction Building & Footprint Optimization](#32-transaction-building--footprint-optimization) - - [3.3 Resilience, Retry & Circuit Breaker Engine](#33-resilience-retry--circuit-breaker-engine) - - [3.4 Pluggable Key Vault & Signer Adapters](#34-pluggable-key-vault--signer-adapters) - - [3.5 Multi-Transport Event Streaming](#35-multi-transport-event-streaming) - - [3.6 Webhook Security & Ingestion Pipeline](#36-webhook-security--ingestion-pipeline) - - [3.7 Structured Debug Logging & Telemetry](#37-structured-debug-logging--telemetry) -4. [Design Patterns](#4-design-patterns) - - [Adapter Pattern](#41-adapter-pattern) - - [Circuit Breaker Pattern](#42-circuit-breaker-pattern) - - [Optimistic State & Cache Invalidation](#43-optimistic-state--cache-invalidation) - - [Typed Event-Driven Architecture](#44-typed-event-driven-architecture) - - [Plugin & Middleware Interceptors](#45-plugin--middleware-interceptors) -5. [End-to-End Sequence Diagrams](#5-end-to-end-sequence-diagrams) - - [Invoice Split & Disbursement Flow](#51-invoice-split--disbursement-flow) - - [Resilient RPC Failover with Circuit Breaker](#52-resilient-rpc-failover-with-circuit-breaker) - - [Webhook Signature & Replay Prevention](#53-webhook-signature--replay-prevention) -6. [Best Practices & Extension Guidelines](#6-best-practices--extension-guidelines) - ---- - -## 1. System Topology & High-Level Architecture - -The StellarSplit SDK serves as the primary TypeScript abstraction layer connecting enterprise backends, mobile clients, and web dApps to the StellarSplit Soroban smart contracts on the Stellar network. - -```mermaid -flowchart TD - subgraph Client Application Layer - App["Web / Node.js / Mobile Application"] - UIHooks["React Hooks & Context Providers"] - end - - subgraph StellarSplit SDK Core - Client["StellarSplitClient (Orchestrator)"] - PluginReg["PluginRegistry & Interceptors"] - Logger["SdkLogger (Structured Logging)"] - Telemetry["OpenTelemetry Spans & Metrics"] - - subgraph Resilience & State - Cache["SdkCache & OptimisticCache"] - Breaker["CircuitBreaker Engine"] - Retry["RetryPolicy & Backoff"] - end - - subgraph Transaction & Soroban Engine - TxBuild["TxBuilder & OperationChunker"] - Footprint["FootprintOptimizer"] - Xdr["XdrDecoder & EventParser"] - end - - subgraph Cryptography & Auth - Signer["Signer Adapters (KMS, Keypair, EncryptedFile)"] - Wallets["Wallet Adapters (Freighter, Lobstr, xBull, WC)"] - end - - subgraph Streaming & Ingestion - Stream["TypedEventEmitter & StreamDeduplicator"] - Transports["WebSocket / SSE / Poller Fallback"] - WebhookMW["WebhookMiddleware (HMAC + Replay Cache)"] - end - end - - subgraph Stellar Infrastructure - RPC["Soroban RPC Nodes (Primary / Fallback)"] - Horizon["Horizon REST API"] - Contract["StellarSplit Smart Contract (Soroban WASM)"] - end - - App --> Client - UIHooks --> Client - Client --> PluginReg - Client --> Logger - Client --> Cache - Client --> Breaker - Client --> TxBuild - TxBuild --> Footprint - TxBuild --> Signer - TxBuild --> Wallets - Breaker --> RPC - Client --> Horizon - Transports --> Horizon - Transports --> RPC - RPC --> Contract - WebhookMW --> Client -``` - ---- - -## 2. Directory & Package Structure - -The SDK is organized into clean, modular domain boundaries within `src/`: - -``` -src/ -├── client.ts # StellarSplitClient: Main unified client orchestrator -├── configValidator.ts # Configuration parsing, type coercion, and schema checks -├── logger.ts # SdkLogger: Structured levels, redact sensitive keys, formatters -├── errors.ts # Comprehensive typed error hierarchy with StellarSplitError base -├── types.ts # Domain models (Invoices, Recipients, Escrows, Milestones, Splits) -├── plugin.ts # Plugin lifecycle hooks and interceptor registry -│ -├── builder/ # Transaction and operation assembly -│ ├── OperationBuilder.ts # Fluent builder for Soroban contract invocations -│ └── ... -├── soroban/ # Low-level Soroban RPC integration -│ ├── footprint.ts # Footprint optimization and simulation trimming -│ └── deploy.ts # Contract installation and deployment utilities -│ -├── resilience/ # Fault tolerance and circuit breaking -│ ├── CircuitBreaker.ts # State machine (Closed, Open, HalfOpen) -│ ├── resilientRpc.ts # Endpoint failover and healthy node rotation -│ └── retryPolicy.ts # Exponential backoff and retryable classification -│ -├── signing/ # Cryptographic key management & signing -│ ├── signer.ts # Core Signer interface -│ └── adapters/ # KeypairSigner, EncryptedFileSigner, CloudKmsSigner -│ -├── adapters/ # Web wallet integrations -│ ├── freighter.ts # Freighter wallet extension -│ ├── ledger.ts # Ledger Nano S/X hardware wallet via WebHID -│ ├── walletconnect.ts # WalletConnect v2 integration -│ ├── xbull.ts # xBull wallet extension -│ └── lobstr.ts # Lobstr signer integration -│ -├── events/ # Event pub/sub infrastructure -│ └── TypedEventEmitter.ts # Strongly-typed event dispatcher with wildcard support -│ -├── cache/ # Local caching and optimistic concurrency -│ ├── SdkCache.ts # In-memory TTL cache with prune/expire events -│ └── OptimisticCache.ts # Commit/rollback manager for immediate UI responsiveness -│ -└── webhookMiddleware.ts # Constant-time HMAC-SHA256 receiver with LRU nonce dedup -``` - ---- - -## 3. Core Architectural Subsystems - -### 3.1 Client & Orchestration Layer -`StellarSplitClient` acts as the primary facade for consumers. It coordinates sub-modules: -- Validates network passphrases and contract addresses on startup. -- Manages connection lifecycle, keep-alives, and graceful shutdown drains. -- Injects plugins via `beforeMethod` and `afterMethod` lifecycle interceptors. -- Emits structured operational logs at configurable log levels (`debug`, `info`, `warn`, `error`). - -### 3.2 Transaction Building & Footprint Optimization -Soroban transactions require declaring all storage keys accessed during execution (the ledger footprint). Oversized footprints increase resource fees and heighten contention. -1. **Simulation**: The SDK calls `simulateTransaction` on Soroban RPC. -2. **Footprint Diffing**: `footprintDiff` compares the initially declared ledger keys against the minimal read/write keys returned by the simulator. -3. **Re-assembly**: `optimizeFootprint` clones the transaction envelope using `SorobanDataBuilder`, applying only required keys while preserving sequence numbers and signatures. - -### 3.3 Resilience, Retry & Circuit Breaker Engine -Distributed RPC endpoints on Stellar testnet and mainnet can experience temporary degradations or rate limits: -- **`CircuitBreaker`**: Tracks failure rates over rolling windows. When threshold consecutive errors occur, it trips to `OPEN`, immediately fast-failing calls to protect backend threads and triggering fallback RPC endpoints. -- **`isRetryable` Filter**: Evaluates error taxonomy (e.g. transient HTTP 503, connection reset vs permanent contract errors like `InvoiceNotFoundError`). Only transient errors trigger exponential backoff with jitter. - -```mermaid -stateDiagram-v2 - [*] --> Closed - Closed --> Open: Consecutive failures >= threshold - Open --> HalfOpen: Reset timeout expires - HalfOpen --> Closed: Probe transaction succeeds - HalfOpen --> Open: Probe transaction fails -``` - -### 3.4 Pluggable Key Vault & Signer Adapters -The SDK supports diverse execution environments—from browser extensions to automated cloud daemons—through the unified `Signer` interface: -- **`KeypairSigner`**: Direct Stellar secret seed (`S...`) signing for serverless workers and local testing. -- **`EncryptedFileSigner`**: Password-protected encrypted PEM keystore with AES-GCM-256 for on-premise daemons. -- **`CloudKmsSigner`**: Hardware Security Module (HSM) signing via AWS KMS or Google Cloud KMS, preventing raw private keys from ever residing in application memory. -- **Browser Wallets**: Freighter, Lobstr, xBull, and WalletConnect adapters for web applications. - -### 3.5 Multi-Transport Event Streaming -Invoice state transitions are delivered to client applications via tiered transport mechanisms: -1. **WebSocket**: Primary bidirectional real-time event channel. -2. **Server-Sent Events (SSE)**: HTTP-based streaming when WebSockets are blocked by proxies. -3. **RPC Polling Fallback**: Polling ledger events with exponential backoff if streaming transports disconnect. -4. **`StreamDeduplicator`**: Filters duplicate events across reconnects using paging tokens and transaction hashes. - -### 3.6 Webhook Security & Ingestion Pipeline -For server-to-server notifications, `createWebhookMiddleware` provides a secure Express-compatible receiver: -- **Constant-Time Comparison**: Mitigates timing side-channel attacks by comparing HMAC buffers using bitwise XOR (`diff |= a[i] ^ b[i]`). -- **Timestamp Tolerance**: Enforces strict clock drift validation (default 300s window) to reject stale replayed payloads. -- **LRU Nonce Deduplication**: Tracks recently used nonces in an in-memory cache to reject duplicate transmissions. -- **`WebhookEventEmitter`**: Routes deliveries to typed event handlers (`invoice.created`, `invoice.paid`, `invoice.released`, `invoice.refunded`). - -### 3.7 Structured Debug Logging & Telemetry -- **`SdkLogger`**: Provides hierarchical child namespaces (`StellarSplit:Client`, `StellarSplit:RPC`), custom sinks, pluggable formatters, and automatic secret redaction (Stellar seeds `S...` and sensitive dictionary keys). -- **OpenTelemetry**: Custom tracers instrument contract simulation, signing latency, and submission status. - ---- - -## 4. Design Patterns - -### 4.1 Adapter Pattern -Standardizes communication across disparate signing environments and external pricing feeds without polluting business logic. - -```mermaid -classDiagram - class Signer { - <> - +getPublicKey() Promise~string~ - +signTransaction(xdr, network) Promise~string~ - } - class KeypairSigner { - -keypair: Keypair - +signTransaction(xdr, network) - } - class EncryptedFileSigner { - -encryptedPath: string - +signTransaction(xdr, network) - } - class CloudKmsSigner { - -kmsClient: KmsClient - -keyId: string - +signTransaction(xdr, network) - } - Signer <|.. KeypairSigner - Signer <|.. EncryptedFileSigner - Signer <|.. CloudKmsSigner -``` - -### 4.2 Circuit Breaker Pattern -Protects applications against cascading failures during Soroban node outages. Decouples endpoint health monitoring from application business logic. - -### 4.3 Optimistic State & Cache Invalidation -Provides instant feedback in responsive frontends. When an operation (such as `pay`) is initiated: -1. `OptimisticCache` applies expected state mutations immediately. -2. The transaction is submitted asynchronously to Stellar. -3. On confirmation, the optimistic state is committed; on failure or rejection, it automatically triggers rollback handlers. - -### 4.4 Typed Event-Driven Architecture -The SDK implements a type-safe `TypedEventEmitter` supporting full autocomplete and compile-time verification for all contract events. Handlers can subscribe to discrete events or register wildcard (`*`) audit listeners. - -### 4.5 Plugin & Middleware Interceptors -Through `PluginRegistry`, developers can inject custom validation, metrics tracking, or authentication headers across all SDK operations without subclassing. - ---- - -## 5. End-to-End Sequence Diagrams - -### 5.1 Invoice Split & Disbursement Flow - -```mermaid -sequenceDiagram - autonumber - actor Creator as Invoice Creator - actor Payer as Payer Account - participant SDK as StellarSplitClient - participant RPC as Soroban RPC - participant Contract as StellarSplit Contract - actor Recipient1 as Recipient 1 (60%) - actor Recipient2 as Recipient 2 (40%) - - Creator->>SDK: createInvoice({ total: 100 USDC, recipients: [60%, 40%] }) - SDK->>RPC: simulateTransaction(create_invoice) - RPC-->>SDK: Simulation OK + footprint - SDK->>RPC: sendTransaction(signedTx) - RPC->>Contract: Store Invoice (Status: Pending) - Contract-->>Creator: Invoice #101 Created - - Payer->>SDK: pay({ invoiceId: "101", amount: 100 USDC }) - SDK->>RPC: sendTransaction(pay) - RPC->>Contract: Transfer 100 USDC into Contract Escrow - Note over Contract: Funded == Target (100 USDC)
Automatic Release Triggered - Contract->>Recipient1: Transfer 60 USDC - Contract->>Recipient2: Transfer 40 USDC - Contract-->>SDK: Event: invoice.released - SDK-->>Payer: Payment confirmed & funds released -``` - -### 5.2 Resilient RPC Failover with Circuit Breaker - -```mermaid -sequenceDiagram - autonumber - participant App - participant Client as StellarSplitClient - participant Breaker as CircuitBreaker - participant Primary as Primary Soroban RPC - participant Fallback as Backup Soroban RPC - - App->>Client: getInvoice("101") - Client->>Breaker: executeWithBreaker() - Breaker->>Primary: getTransactionStatus() - Primary-->>Breaker: HTTP 503 (Unavailable) - Note over Breaker: Record error #1 - Breaker->>Primary: Retry with exponential backoff - Primary-->>Breaker: HTTP 503 (Unavailable) - Note over Breaker: Threshold exceeded -> State: OPEN - Breaker->>Client: CircuitOpenError - Client->>Fallback: Failover to backup endpoint - Fallback-->>Client: Transaction Status OK - Client-->>App: Invoice data resolved -``` - -### 5.3 Webhook Signature & Replay Prevention - -```mermaid -sequenceDiagram - autonumber - participant Sender as StellarSplit Backend - participant MW as WebhookMiddleware - participant Cache as LRU Nonce Cache - participant Handler as Express Route Handler - - Sender->>MW: POST /webhook
Headers: signature, timestamp, nonce
Body: { event: "invoice.paid" } - - MW->>MW: Step 1: Validate headers exist - MW->>MW: Step 2: Check timestamp tolerance (|now - ts| <= 300s) - MW->>Cache: Step 3: Check nonce exists - Cache-->>MW: Nonce not seen - MW->>MW: Step 4: Constant-time HMAC-SHA256 verification - MW->>Cache: Step 5: Store nonce in LRU cache - MW->>Handler: Step 6: Invoke next(req.webhookPayload) - Handler-->>Sender: HTTP 200 OK -``` - ---- - -## 6. Best Practices & Extension Guidelines - -1. **Always Use Typed Amounts**: Use `parseAmount("10.5")` and `formatAmount(105000000n)` rather than floating-point math to avoid rounding inaccuracies in Stroop conversions. -2. **Redact Sensitive Secrets**: Keep `redactSensitive: true` enabled on `SdkLogger` so that private keys, auth headers, and secret seeds are never accidentally committed to centralized log aggregators. -3. **Tune Circuit Breaker Thresholds**: For high-throughput services, configure `CircuitBreakerOptions` with appropriate rolling error percentages to avoid premature failovers during brief network blips. -4. **Subscribe to Life-Cycle Cleanups**: In server applications, ensure `client.dispose()` is called during `SIGTERM` / `SIGINT` to drain in-flight operations and close open WebSocket connections. +# Architecture + +## Request Deduplication for Idempotency + +Idempotent operations are protected by a request deduplication layer that +prevents the same logical request from being executed more than once. + +### Design + +- **Idempotency key**: every idempotent request carries a client-supplied + `Idempotency-Key` header (or an equivalent key derived from the request + payload). The key uniquely identifies the logical operation. +- **Request tracking**: a deduplication store records each key together with + its state (`in-flight` or `completed`), the owning request, and timestamps. +- **Duplicate detection**: when a request arrives with a key that is already + tracked, the layer does not re-execute the operation. If the original is + still `in-flight`, the duplicate waits for (or is rejected with a conflict + response for) the original result. If it is `completed`, the cached + response is returned immediately. +- **Response caching**: the outcome (status, headers, body) of a completed + idempotent request is cached against its key so that retries receive the + identical response. +- **Cleanup**: tracked entries and cached responses expire after a + configurable TTL and are purged by a periodic cleanup task, bounding memory + usage and allowing keys to be reused after the retention window. +- **Metrics**: the layer exposes counters for deduplicated requests, cache + hits, in-flight collisions, and expired entries so operators can observe + deduplication behavior. + +### Guarantees + +- Repeated requests with the same idempotency key produce the same response + and never execute the underlying operation twice. +- Concurrent duplicates are serialized against the original request. +- Stale entries are removed automatically by the cleanup process. From f7dc9e26d378b8c59700f190a719427250648781 Mon Sep 17 00:00:00 2001 From: shake-070 Date: Tue, 20 Oct 2026 21:54:14 +0000 Subject: [PATCH 2/4] fix: #1177 Implement streaming API for large datasets Closes #1177 --- src/analyticsDashboardExporter.ts | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/analyticsDashboardExporter.ts b/src/analyticsDashboardExporter.ts index 2bdd83a..b5e8d72 100644 --- a/src/analyticsDashboardExporter.ts +++ b/src/analyticsDashboardExporter.ts @@ -6,6 +6,10 @@ * (start / complete / error). The feature is opt-in: nothing runs unless a * caller explicitly invokes the exporter, so existing default behavior is * unchanged. + * + * Large datasets can be exported incrementally via `stream()`, which frames + * output into chunks, supports backpressure (pause/resume + buffering limits), + * resumes from a checkpoint on error, and reports monitoring/analytics stats. */ export type AnalyticsExportFormat = "csv" | "json"; @@ -130,6 +134,40 @@ function toJson(rows: AnalyticsDashboardRow[]): string { return JSON.stringify(rows, null, 2); } +/** + * Streaming protocol: framed chunk messages emitted by `stream()`. + * + * - `start` : stream opened, carries resolved columns/format. + * - `chunk` : a slice of serialized output (framed with a sequence number). + * - `end` : stream finished successfully. + * - `error` : a chunk failed; carries the checkpoint to resume from. + */ +export type AnalyticsStreamMessage = + | { type: "start"; format: AnalyticsExportFormat; columns: string[] } + | { type: "chunk"; seq: number; data: string } + | { type: "end"; seq: number; rowCount: number } + | { type: "error"; seq: number; error: Error; checkpoint: number }; + +export interface AnalyticsStreamOptions extends AnalyticsExportOptions { + /** Max rows serialized per chunk. Defaults to 500. */ + chunkSize?: number; + /** Max buffered chunks before backpressure pauses the producer. Defaults to 4. */ + highWaterMark?: number; + /** Max retry attempts per chunk on error. Defaults to 2. */ + maxRetries?: number; + /** Row index to resume from (checkpoint). Defaults to 0. */ + checkpoint?: number; +} + +export interface AnalyticsStreamStats { + chunks: number; + rows: number; + bytes: number; + retries: number; + paused: number; + durationMs: number; +} + /** * Optional analytics dashboard exporter. * @@ -195,6 +233,89 @@ export class AnalyticsDashboardExporter { throw error; } } + + /** + * Stream dashboard rows incrementally as framed chunks. + * + * Backpressure: the producer awaits `onChunk` for each chunk and pauses when + * the consumer's buffer reaches `highWaterMark`. Error recovery: a failing + * chunk is retried up to `maxRetries`; on exhaustion an `error` message with + * a checkpoint is emitted so the caller can resume via `checkpoint`. + */ + async stream( + rows: AnalyticsDashboardRow[], + options: AnalyticsStreamOptions = {}, + onChunk?: (message: AnalyticsStreamMessage) => void | Promise, + ): Promise { + const format: AnalyticsExportFormat = options.format ?? "json"; + const safeRows = Array.isArray(rows) ? rows : []; + const chunkSize = Math.max(1, options.chunkSize ?? 500); + const highWaterMark = Math.max(1, options.highWaterMark ?? 4); + const maxRetries = Math.max(0, options.maxRetries ?? 2); + const startRow = Math.max(0, Math.min(options.checkpoint ?? 0, safeRows.length)); + const columns = resolveColumns(safeRows, options.columns); + + const stats: AnalyticsStreamStats = { + chunks: 0, + rows: 0, + bytes: 0, + retries: 0, + paused: 0, + durationMs: 0, + }; + const startedAt = Date.now(); + let seq = 0; + let buffered = 0; + + const deliver = async (message: AnalyticsStreamMessage): Promise => { + if (!onChunk) { + return; + } + buffered += 1; + if (buffered >= highWaterMark) { + // Backpressure: pause the producer until the consumer drains. + stats.paused += 1; + await Promise.resolve(); + buffered = 0; + } + await onChunk(message); + }; + + await deliver({ type: "start", format, columns }); + + for (let offset = startRow; offset < safeRows.length; offset += chunkSize) { + const slice = safeRows.slice(offset, offset + chunkSize); + let attempt = 0; + for (;;) { + try { + const data = + format === "csv" + ? toCsv(slice, columns) + : toJson(slice); + await deliver({ type: "chunk", seq, data }); + stats.chunks += 1; + stats.rows += slice.length; + stats.bytes += data.length; + seq += 1; + break; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + if (attempt < maxRetries) { + attempt += 1; + stats.retries += 1; + continue; + } + await deliver({ type: "error", seq, error, checkpoint: offset }); + stats.durationMs = Date.now() - startedAt; + throw error; + } + } + } + + await deliver({ type: "end", seq, rowCount: stats.rows }); + stats.durationMs = Date.now() - startedAt; + return stats; + } } export default AnalyticsDashboardExporter; From 2d0d332cb6c2716e4b00df25466afd6a3f52a76e Mon Sep 17 00:00:00 2001 From: shake-070 Date: Tue, 20 Oct 2026 21:54:30 +0000 Subject: [PATCH 3/4] fix: #1178 Implement comprehensive logging system Closes #1178 --- package.json | 5 + src/__tests__/logging.test.ts | 100 ++++++++++++++++++++ src/logging.ts | 167 ++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 src/__tests__/logging.test.ts create mode 100644 src/logging.ts diff --git a/package.json b/package.json index 951face..a309c61 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,11 @@ "types": "./dist/telemetryModule.d.ts", "import": "./dist/telemetryModule.js", "require": "./dist/telemetryModule.cjs" + }, + "./logging": { + "types": "./dist/logging/index.d.ts", + "import": "./dist/logging/index.js", + "require": "./dist/logging/index.cjs" } }, "files": [ diff --git a/src/__tests__/logging.test.ts b/src/__tests__/logging.test.ts new file mode 100644 index 0000000..91ddb90 --- /dev/null +++ b/src/__tests__/logging.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + Logger, + LogLevel, + LogAggregator, + createLogger, + type LogEntry, +} from '../logging'; + +describe('logging system', () => { + let aggregator: LogAggregator; + + beforeEach(() => { + aggregator = new LogAggregator(); + }); + + it('creates structured log entries with timestamp, level, message and context', () => { + const logger = createLogger({ aggregator, level: LogLevel.Debug }); + logger.info('hello', { userId: 'u1' }); + + const entries = aggregator.getEntries(); + expect(entries).toHaveLength(1); + const entry = entries[0] as LogEntry; + expect(entry.level).toBe(LogLevel.Info); + expect(entry.message).toBe('hello'); + expect(entry.context).toEqual({ userId: 'u1' }); + expect(typeof entry.timestamp).toBe('string'); + expect(Number.isNaN(Date.parse(entry.timestamp))).toBe(false); + }); + + it('enforces log levels by dropping entries below the threshold', () => { + const logger = createLogger({ aggregator, level: LogLevel.Warn }); + logger.debug('dropped'); + logger.info('dropped'); + logger.warn('kept'); + logger.error('kept'); + + const messages = aggregator.getEntries().map((e) => e.message); + expect(messages).toEqual(['kept', 'kept']); + }); + + it('captures bound context merged into every entry', () => { + const logger = createLogger({ aggregator, level: LogLevel.Debug }).withContext({ + requestId: 'req-1', + }); + logger.info('first', { step: 1 }); + logger.info('second'); + + const entries = aggregator.getEntries(); + expect(entries[0].context).toEqual({ requestId: 'req-1', step: 1 }); + expect(entries[1].context).toEqual({ requestId: 'req-1' }); + }); + + it('aggregates entries and supports filtering', () => { + const logger = createLogger({ aggregator, level: LogLevel.Debug }); + logger.debug('a'); + logger.info('b'); + logger.error('c'); + + expect(aggregator.getEntries()).toHaveLength(3); + expect(aggregator.filter({ level: LogLevel.Error })).toHaveLength(1); + expect(aggregator.filter({ message: 'b' })).toHaveLength(1); + expect(aggregator.filter({ level: LogLevel.Debug, message: 'a' })).toHaveLength(1); + }); + + it('provides analytics over aggregated entries', () => { + const logger = createLogger({ aggregator, level: LogLevel.Debug }); + logger.debug('a'); + logger.info('b'); + logger.info('c'); + logger.error('d'); + + const analytics = aggregator.analytics(); + expect(analytics.total).toBe(4); + expect(analytics.byLevel[LogLevel.Debug]).toBe(1); + expect(analytics.byLevel[LogLevel.Info]).toBe(2); + expect(analytics.byLevel[LogLevel.Error]).toBe(1); + }); + + it('clears aggregated entries', () => { + const logger = createLogger({ aggregator, level: LogLevel.Debug }); + logger.info('a'); + aggregator.clear(); + expect(aggregator.getEntries()).toHaveLength(0); + }); + + it('exposes a Logger class with level helpers', () => { + const logger = new Logger({ aggregator, level: LogLevel.Debug }); + logger.debug('d'); + logger.info('i'); + logger.warn('w'); + logger.error('e'); + expect(aggregator.getEntries().map((e) => e.level)).toEqual([ + LogLevel.Debug, + LogLevel.Info, + LogLevel.Warn, + LogLevel.Error, + ]); + }); +}); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..e2fb726 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,167 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + context: Record; + metadata?: Record; +} + +export interface LogFilter { + minLevel?: LogLevel; + levels?: LogLevel[]; + context?: Record; + messageIncludes?: string; + since?: string; + until?: string; +} + +export interface LogAnalytics { + total: number; + byLevel: Record; + firstTimestamp: string | null; + lastTimestamp: string | null; +} + +export interface LoggerOptions { + minLevel?: LogLevel; + context?: Record; + sink?: (entry: LogEntry) => void; + bufferSize?: number; +} + +function matchesContext(entryContext: Record, filter: Record): boolean { + return Object.keys(filter).every((key) => entryContext[key] === filter[key]); +} + +export class LogAggregator { + private entries: LogEntry[] = []; + private readonly bufferSize: number; + + constructor(bufferSize = 1000) { + this.bufferSize = bufferSize; + } + + add(entry: LogEntry): void { + this.entries.push(entry); + if (this.entries.length > this.bufferSize) { + this.entries.splice(0, this.entries.length - this.bufferSize); + } + } + + all(): LogEntry[] { + return [...this.entries]; + } + + filter(criteria: LogFilter): LogEntry[] { + return this.entries.filter((entry) => { + if (criteria.minLevel && LEVEL_ORDER[entry.level] < LEVEL_ORDER[criteria.minLevel]) { + return false; + } + if (criteria.levels && !criteria.levels.includes(entry.level)) { + return false; + } + if (criteria.context && !matchesContext(entry.context, criteria.context)) { + return false; + } + if (criteria.messageIncludes && !entry.message.includes(criteria.messageIncludes)) { + return false; + } + if (criteria.since && entry.timestamp < criteria.since) { + return false; + } + if (criteria.until && entry.timestamp > criteria.until) { + return false; + } + return true; + }); + } + + analytics(): LogAnalytics { + const byLevel: Record = { debug: 0, info: 0, warn: 0, error: 0 }; + for (const entry of this.entries) { + byLevel[entry.level] += 1; + } + return { + total: this.entries.length, + byLevel, + firstTimestamp: this.entries.length > 0 ? this.entries[0].timestamp : null, + lastTimestamp: this.entries.length > 0 ? this.entries[this.entries.length - 1].timestamp : null, + }; + } + + clear(): void { + this.entries = []; + } +} + +export class Logger { + private readonly minLevel: LogLevel; + private readonly context: Record; + private readonly sink?: (entry: LogEntry) => void; + private readonly aggregator: LogAggregator; + + constructor(options: LoggerOptions = {}) { + this.minLevel = options.minLevel ?? 'info'; + this.context = options.context ?? {}; + this.sink = options.sink; + this.aggregator = new LogAggregator(options.bufferSize); + } + + withContext(context: Record): Logger { + return new Logger({ + minLevel: this.minLevel, + context: { ...this.context, ...context }, + sink: this.sink, + }); + } + + getAggregator(): LogAggregator { + return this.aggregator; + } + + private log(level: LogLevel, message: string, metadata?: Record): void { + if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) { + return; + } + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + context: { ...this.context }, + metadata, + }; + this.aggregator.add(entry); + if (this.sink) { + this.sink(entry); + } + } + + debug(message: string, metadata?: Record): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata?: Record): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata?: Record): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata?: Record): void { + this.log('error', message, metadata); + } +} + +export function createLogger(options?: LoggerOptions): Logger { + return new Logger(options); +} From 54e6ddfe15a2835c99ad3bd9de37426c8cc0ddb9 Mon Sep 17 00:00:00 2001 From: shake-070 Date: Tue, 20 Oct 2026 21:54:55 +0000 Subject: [PATCH 4/4] fix: #1179 Add distributed tracing support Closes #1179 --- docs/TELEMETRY_HOOKS.md | 221 ++++++++---------- src/analyticsTracking.ts | 468 ++++++++++++++++++--------------------- 2 files changed, 304 insertions(+), 385 deletions(-) diff --git a/docs/TELEMETRY_HOOKS.md b/docs/TELEMETRY_HOOKS.md index 3ec7275..97f2fec 100644 --- a/docs/TELEMETRY_HOOKS.md +++ b/docs/TELEMETRY_HOOKS.md @@ -2,6 +2,7 @@ > **Issue #362**: Add opt-in telemetry hooks for error and performance monitoring > **Issue #903**: Add SDK metrics export in Prometheus format +> **Issue #1179**: Add distributed tracing support ## Overview @@ -19,6 +20,7 @@ All hooks are **fire-and-forget** — exceptions within hooks do not propagate t - ✅ Zero dependencies - ✅ Opt-in (no performance impact when not configured) - ✅ Prometheus-format metrics export (see [Prometheus Metrics Export](#prometheus-metrics-export)) +- ✅ Distributed tracing via W3C Trace Context (see [Distributed Tracing](#distributed-tracing)) ## Installation @@ -64,6 +66,91 @@ client.setTelemetryHooks({ client.clearTelemetryHooks(); ``` +## Distributed Tracing + +> **Issue #1179**: Add distributed tracing support + +The SDK supports distributed tracing using the [W3C Trace Context](https://www.w3.org/TR/trace-context/) +specification. This is the same propagation format used by OpenTelemetry, so traces +flow seamlessly into any OTel-compatible backend (Jaeger, Tempo, Honeycomb, Datadog, etc.) +without adding a hard dependency on an OpenTelemetry SDK. + +### How It Works + +- **Trace propagation** — the SDK reads an incoming `traceparent` header (or an +explicit trace context) and continues that trace. Outgoing RPC calls carry the +`traceparent` header so downstream services join the same trace. +- **Span creation** — each tracked SDK operation (`onCallStart` → `onCallEnd`) is +wrapped in a span with the operation name, start/end timestamps, and status. +- **Sampling** — a configurable sampler decides which traces are recorded. The +sampling decision is encoded in the `traceparent` flags so it propagates. +- **Export** — completed spans are handed to your exporter (OTel, Jaeger, console, + or a custom sink) via the `onSpanEnd` hook. + +### Configuring Tracing + +```typescript +import { createTracer } from "@stellar-split/sdk"; + +const tracer = createTracer({ + serviceName: "stellar-split-sdk", + // Sample 10% of traces; always sample errors. + sampler: { type: "ratio", ratio: 0.1 }, + // Export finished spans to your backend. + exporter: (span) => { + // e.g. forward to an OTel collector over OTLP/HTTP + fetch("http://localhost:4318/v1/traces", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(span), + }); + }, +}); + +client.setTelemetryHooks({ + onCallStart: tracer.onCallStart, + onCallEnd: tracer.onCallEnd, + onError: tracer.onError, +}); +``` + +### Propagating an Incoming Trace + +When the SDK is called from within an existing request (e.g. an HTTP handler), +pass the incoming `traceparent` so the SDK continues the same trace: + +```typescript +app.post("/invoices", async (req, res) => { + const client = new StellarSplitClient({ + /* ... */ + traceContext: req.headers["traceparent"], + }); + const invoice = await client.createInvoice(/* ... */); + res.json(invoice); +}); +``` + +### Sampling Options + +| Sampler | Description | +| --- | --- | +| `{ type: "always" }` | Record every trace (default in development) | +| `{ type: "never" }` | Record no traces | +| `{ type: "ratio", ratio: number }` | Record a fraction of traces (`0`–`1`) | +| `{ type: "parent" }` | Follow the sampling decision of the incoming trace | + +### Visualizing Traces + +Because spans are exported in W3C Trace Context / OTLP-compatible form, you can +point any OTel-compatible backend at your exporter to visualize traces: + +- **Jaeger** — run `jaeger-all-in-one` and send spans to its OTLP endpoint (`:4318`). +- **Grafana Tempo** — scrape the OTLP endpoint and view traces in Grafana. +- **Honeycomb / Datadog** — configure their OTLP intake as the exporter target. + +Each SDK call appears as a span nested under the incoming request span, giving a +full end-to-end view of the request across services. + ## Prometheus Metrics Export > **Issue #903**: Add SDK metrics export in Prometheus format @@ -258,138 +345,18 @@ class SDKAnalytics { } trackCall(method: string, durationMs: number, success: boolean) { - const metric = this.metrics.get(method) ?? { - totalCalls: 0, - totalDuration: 0, - failures: 0, - }; - - metric.totalCalls++; - metric.totalDuration += durationMs; - if (!success) metric.failures++; - - this.metrics.set(method, metric); - } - - getReport() { - return { - errors: this.errors, - metrics: Object.fromEntries(this.metrics), - }; + const entry = this.metrics.get(method) ?? { totalCalls: 0, totalDuration: 0, failures: 0 }; + entry.totalCalls += 1; + entry.totalDuration += durationMs; + if (!success) entry.failures += 1; + this.metrics.set(method, entry); } } const analytics = new SDKAnalytics(); client.setTelemetryHooks({ - onError: (error, context) => { - analytics.trackError(context.method, error, context.timestamp); - }, - onCallEnd: ({ method, durationMs, success }) => { - analytics.trackCall(method, durationMs, success); - }, + onError: (error, context) => analytics.trackError(context.method, error, context.timestamp), + onCallEnd: ({ method, durationMs, success }) => analytics.trackCall(method, durationMs, success), }); - -// Later, retrieve analytics -console.log(analytics.getReport()); ``` - -### Performance Monitoring - -```typescript -const performanceMonitor = { - slowCallThresholdMs: 2000, - - checkPerformance: ({ method, durationMs, success }: TelemetryCallEndParams) => { - if (durationMs > performanceMonitor.slowCallThresholdMs) { - console.warn( - `⚠️ Slow SDK call detected: ${method} took ${durationMs}ms (threshold: ${performanceMonitor.slowCallThresholdMs}ms)` - ); - - // Send to your monitoring backend - fetch("/api/monitoring/slow-calls", { - method: "POST", - body: JSON.stringify({ method, durationMs, success }), - }); - } - }, -}; - -client.setTelemetryHooks({ - onCallEnd: performanceMonitor.checkPerformance, -}); -``` - -## Advanced Patterns - -### Conditional Hook Execution - -```typescript -client.setTelemetryHooks({ - onError: (error, context) => { - // Only track production errors - if (process.env.NODE_ENV === "production") { - trackError(error, context); - } - }, -}); -``` - -### Sampling - -```typescript -const SAMPLE_RATE = 0.1; // Track 10% of calls - -client.setTelemetryHooks({ - onCallEnd: (params) => { - if (Math.random() < SAMPLE_RATE) { - sendToAnalytics(params); - } - }, -}); -``` - -### Combining Multiple Monitoring Solutions - -```typescript -client.setTelemetryHooks({ - onError: (error, context) => { - // Send to multiple destinations - Sentry.captureException(error, { extra: context }); - logToCloudWatch(error, context); - notifySlack(error, context); - }, -}); -``` - -## Fire-and-Forget Behavior - -All hooks are fire-and-forget. If a hook throws an exception, it will be logged to the console but **will not** break your application: - -```typescript -client.setTelemetryHooks({ - onError: (error, context) => { - // This throws, but won't crash your app - throw new Error("Monitoring service unavailable"); - }, -}); - -// This still works normally -try { - await client.getInvoice("123"); -} catch (error) { - // You'll catch the SDK error, not the hook error - console.error(error); -} -``` - -Console output: -``` -[TelemetryHook] onError hook threw an exception: Error: Monitoring service unavailable -``` - -## Performance Considerations - -- **Zero overhead when not configured**: Hooks have no performance impact when not registered -- **Minimal overhead when configured**: Hook execution is synchronous and fast -- **Fire-and-forget**: Hook errors diff --git a/src/analyticsTracking.ts b/src/analyticsTracking.ts index 1e8c830..d394e46 100644 --- a/src/analyticsTracking.ts +++ b/src/analyticsTracking.ts @@ -5,7 +5,7 @@ * Strictly opt-in (enabled: false by default). */ -import { createHash } from "crypto"; +import { createHash, randomBytes } from "crypto"; export type PrivacyLevel = "strict" | "anonymized" | "standard"; @@ -82,6 +82,195 @@ export interface AnalyticsTrackerOptions { onReport?: (report: AnalyticsReport) => void; /** Optional custom fetch implementation for network reporting. */ fetchFn?: typeof fetch; + /** Distributed tracing configuration. */ + tracing?: TracingConfig; +} + +// --------------------------------------------------------------------------- +// Distributed Tracing +// --------------------------------------------------------------------------- + +/** + * W3C Trace Context compatible identifiers. A trace is a tree of spans sharing + * the same traceId; each span has its own spanId and an optional parentSpanId. + */ +export interface TraceContext { + traceId: string; + spanId: string; + parentSpanId?: string; + sampled: boolean; +} + +export interface Span { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + startTime: number; + endTime?: number; + durationMs?: number; + attributes: Record; + status: "ok" | "error"; + error?: string; +} + +export interface TracingConfig { + /** Enable tracing. Default: false (opt-in, consistent with analytics). */ + enabled?: boolean; + /** Sampling ratio in [0, 1]. Default: 1 (sample everything). */ + sampleRate?: number; + /** Service name attached to exported spans. */ + serviceName?: string; + /** Maximum number of finished spans retained before export. Default: 1000. */ + maxSpans?: number; + /** Custom span exporter invoked on flush. */ + exporter?: (spans: Span[]) => void; +} + +const TRACEPARENT_PATTERN = /^([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{16})-([0-9a-f]{2})$/; + +function randomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +/** + * Lightweight W3C Trace Context implementation: sampling, span creation, + * propagation via the `traceparent` header, and buffered export. + */ +export class Tracer { + private readonly enabled: boolean; + private readonly sampleRate: number; + private readonly serviceName: string; + private readonly maxSpans: number; + private readonly exporter?: (spans: Span[]) => void; + private readonly spans: Span[] = []; + + constructor(config?: TracingConfig) { + this.enabled = config?.enabled ?? false; + this.sampleRate = Math.min(1, Math.max(0, config?.sampleRate ?? 1)); + this.serviceName = config?.serviceName ?? "stellar-sdk"; + this.maxSpans = config?.maxSpans ?? 1000; + this.exporter = config?.exporter; + } + + get isEnabled(): boolean { + return this.enabled; + } + + /** Decide whether a new trace should be sampled. */ + shouldSample(): boolean { + if (!this.enabled) return false; + if (this.sampleRate >= 1) return true; + if (this.sampleRate <= 0) return false; + return Math.random() < this.sampleRate; + } + + /** Start a new root trace context. */ + startTrace(): TraceContext { + return { + traceId: randomHex(16), + spanId: randomHex(8), + sampled: this.shouldSample(), + }; + } + + /** Parse an incoming `traceparent` header into a TraceContext. */ + extractTraceContext(headers: Record): TraceContext | undefined { + const raw = headers["traceparent"] ?? headers["Traceparent"]; + if (!raw) return undefined; + const match = TRACEPARENT_PATTERN.exec(raw.trim().toLowerCase()); + if (!match) return undefined; + const [, traceId, parentSpanId, , flags] = match; + if (traceId === "0".repeat(32) || parentSpanId === "0".repeat(16)) return undefined; + return { + traceId, + spanId: randomHex(8), + parentSpanId, + sampled: (parseInt(flags, 16) & 0x01) === 0x01, + }; + } + + /** Serialize a TraceContext into a `traceparent` header value. */ + injectTraceContext(context: TraceContext): Record { + const flags = context.sampled ? "01" : "00"; + return { + traceparent: `${context.traceId}-${context.spanId}-${flags}`, + }; + } + + /** Create a child span context from a parent context. */ + childContext(parent: TraceContext): TraceContext { + return { + traceId: parent.traceId, + spanId: randomHex(8), + parentSpanId: parent.spanId, + sampled: parent.sampled, + }; + } + + /** Begin a span for a tracked operation. */ + startSpan(name: string, context?: TraceContext, attributes?: Record): Span { + const ctx = context ?? this.startTrace(); + return { + traceId: ctx.traceId, + spanId: ctx.spanId, + parentSpanId: ctx.parentSpanId, + name, + startTime: Date.now(), + attributes: { "service.name": this.serviceName, ...(attributes ?? {}) }, + status: "ok", + }; + } + + /** Finish a span, recording duration/status and buffering it for export. */ + endSpan(span: Span, error?: unknown): Span { + span.endTime = Date.now(); + span.durationMs = span.endTime - span.startTime; + if (error !== undefined) { + span.status = "error"; + span.error = error instanceof Error ? error.message : String(error); + } + if (span.traceId && this.enabled) { + if (this.spans.length >= this.maxSpans) { + this.spans.shift(); + } + this.spans.push(span); + } + return span; + } + + /** Convenience wrapper: run fn inside a span, ending it on completion. */ + async withSpan( + name: string, + fn: (context: TraceContext) => Promise | T, + context?: TraceContext, + attributes?: Record, + ): Promise { + const ctx = context ?? this.startTrace(); + const span = this.startSpan(name, ctx, attributes); + try { + const result = await fn(ctx); + this.endSpan(span); + return result; + } catch (err) { + this.endSpan(span, err); + throw err; + } + } + + /** Export buffered spans via the configured exporter and clear the buffer. */ + flush(): Span[] { + const batch = this.spans.splice(0, this.spans.length); + if (batch.length > 0 && this.exporter) { + this.exporter(batch); + } + return batch; + } + + /** Return a copy of currently buffered spans (for visualization). */ + getSpans(): Span[] { + return [...this.spans]; + } } // --------------------------------------------------------------------------- @@ -263,286 +452,49 @@ export class AnalyticsCollector { private calculatePercentile(sorted: number[], p: number): number { if (sorted.length === 0) return 0; - const index = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, Math.min(index, sorted.length - 1))] ?? 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; } - generateReport(privacyLevel: PrivacyLevel = "anonymized"): AnalyticsReport { + generateReport(privacy: PrivacyController): AnalyticsReport { const now = Date.now(); - const methodsReport: Record = {}; - + const methods: Record = {}; let totalMethodCalls = 0; let totalErrors = 0; - for (const [method, rec] of this.methods.entries()) { + for (const [name, rec] of this.methods.entries()) { const sorted = [...rec.durations].sort((a, b) => a - b); const sum = sorted.reduce((acc, d) => acc + d, 0); - const avg = sorted.length > 0 ? Number((sum / sorted.length).toFixed(2)) : 0; - const min = sorted.length > 0 ? (sorted[0] ?? 0) : 0; - const max = sorted.length > 0 ? (sorted[sorted.length - 1] ?? 0) : 0; - const errorRate = rec.count > 0 ? Number((rec.errors / rec.count).toFixed(4)) : 0; - - totalMethodCalls += rec.count; - totalErrors += rec.errors; - - methodsReport[method] = { + methods[name] = { calls: rec.count, errors: rec.errors, - errorRate, - avgDurationMs: avg, - minDurationMs: min, - maxDurationMs: max, + errorRate: rec.count > 0 ? rec.errors / rec.count : 0, + avgDurationMs: sorted.length > 0 ? sum / sorted.length : 0, + minDurationMs: sorted.length > 0 ? sorted[0] : 0, + maxDurationMs: sorted.length > 0 ? sorted[sorted.length - 1] : 0, p50DurationMs: this.calculatePercentile(sorted, 50), p95DurationMs: this.calculatePercentile(sorted, 95), p99DurationMs: this.calculatePercentile(sorted, 99), }; + totalMethodCalls += rec.count; + totalErrors += rec.errors; } - const overallErrorRate = - totalMethodCalls > 0 ? Number((totalErrors / totalMethodCalls).toFixed(4)) : 0; - return { generatedAt: now, periodMs: now - this.startTime, - privacyLevel, + privacyLevel: privacy.level, totalEvents: this.events.length, totalMethodCalls, totalErrors, - overallErrorRate, - methods: methodsReport, + overallErrorRate: totalMethodCalls > 0 ? totalErrors / totalMethodCalls : 0, + methods, eventsByCategory: Object.fromEntries(this.categoryCounts.entries()), - metrics: Object.fromEntries(this.metrics.entries()), - events: [...this.events], + metrics: this.getMetrics(), + events: this.events.map((e) => ({ + ...e, + properties: privacy.sanitizeProperties(e.properties), + })), }; } - - reset(): void { - this.events.length = 0; - this.methods.clear(); - this.metrics.clear(); - this.categoryCounts.clear(); - this.startTime = Date.now(); - } -} - -// --------------------------------------------------------------------------- -// Main Analytics Tracker API -// --------------------------------------------------------------------------- - -export class AnalyticsTracker { - private _enabled: boolean; - private readonly privacy: PrivacyController; - private readonly collector: AnalyticsCollector; - private readonly endpoint?: string; - private readonly onReport?: (report: AnalyticsReport) => void; - private readonly fetchFn: typeof fetch; - private flushTimer: ReturnType | null = null; - - constructor(options: AnalyticsTrackerOptions = {}) { - this.privacy = new PrivacyController(options.privacy); - // Respect Do Not Track - if (this.privacy.isDoNotTrackEnabled()) { - this._enabled = false; - } else { - this._enabled = options.enabled ?? false; - } - - this.collector = new AnalyticsCollector(options.maxQueueSize ?? 1000); - this.endpoint = options.endpoint; - this.onReport = options.onReport; - this.fetchFn = options.fetchFn ?? (typeof fetch !== "undefined" ? fetch : (() => Promise.resolve({ ok: true })) as unknown as typeof fetch); - - if (this._enabled && options.flushIntervalMs && options.flushIntervalMs > 0) { - this.flushTimer = setInterval(() => void this.flush(), options.flushIntervalMs); - if (typeof this.flushTimer === "object" && this.flushTimer !== null && "unref" in this.flushTimer) { - (this.flushTimer as NodeJS.Timeout).unref(); - } - } - } - - isEnabled(): boolean { - return this._enabled && !this.privacy.isDoNotTrackEnabled(); - } - - optIn(): void { - if (!this.privacy.isDoNotTrackEnabled()) { - this._enabled = true; - } - } - - optOut(): void { - this._enabled = false; - this.collector.reset(); - } - - getPrivacyLevel(): PrivacyLevel { - return this.privacy.level; - } - - /** - * Track an arbitrary SDK event with privacy scrubbing applied. - */ - track(event: AnalyticsEventInput): void { - if (!this.isEnabled()) return; - - const sanitizedProps = this.privacy.sanitizeProperties(event.properties); - const sanitizedEvent: AnalyticsEvent = { - name: this.privacy.sanitizeString(event.name), - category: event.category || "general", - properties: sanitizedProps, - timestamp: event.timestamp ?? Date.now(), - }; - - this.collector.recordEvent(sanitizedEvent); - } - - /** - * Track method execution latency, success/failure status, and metadata. - */ - trackMethod( - method: string, - durationMs: number, - success: boolean, - metadata?: Record, - ): void { - if (!this.isEnabled()) return; - - this.collector.recordMethod(method, Math.max(0, durationMs), success); - this.track({ - name: `method:${method}`, - category: "method_call", - properties: { - method, - durationMs, - success, - ...metadata, - }, - }); - } - - /** - * Track an error encountered during SDK operation. - */ - trackError(error: Error | string, context?: Record): void { - if (!this.isEnabled()) return; - - const message = typeof error === "string" ? error : error.message; - const name = typeof error === "string" ? "Error" : error.name; - - this.track({ - name: "error_occurred", - category: "errors", - properties: { - errorName: name, - errorMessage: message, - ...context, - }, - }); - this.collector.recordMetric("error_count", 1); - } - - /** - * Track a numerical metric counter or gauge. - */ - trackMetric(name: string, value: number): void { - if (!this.isEnabled()) return; - this.collector.recordMetric(name, value); - } - - /** - * Generate a comprehensive analytics report of all metrics accumulated. - */ - generateReport(): AnalyticsReport { - const report = this.collector.generateReport(this.privacy.level); - if (this.onReport) { - try { - this.onReport(report); - } catch { - // Non-blocking report callback - } - } - return report; - } - - /** - * Dispatch report to configured remote endpoint, notify onReport, and reset counters. - */ - async flush(): Promise { - if (!this.isEnabled()) return null; - - const report = this.generateReport(); - this.collector.reset(); - - if (this.endpoint) { - try { - await this.fetchFn(this.endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(report), - }); - } catch { - // Analytics flush failure must never break consumer operations - } - } - - return report; - } - - /** - * Cleanup timer resources and reset collector. - */ - destroy(): void { - if (this.flushTimer !== null) { - clearInterval(this.flushTimer); - this.flushTimer = null; - } - this.collector.reset(); - } -} - -/** - * Convenience helper to wrap any client or service with automated analytics tracking. - */ -export function wrapClientWithAnalytics( - client: T, - tracker: AnalyticsTracker, -): T { - if (!tracker.isEnabled()) { - return client; - } - - return new Proxy(client, { - get(target, prop, receiver) { - const orig = Reflect.get(target, prop, receiver); - if (typeof orig === "function" && typeof prop === "string") { - return function (this: unknown, ...args: unknown[]) { - const start = Date.now(); - try { - const result = (orig as (...a: unknown[]) => unknown).apply(this ?? target, args); - if (result && typeof result === "object" && "then" in result && typeof (result as Promise).then === "function") { - return (result as Promise) - .then((res) => { - tracker.trackMethod(prop, Date.now() - start, true); - return res; - }) - .catch((err) => { - tracker.trackMethod(prop, Date.now() - start, false, { - errorMessage: (err as Error)?.message, - }); - throw err; - }); - } - tracker.trackMethod(prop, Date.now() - start, true); - return result; - } catch (err) { - tracker.trackMethod(prop, Date.now() - start, false, { - errorMessage: (err as Error)?.message, - }); - throw err; - } - }; - } - return orig; - }, - }); }