From 7d0c22f2fd70b06a81d74e1330cd984ff038f8c4 Mon Sep 17 00:00:00 2001 From: joromi1 Date: Sat, 17 Oct 2026 22:43:30 +0000 Subject: [PATCH] feat: implement error logging, breadcrumb tracking, error context, and error aggregation (#1069, #1070, #1071, #1072) - Error logging (#1069): introduce ErrorLogger with log levels, Text/JSON formatters, and reliable Memory/Console/Multi storage sinks - Breadcrumb tracking (#1070): add BreadcrumbTracker with bounded ring buffer storage and rich context helpers (HTTP, RPC, transaction, state changes) - Error context (#1071): add ErrorContext with attachment utilities, secret key masking/sanitization, safe BigInt/circular serialization, and fluent builder - Error aggregation (#1072): add ErrorAggregator with normalized message grouping, pattern detection (spikes, recurring failures, cascading failures), and clear reporting - Export modules from SDK root and add comprehensive unit test suites --- src/breadcrumbTracker.ts | 376 ++++++++++++++++ src/errorAggregator.ts | 758 +++++++++++++++++++++++++++++++++ src/errorContext.ts | 480 +++++++++++++++++++++ src/errorLogger.ts | 541 +++++++++++++++++++++++ src/index.ts | 85 ++++ test/breadcrumbTracker.test.ts | 217 ++++++++++ test/errorAggregator.test.ts | 242 +++++++++++ test/errorContext.test.ts | 228 ++++++++++ test/errorLogger.test.ts | 270 ++++++++++++ 9 files changed, 3197 insertions(+) create mode 100644 src/breadcrumbTracker.ts create mode 100644 src/errorAggregator.ts create mode 100644 src/errorContext.ts create mode 100644 src/errorLogger.ts create mode 100644 test/breadcrumbTracker.test.ts create mode 100644 test/errorAggregator.test.ts create mode 100644 test/errorContext.test.ts create mode 100644 test/errorLogger.test.ts diff --git a/src/breadcrumbTracker.ts b/src/breadcrumbTracker.ts new file mode 100644 index 0000000..fc5032f --- /dev/null +++ b/src/breadcrumbTracker.ts @@ -0,0 +1,376 @@ +/** + * Breadcrumb tracking system for recording event history leading up to errors. + * Provides storage-efficient bounded ring buffers and fast retrieval with rich context. + * + * Issue #1070 — Add breadcrumb tracking + */ + +/** Category of breadcrumb event. */ +export type BreadcrumbCategory = + | "http" + | "rpc" + | "transaction" + | "auth" + | "ui" + | "state" + | "navigation" + | "error" + | "custom" + | (string & {}); + +/** Severity level for breadcrumb. */ +export type BreadcrumbLevel = "debug" | "info" | "warn" | "error" | "fatal"; + +/** Individual breadcrumb item representing a discreet event. */ +export interface Breadcrumb { + /** Unique sequential or UUID identifier. */ + id: string; + /** Timestamp in milliseconds since Unix epoch. */ + timestamp: number; + /** Category classifying the breadcrumb. */ + category: BreadcrumbCategory; + /** Human-readable event description. */ + message: string; + /** Optional severity level. */ + level?: BreadcrumbLevel; + /** Rich contextual payload associated with the event. */ + data?: Record; +} + +/** Storage backend contract for breadcrumb items. */ +export interface BreadcrumbStorage { + /** Appends a breadcrumb to storage. */ + add(crumb: Breadcrumb): void; + /** Returns all stored breadcrumbs in chronological order. */ + getAll(): Breadcrumb[]; + /** Clears all stored breadcrumbs. */ + clear(): void; + /** Returns the current count of stored breadcrumbs. */ + size(): number; +} + +/** + * Storage-efficient circular ring buffer for breadcrumbs. + * Fixed maximum capacity prevents unbounded memory growth in long-running processes. + */ +export class RingBufferBreadcrumbStorage implements BreadcrumbStorage { + private readonly capacity: number; + private buffer: (Breadcrumb | undefined)[]; + private head: number = 0; // insertion pointer + private length: number = 0; + + constructor(capacity: number = 100) { + this.capacity = Math.max(1, capacity); + this.buffer = new Array(this.capacity); + } + + add(crumb: Breadcrumb): void { + this.buffer[this.head] = crumb; + this.head = (this.head + 1) % this.capacity; + if (this.length < this.capacity) { + this.length++; + } + } + + getAll(): Breadcrumb[] { + const result = new Array(this.length); + if (this.length < this.capacity) { + // Buffer hasn't wrapped yet: items are from 0 to length - 1 + for (let i = 0; i < this.length; i++) { + result[i] = this.buffer[i]!; + } + } else { + // Buffer has wrapped: oldest item is at head + for (let i = 0; i < this.capacity; i++) { + const index = (this.head + i) % this.capacity; + result[i] = this.buffer[index]!; + } + } + return result; + } + + clear(): void { + this.buffer = new Array(this.capacity); + this.head = 0; + this.length = 0; + } + + size(): number { + return this.length; + } +} + +/** Filter criteria for retrieving breadcrumbs. */ +export interface BreadcrumbFilter { + /** Filter by category or array of categories. */ + category?: BreadcrumbCategory | BreadcrumbCategory[]; + /** Filter by specific level. */ + level?: BreadcrumbLevel; + /** Filter events on or after timestamp. */ + since?: number; + /** Filter events on or before timestamp. */ + until?: number; + /** Maximum number of latest breadcrumbs to return. */ + limit?: number; +} + +/** Configuration options for BreadcrumbTracker. */ +export interface BreadcrumbTrackerOptions { + /** Maximum number of breadcrumbs to retain. Defaults to 100. */ + maxBreadcrumbs?: number; + /** Custom storage backend. Defaults to RingBufferBreadcrumbStorage. */ + storage?: BreadcrumbStorage; + /** Optional pre-insert sanitizer/filter hook. Return null to discard breadcrumb. */ + beforeAddBreadcrumb?: (crumb: Breadcrumb) => Breadcrumb | null; +} + +/** + * BreadcrumbTracker manages and retains an event trail leading up to errors. + */ +export class BreadcrumbTracker { + private readonly storage: BreadcrumbStorage; + private readonly beforeAddBreadcrumb?: (crumb: Breadcrumb) => Breadcrumb | null; + private counter: number = 0; + + constructor(options: BreadcrumbTrackerOptions = {}) { + const maxBreadcrumbs = options.maxBreadcrumbs ?? 100; + this.storage = options.storage ?? new RingBufferBreadcrumbStorage(maxBreadcrumbs); + this.beforeAddBreadcrumb = options.beforeAddBreadcrumb; + } + + /** + * Adds a new breadcrumb. + */ + addBreadcrumb(crumb: { + category?: BreadcrumbCategory; + message: string; + level?: BreadcrumbLevel; + data?: Record; + timestamp?: number; + id?: string; + }): Breadcrumb | null { + this.counter++; + const fullCrumb: Breadcrumb = { + id: crumb.id ?? `bc-${Date.now()}-${this.counter}`, + timestamp: crumb.timestamp ?? Date.now(), + category: crumb.category ?? "custom", + message: crumb.message, + level: crumb.level ?? "info", + data: crumb.data ? { ...crumb.data } : undefined, + }; + + const finalCrumb = this.beforeAddBreadcrumb ? this.beforeAddBreadcrumb(fullCrumb) : fullCrumb; + if (!finalCrumb) { + return null; + } + + this.storage.add(finalCrumb); + return finalCrumb; + } + + /** Helper to track HTTP requests and responses. */ + trackHttp( + method: string, + url: string, + status?: number, + durationMs?: number, + extra?: Record + ): Breadcrumb | null { + return this.addBreadcrumb({ + category: "http", + message: `${method.toUpperCase()} ${url} ${status !== undefined ? `[${status}]` : ""}`.trim(), + level: status && status >= 400 ? "warn" : "info", + data: { + method, + url, + status, + durationMs, + ...extra, + }, + }); + } + + /** Helper to track Stellar transactions. */ + trackTransaction( + txHashOrXdr: string, + operation: string, + status: "pending" | "submitted" | "success" | "failed", + extra?: Record + ): Breadcrumb | null { + return this.addBreadcrumb({ + category: "transaction", + message: `Transaction ${operation}: ${status}`, + level: status === "failed" ? "error" : "info", + data: { + txHashOrXdr, + operation, + status, + ...extra, + }, + }); + } + + /** Helper to track Soroban / Horizon RPC calls. */ + trackRpc( + method: string, + endpoint?: string, + status?: "pending" | "success" | "failed", + durationMs?: number, + extra?: Record + ): Breadcrumb | null { + return this.addBreadcrumb({ + category: "rpc", + message: `RPC ${method} ${status ? `(${status})` : ""}`.trim(), + level: status === "failed" ? "warn" : "info", + data: { + method, + endpoint, + status, + durationMs, + ...extra, + }, + }); + } + + /** Helper to track state changes in invoices or contracts. */ + trackStateChange( + fromState: string, + toState: string, + entityId?: string, + extra?: Record + ): Breadcrumb | null { + return this.addBreadcrumb({ + category: "state", + message: `State changed: ${fromState} -> ${toState}${entityId ? ` (${entityId})` : ""}`, + level: "info", + data: { + fromState, + toState, + entityId, + ...extra, + }, + }); + } + + /** Helper to track UI or caller user actions. */ + trackUserAction(action: string, extra?: Record): Breadcrumb | null { + return this.addBreadcrumb({ + category: "ui", + message: `User action: ${action}`, + level: "info", + data: { action, ...extra }, + }); + } + + /** Helper to record caught or handled errors as breadcrumbs. */ + trackError(error: unknown, extra?: Record): Breadcrumb | null { + const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + return this.addBreadcrumb({ + category: "error", + message: `Handled error: ${message}`, + level: "error", + data: { + errorType: error instanceof Error ? error.name : typeof error, + ...extra, + }, + }); + } + + /** + * Retrieves stored breadcrumbs, optionally applying filters. + * Retrieval is fast and returns a fresh copy of the array. + */ + getBreadcrumbs(filter?: BreadcrumbFilter): Breadcrumb[] { + let crumbs = this.storage.getAll(); + + if (!filter) { + return crumbs; + } + + if (filter.category) { + if (Array.isArray(filter.category)) { + const catSet = new Set(filter.category); + crumbs = crumbs.filter((c) => catSet.has(c.category)); + } else { + crumbs = crumbs.filter((c) => c.category === filter.category); + } + } + + if (filter.level) { + crumbs = crumbs.filter((c) => c.level === filter.level); + } + + if (filter.since !== undefined) { + crumbs = crumbs.filter((c) => c.timestamp >= filter.since!); + } + + if (filter.until !== undefined) { + crumbs = crumbs.filter((c) => c.timestamp <= filter.until!); + } + + if (filter.limit !== undefined && filter.limit >= 0 && crumbs.length > filter.limit) { + crumbs = crumbs.slice(crumbs.length - filter.limit); + } + + return crumbs; + } + + /** Gets the most recent breadcrumb, if any. */ + getLastBreadcrumb(): Breadcrumb | undefined { + const all = this.storage.getAll(); + return all.length > 0 ? all[all.length - 1] : undefined; + } + + /** Returns an immutable snapshot copy of current breadcrumbs. */ + snapshot(): Breadcrumb[] { + return this.storage.getAll(); + } + + /** Clears all stored breadcrumbs. */ + clear(): void { + this.storage.clear(); + } + + /** Returns the current number of breadcrumbs stored. */ + count(): number { + return this.storage.size(); + } +} + +// Global default tracker singleton +let defaultBreadcrumbTracker = new BreadcrumbTracker(); + +/** Gets the default global BreadcrumbTracker instance. */ +export function getDefaultBreadcrumbTracker(): BreadcrumbTracker { + return defaultBreadcrumbTracker; +} + +/** Sets the default global BreadcrumbTracker instance. */ +export function setDefaultBreadcrumbTracker(tracker: BreadcrumbTracker): void { + defaultBreadcrumbTracker = tracker; +} + +/** Resets the default BreadcrumbTracker instance to empty state. */ +export function resetDefaultBreadcrumbTracker(): void { + defaultBreadcrumbTracker = new BreadcrumbTracker(); +} + +/** Convenience function to append a breadcrumb to the default tracker. */ +export function addBreadcrumb(crumb: { + category?: BreadcrumbCategory; + message: string; + level?: BreadcrumbLevel; + data?: Record; +}): Breadcrumb | null { + return defaultBreadcrumbTracker.addBreadcrumb(crumb); +} + +/** Convenience function to get breadcrumbs from the default tracker. */ +export function getBreadcrumbs(filter?: BreadcrumbFilter): Breadcrumb[] { + return defaultBreadcrumbTracker.getBreadcrumbs(filter); +} + +/** Convenience function to clear breadcrumbs in the default tracker. */ +export function clearBreadcrumbs(): void { + defaultBreadcrumbTracker.clear(); +} diff --git a/src/errorAggregator.ts b/src/errorAggregator.ts new file mode 100644 index 0000000..cd3cbed --- /dev/null +++ b/src/errorAggregator.ts @@ -0,0 +1,758 @@ +/** + * Error Aggregation and Diagnostics System for StellarSplit SDK. + * Groups related errors logically, computes rates and patterns, and produces clear diagnostic reports. + * + * Issue #1072 — Implement error aggregation + */ + +import { StellarSplitError } from "./errors.js"; +import { + type SerializedError, + serializeError, + getErrorContext, +} from "./errorContext.js"; + +/** Built-in grouping strategy types. */ +export type GroupingStrategyType = + | "code" + | "message" + | "fingerprint" + | "type" + | "component"; + +/** Custom grouping resolver function. */ +export type CustomGroupingResolver = ( + error: unknown, + context?: Record +) => string; + +/** Grouping strategy configuration: either a named preset or a custom function. */ +export type ErrorGroupingStrategy = GroupingStrategyType | CustomGroupingResolver; + +/** A single aggregated group of related errors. */ +export interface AggregatedErrorGroup { + /** Identifier key for the group. */ + groupKey: string; + /** Strategy used to form this group. */ + strategy: string; + /** Total count of errors in this group. */ + count: number; + /** Timestamp of first occurrence (epoch ms). */ + firstSeen: number; + /** Timestamp of most recent occurrence (epoch ms). */ + lastSeen: number; + /** Recent sample error instances. */ + sampleErrors: SerializedError[]; + /** Unique error codes observed within this group. */ + errorCodes: string[]; + /** Unique invoice IDs affected by this error group. */ + affectedInvoices: string[]; + /** Unique Stellar account addresses affected. */ + affectedAccounts: string[]; + /** Unique operations during which this error was encountered. */ + affectedOperations: string[]; + /** Context keys and values identical across all occurrences in this group. */ + commonContext: Record; + /** Estimated rate of errors per minute over its active span. */ + ratePerMinute: number; +} + +/** Pattern detected within aggregated errors. */ +export interface ErrorPattern { + /** Nature of the detected pattern. */ + type: "spike" | "high_frequency" | "recurring_entity" | "cascading_failure"; + /** Severity rating. */ + severity: "info" | "low" | "medium" | "high" | "critical"; + /** Descriptive explanation of the pattern. */ + description: string; + /** Group key involved if group-specific. */ + groupKey?: string; + /** Entity identifier (e.g. invoiceId or accountId) if entity-specific. */ + entityId?: string; + /** Count or measurement associated with the pattern. */ + count: number; + /** Additional diagnostic metrics. */ + details: Record; +} + +/** Options for generating aggregation reports. */ +export interface ReportOptions { + /** Filter errors on or after this timestamp. */ + since?: number; + /** Filter errors on or before this timestamp. */ + until?: number; + /** Minimum error count to include in report groups. */ + minCount?: number; + /** Maximum number of groups to report. */ + limit?: number; +} + +/** Comprehensive aggregation report structure. */ +export interface ErrorAggregationReport { + generatedAt: number; + timeRange: { + start: number; + end: number; + durationMs: number; + }; + totalErrors: number; + uniqueGroups: number; + groups: AggregatedErrorGroup[]; + topErrors: { + groupKey: string; + count: number; + percentage: number; + }[]; + patterns: ErrorPattern[]; + summary: { + totalRecorded: number; + criticalPatternsCount: number; + mostFrequentCode: string | null; + mostAffectedInvoice: string | null; + mostAffectedAccount: string | null; + }; + formatAsText(): string; + formatAsMarkdown(): string; + toJSON(): string; +} + +/** Configuration options for ErrorAggregator. */ +export interface ErrorAggregatorOptions { + /** Strategy for grouping errors. Defaults to 'fingerprint'. */ + strategy?: ErrorGroupingStrategy; + /** Maximum number of groups to track before evicting oldest. Defaults to 500. */ + maxGroups?: number; + /** Maximum sample errors to retain per group. Defaults to 5. */ + maxSamplesPerGroup?: number; + /** Rolling time window in ms (e.g. 3600000 for 1h). If omitted, retains indefinitely until evicted. */ + windowMs?: number; +} + +/** + * Normalizes dynamic identifiers (Stellar addresses, hashes, UUIDs, numbers) + * in error messages to reveal underlying message templates. + */ +export function normalizeErrorMessage(message: string): string { + if (!message) return ""; + + return message + .replace(/\bS[A-Z0-9]{55}\b/g, "") + .replace(/\bG[A-Z0-9]{55}\b/g, "") + .replace(/\bC[A-Z0-9]{55}\b/g, "") + .replace(/0x[0-9a-fA-F]{8,}/g, "") + .replace(/\b[0-9a-fA-F]{64}\b/g, "") + .replace( + /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, + "" + ) + .replace(/\binv-[a-zA-Z0-9_-]+\b/g, "") + .replace(/\b\d+(\.\d+)?\b/g, "") + .trim(); +} + +/** + * ErrorAggregator collects, groups, analyzes, and reports error occurrences. + */ +export class ErrorAggregator { + private readonly strategy: ErrorGroupingStrategy; + private readonly maxGroups: number; + private readonly maxSamplesPerGroup: number; + private readonly windowMs?: number; + + private groups = new Map< + string, + { + groupKey: string; + strategyName: string; + count: number; + firstSeen: number; + lastSeen: number; + sampleErrors: SerializedError[]; + errorCodes: Set; + affectedInvoices: Set; + affectedAccounts: Set; + affectedOperations: Set; + timestamps: number[]; + contextCounts: Map>; + } + >(); + + private totalErrorsRecorded = 0; + + constructor(options: ErrorAggregatorOptions = {}) { + this.strategy = options.strategy ?? "fingerprint"; + this.maxGroups = Math.max(1, options.maxGroups ?? 500); + this.maxSamplesPerGroup = Math.max(1, options.maxSamplesPerGroup ?? 5); + this.windowMs = options.windowMs; + } + + /** + * Computes the grouping key for an error using the configured strategy. + */ + resolveGroupKey(error: unknown, context?: Record): string { + if (typeof this.strategy === "function") { + return this.strategy(error, context); + } + + const serialized = serializeError(error); + const code = serialized.code || serialized.name || "UNKNOWN_ERROR"; + const normalizedMsg = normalizeErrorMessage(serialized.message); + + switch (this.strategy) { + case "code": + return code; + case "message": + return normalizedMsg || serialized.name; + case "type": + return serialized.name || "Error"; + case "component": { + const comp = + context?.component || + context?.operation || + serialized.context?.component || + serialized.context?.operation || + "general"; + return `${String(comp)}:${code}`; + } + case "fingerprint": + default: + return `${serialized.name}::${code}::${normalizedMsg}`; + } + } + + /** + * Ingests and aggregates an error. + */ + record(error: unknown, explicitContext?: Record): AggregatedErrorGroup { + const now = Date.now(); + this.totalErrorsRecorded++; + + // Merge error-level context with explicit context + const attachedCtx = getErrorContext(error); + const combinedContext: Record = { + ...(attachedCtx || {}), + ...(explicitContext || {}), + }; + + const groupKey = this.resolveGroupKey(error, combinedContext); + const serialized = serializeError(error); + + // Evict oldest group if capacity reached and group is new + if (!this.groups.has(groupKey) && this.groups.size >= this.maxGroups) { + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, grp] of this.groups.entries()) { + if (grp.lastSeen < oldestTime) { + oldestTime = grp.lastSeen; + oldestKey = key; + } + } + if (oldestKey) { + this.groups.delete(oldestKey); + } + } + + let grp = this.groups.get(groupKey); + const strategyName = typeof this.strategy === "string" ? this.strategy : "custom"; + + if (!grp) { + grp = { + groupKey, + strategyName, + count: 0, + firstSeen: now, + lastSeen: now, + sampleErrors: [], + errorCodes: new Set(), + affectedInvoices: new Set(), + affectedAccounts: new Set(), + affectedOperations: new Set(), + timestamps: [], + contextCounts: new Map>(), + }; + this.groups.set(groupKey, grp); + } + + grp.count++; + grp.lastSeen = now; + grp.timestamps.push(now); + + // Track error code + if (serialized.code) { + grp.errorCodes.add(serialized.code); + } + + // Track entity IDs from context + const invoiceId = combinedContext.invoiceId || serialized.context?.invoiceId; + if (invoiceId && typeof invoiceId === "string") { + grp.affectedInvoices.add(invoiceId); + } + + const accountId = combinedContext.accountId || serialized.context?.accountId; + if (accountId && typeof accountId === "string") { + grp.affectedAccounts.add(accountId); + } + + const operation = combinedContext.operation || serialized.context?.operation; + if (operation && typeof operation === "string") { + grp.affectedOperations.add(operation); + } + + // Track sample errors + if (grp.sampleErrors.length < this.maxSamplesPerGroup) { + grp.sampleErrors.push(serialized); + } else { + // Keep most recent samples + grp.sampleErrors.shift(); + grp.sampleErrors.push(serialized); + } + + // Track context frequencies to compute common context + for (const [k, v] of Object.entries(combinedContext)) { + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { + if (!grp.contextCounts.has(k)) { + grp.contextCounts.set(k, new Map()); + } + const valMap = grp.contextCounts.get(k)!; + valMap.set(v, (valMap.get(v) || 0) + 1); + } + } + + return this.buildAggregatedGroup(grp); + } + + /** + * Ingests a batch of errors. + */ + recordBatch(errors: unknown[], context?: Record): AggregatedErrorGroup[] { + return errors.map((err) => this.record(err, context)); + } + + /** + * Helper to construct AggregatedErrorGroup from internal group state. + */ + private buildAggregatedGroup(grp: { + groupKey: string; + strategyName: string; + count: number; + firstSeen: number; + lastSeen: number; + sampleErrors: SerializedError[]; + errorCodes: Set; + affectedInvoices: Set; + affectedAccounts: Set; + affectedOperations: Set; + timestamps: number[]; + contextCounts: Map>; + }): AggregatedErrorGroup { + const spanMinutes = Math.max(0.1, (grp.lastSeen - grp.firstSeen) / 60000); + const ratePerMinute = parseFloat((grp.count / spanMinutes).toFixed(2)); + + // Calculate common context (values that appeared in 100% of the occurrences) + const commonContext: Record = {}; + for (const [k, valMap] of grp.contextCounts.entries()) { + for (const [val, count] of valMap.entries()) { + if (count === grp.count) { + commonContext[k] = val; + } + } + } + + return { + groupKey: grp.groupKey, + strategy: grp.strategyName, + count: grp.count, + firstSeen: grp.firstSeen, + lastSeen: grp.lastSeen, + sampleErrors: [...grp.sampleErrors], + errorCodes: Array.from(grp.errorCodes), + affectedInvoices: Array.from(grp.affectedInvoices), + affectedAccounts: Array.from(grp.affectedAccounts), + affectedOperations: Array.from(grp.affectedOperations), + commonContext, + ratePerMinute, + }; + } + + /** + * Returns all current aggregated groups. + */ + getGroups(options?: ReportOptions): AggregatedErrorGroup[] { + let list: AggregatedErrorGroup[] = []; + + const now = Date.now(); + for (const grp of this.groups.values()) { + if (this.windowMs && now - grp.lastSeen > this.windowMs) { + continue; + } + if (options?.since && grp.lastSeen < options.since) { + continue; + } + if (options?.until && grp.firstSeen > options.until) { + continue; + } + if (options?.minCount && grp.count < options.minCount) { + continue; + } + list.push(this.buildAggregatedGroup(grp)); + } + + // Sort by count descending + list.sort((a, b) => b.count - a.count); + + if (options?.limit && options.limit > 0) { + list = list.slice(0, options.limit); + } + + return list; + } + + /** + * Detects patterns across the aggregated errors to make trends and anomalies visible. + */ + detectPatterns(): ErrorPattern[] { + const patterns: ErrorPattern[] = []; + const now = Date.now(); + + // 1. Spike Detection: >= 5 errors in the last 60 seconds within a group + for (const grp of this.groups.values()) { + const recentTimestamps = grp.timestamps.filter((t) => now - t <= 60000); + if (recentTimestamps.length >= 5) { + patterns.push({ + type: "spike", + severity: recentTimestamps.length >= 20 ? "critical" : "high", + description: `Spike detected: ${recentTimestamps.length} errors occurred in the last 60 seconds for "${grp.groupKey}"`, + groupKey: grp.groupKey, + count: recentTimestamps.length, + details: { + timeWindowMs: 60000, + recentCount: recentTimestamps.length, + totalGroupCount: grp.count, + }, + }); + } + } + + // 2. High Frequency Detection: group accounts for > 40% of total errors (if total >= 10) + if (this.totalErrorsRecorded >= 10) { + for (const grp of this.groups.values()) { + const proportion = grp.count / this.totalErrorsRecorded; + if (proportion >= 0.4) { + patterns.push({ + type: "high_frequency", + severity: proportion >= 0.7 ? "high" : "medium", + description: `Dominant error pattern: group "${grp.groupKey}" accounts for ${(proportion * 100).toFixed(1)}% of all recorded errors`, + groupKey: grp.groupKey, + count: grp.count, + details: { + proportion: parseFloat(proportion.toFixed(3)), + totalErrors: this.totalErrorsRecorded, + }, + }); + } + } + } + + // 3. Recurring Entity Failures: invoice or account appearing across multiple groups or >= 3 failures + const invoiceCounts = new Map(); + const accountCounts = new Map(); + + for (const grp of this.groups.values()) { + for (const inv of grp.affectedInvoices) { + invoiceCounts.set(inv, (invoiceCounts.get(inv) || 0) + grp.count); + } + for (const acc of grp.affectedAccounts) { + accountCounts.set(acc, (accountCounts.get(acc) || 0) + grp.count); + } + } + + for (const [inv, count] of invoiceCounts.entries()) { + if (count >= 3) { + patterns.push({ + type: "recurring_entity", + severity: count >= 10 ? "high" : "medium", + description: `Recurring invoice failure: Invoice "${inv}" has failed ${count} times`, + entityId: inv, + count, + details: { entityType: "invoice", invoiceId: inv }, + }); + } + } + + for (const [acc, count] of accountCounts.entries()) { + if (count >= 5) { + patterns.push({ + type: "recurring_entity", + severity: count >= 15 ? "high" : "medium", + description: `Recurring account failure: Account "${acc}" has failed ${count} times`, + entityId: acc, + count, + details: { entityType: "account", accountId: acc }, + }); + } + } + + // 4. Cascading Failure: 3 or more distinct error groups within a tight 30-second window + const recentWindowMs = 30000; + const distinctGroupsRecent = new Set(); + for (const grp of this.groups.values()) { + if (grp.timestamps.some((t) => now - t <= recentWindowMs)) { + distinctGroupsRecent.add(grp.groupKey); + } + } + + if (distinctGroupsRecent.size >= 3) { + patterns.push({ + type: "cascading_failure", + severity: "critical", + description: `Cascading system failure: ${distinctGroupsRecent.size} distinct error groups triggered in the last 30 seconds`, + count: distinctGroupsRecent.size, + details: { + groups: Array.from(distinctGroupsRecent), + windowMs: recentWindowMs, + }, + }); + } + + return patterns; + } + + /** + * Generates a comprehensive, human-readable and structured ErrorAggregationReport. + */ + generateReport(options?: ReportOptions): ErrorAggregationReport { + const groups = this.getGroups(options); + const patterns = this.detectPatterns(); + const now = Date.now(); + + let earliestTime = now; + let latestTime = 0; + let totalErrors = 0; + + const invoiceCounts = new Map(); + const accountCounts = new Map(); + const codeCounts = new Map(); + + for (const g of groups) { + totalErrors += g.count; + if (g.firstSeen < earliestTime) earliestTime = g.firstSeen; + if (g.lastSeen > latestTime) latestTime = g.lastSeen; + + for (const inv of g.affectedInvoices) { + invoiceCounts.set(inv, (invoiceCounts.get(inv) || 0) + g.count); + } + for (const acc of g.affectedAccounts) { + accountCounts.set(acc, (accountCounts.get(acc) || 0) + g.count); + } + for (const code of g.errorCodes) { + codeCounts.set(code, (codeCounts.get(code) || 0) + g.count); + } + } + + if (latestTime === 0) latestTime = now; + if (earliestTime === now && groups.length === 0) earliestTime = now; + + // Top errors ranked + const topErrors = groups.slice(0, 5).map((g) => ({ + groupKey: g.groupKey, + count: g.count, + percentage: totalErrors > 0 ? parseFloat(((g.count / totalErrors) * 100).toFixed(1)) : 0, + })); + + // Find most affected entities + let mostFrequentCode: string | null = null; + let maxCodeCount = 0; + for (const [code, count] of codeCounts.entries()) { + if (count > maxCodeCount) { + maxCodeCount = count; + mostFrequentCode = code; + } + } + + let mostAffectedInvoice: string | null = null; + let maxInvoiceCount = 0; + for (const [inv, count] of invoiceCounts.entries()) { + if (count > maxInvoiceCount) { + maxInvoiceCount = count; + mostAffectedInvoice = inv; + } + } + + let mostAffectedAccount: string | null = null; + let maxAccountCount = 0; + for (const [acc, count] of accountCounts.entries()) { + if (count > maxAccountCount) { + maxAccountCount = count; + mostAffectedAccount = acc; + } + } + + const criticalPatternsCount = patterns.filter( + (p) => p.severity === "critical" || p.severity === "high" + ).length; + + const report: ErrorAggregationReport = { + generatedAt: now, + timeRange: { + start: earliestTime, + end: latestTime, + durationMs: Math.max(0, latestTime - earliestTime), + }, + totalErrors, + uniqueGroups: groups.length, + groups, + topErrors, + patterns, + summary: { + totalRecorded: this.totalErrorsRecorded, + criticalPatternsCount, + mostFrequentCode, + mostAffectedInvoice, + mostAffectedAccount, + }, + + formatAsText(): string { + const lines: string[] = []; + lines.push("=================================================="); + lines.push(" STELLARSPLIT ERROR REPORT "); + lines.push("=================================================="); + lines.push(`Generated: ${new Date(now).toISOString()}`); + lines.push(`Total Errors: ${totalErrors} across ${groups.length} distinct group(s)`); + lines.push(`Span: ${((latestTime - earliestTime) / 1000).toFixed(1)}s`); + lines.push(""); + + if (patterns.length > 0) { + lines.push("--- DETECTED PATTERNS & ANOMALIES ---"); + for (const p of patterns) { + lines.push(`[${p.severity.toUpperCase()}] [${p.type}] ${p.description}`); + } + lines.push(""); + } + + lines.push("--- TOP ERROR GROUPS ---"); + for (const [idx, item] of topErrors.entries()) { + lines.push(`${idx + 1}. [${item.count} hits / ${item.percentage}%] ${item.groupKey}`); + } + lines.push(""); + + lines.push("--- GROUP DETAILS ---"); + for (const g of groups) { + lines.push(`Group: ${g.groupKey}`); + lines.push(` Hits: ${g.count} | Rate: ${g.ratePerMinute}/min | Codes: ${g.errorCodes.join(", ") || "none"}`); + if (g.affectedInvoices.length > 0) { + lines.push(` Invoices: ${g.affectedInvoices.join(", ")}`); + } + if (g.affectedAccounts.length > 0) { + lines.push(` Accounts: ${g.affectedAccounts.join(", ")}`); + } + if (g.sampleErrors.length > 0) { + lines.push(` Latest sample: ${g.sampleErrors[g.sampleErrors.length - 1].message}`); + } + } + lines.push("=================================================="); + return lines.join("\n"); + }, + + formatAsMarkdown(): string { + const md: string[] = []; + md.push("# StellarSplit Error Aggregation Report"); + md.push(`**Generated At:** ${new Date(now).toISOString()} `); + md.push(`**Total Errors:** ${totalErrors} | **Unique Groups:** ${groups.length} `); + md.push(""); + + if (patterns.length > 0) { + md.push("## Patterns & Anomalies"); + for (const p of patterns) { + md.push(`- **[${p.severity.toUpperCase()}]** \`${p.type}\`: ${p.description}`); + } + md.push(""); + } + + md.push("## Top Errors"); + md.push("| Rank | Group | Count | Percentage |"); + md.push("| --- | --- | --- | --- |"); + topErrors.forEach((t, i) => { + md.push(`| ${i + 1} | \`${t.groupKey}\` | ${t.count} | ${t.percentage}% |`); + }); + md.push(""); + + md.push("## Error Groups Breakdown"); + for (const g of groups) { + md.push(`### \`${g.groupKey}\``); + md.push(`- **Occurrences:** ${g.count} (${g.ratePerMinute}/min)`); + md.push(`- **First Seen:** ${new Date(g.firstSeen).toISOString()}`); + md.push(`- **Last Seen:** ${new Date(g.lastSeen).toISOString()}`); + if (g.affectedInvoices.length > 0) { + md.push(`- **Affected Invoices:** ${g.affectedInvoices.map((id) => `\`${id}\``).join(", ")}`); + } + if (g.sampleErrors.length > 0) { + md.push(`- **Sample Error:** \`${g.sampleErrors[g.sampleErrors.length - 1].message}\``); + } + md.push(""); + } + + return md.join("\n"); + }, + + toJSON(): string { + return JSON.stringify( + { + generatedAt: this.generatedAt, + timeRange: this.timeRange, + totalErrors: this.totalErrors, + uniqueGroups: this.uniqueGroups, + groups: this.groups, + topErrors: this.topErrors, + patterns: this.patterns, + summary: this.summary, + }, + null, + 2 + ); + }, + }; + + return report; + } + + /** + * Resets all aggregation counters and clear memory. + */ + clear(): void { + this.groups.clear(); + this.totalErrorsRecorded = 0; + } + + /** + * Returns total error occurrences recorded across all groups. + */ + totalCount(): number { + return this.totalErrorsRecorded; + } +} + +// Global default aggregator singleton +let defaultErrorAggregator = new ErrorAggregator(); + +/** Gets the default global ErrorAggregator instance. */ +export function getDefaultErrorAggregator(): ErrorAggregator { + return defaultErrorAggregator; +} + +/** Sets the default global ErrorAggregator instance. */ +export function setDefaultErrorAggregator(aggregator: ErrorAggregator): void { + defaultErrorAggregator = aggregator; +} + +/** Resets the default global ErrorAggregator instance. */ +export function resetDefaultErrorAggregator(): void { + defaultErrorAggregator = new ErrorAggregator(); +} + +/** Convenience function to record an error in the default aggregator. */ +export function aggregateError( + error: unknown, + context?: Record +): AggregatedErrorGroup { + return defaultErrorAggregator.record(error, context); +} diff --git a/src/errorContext.ts b/src/errorContext.ts new file mode 100644 index 0000000..9e69b41 --- /dev/null +++ b/src/errorContext.ts @@ -0,0 +1,480 @@ +/** + * Error Context Management System for StellarSplit SDK. + * Attaches rich, structured, serializable metadata to errors with sanitization and helpers. + * + * Issue #1071 — Implement error context + */ + +import { StellarSplitError } from "./errors.js"; +import { type Breadcrumb, getDefaultBreadcrumbTracker } from "./breadcrumbTracker.js"; + +/** Symbol used to safely attach context to arbitrary Error objects. */ +export const ERROR_CONTEXT_SYMBOL = Symbol.for("stellarsplit.errorContext"); + +/** + * Structured contextual information attached to errors. + */ +export interface ErrorContext { + /** High-level operation during which the error occurred (e.g. 'pay', 'createInvoice'). */ + operation?: string; + /** On-chain invoice identifier if applicable. */ + invoiceId?: string; + /** Stellar account public key (G-address) if applicable. */ + accountId?: string; + /** Stellar network name (e.g. 'testnet', 'public', 'futurenet', 'standalone'). */ + network?: string; + /** Contract address (C-address) if applicable. */ + contractId?: string; + /** Transaction hash or XDR reference. */ + transactionHash?: string; + /** Timestamp when the context was attached. */ + timestamp?: number; + /** Categorical key-value tags. */ + tags?: Record; + /** Breadcrumb trail leading up to the error. */ + breadcrumbs?: Breadcrumb[]; + /** Arbitrary domain-specific metadata. */ + extra?: Record; + /** Additional dynamic properties. */ + [key: string]: unknown; +} + +/** Plain JSON-serializable representation of an error. */ +export interface SerializedError { + /** Error class name. */ + name: string; + /** Error message. */ + message: string; + /** Machine-readable error code if present. */ + code?: string; + /** Stack trace if available. */ + stack?: string; + /** Soroban / RPC raw response if available. */ + raw?: string; + /** Attached sanitized error context. */ + context?: Record; + /** Nested cause error if supported. */ + cause?: unknown; + /** Timestamp of serialization. */ + timestamp: number; +} + +/** Regex matching Stellar secret seed keys (S-addresses, 56 characters). */ +const STELLAR_SECRET_SEED_REGEX = /\bS[A-Z0-9]{55}\b/g; + +/** Keys that typically hold secret or sensitive credential data. */ +const SENSITIVE_KEY_PATTERNS = [ + /password/i, + /secret/i, + /seed/i, + /private[_-]?key/i, + /api[_-]?key/i, + /token/i, + /authorization/i, + /auth[_-]?header/i, + /bearer/i, +]; + +/** + * Recursively sanitizes context data by masking secret keys, tokens, and passwords. + * Handles circular references and converts BigInt values safely. + */ +export function sanitizeContext>( + input: T, + seen: WeakSet = new WeakSet() +): T { + if (input === null || input === undefined) { + return input; + } + + // Handle BigInt + if (typeof input === "bigint") { + return (input as bigint).toString() as unknown as T; + } + + // Handle Strings with embedded secret seeds + if (typeof input === "string") { + return input.replace(STELLAR_SECRET_SEED_REGEX, "S***[MASKED]") as unknown as T; + } + + // Handle Primitives + if (typeof input !== "object") { + return input; + } + + // Prevent Circular Loops + if (seen.has(input as object)) { + return "[Circular]" as unknown as T; + } + seen.add(input as object); + + // Handle Arrays + if (Array.isArray(input)) { + return input.map((item) => sanitizeContext(item, seen)) as unknown as T; + } + + // Handle Plain Objects + const sanitizedObj: Record = {}; + for (const [key, val] of Object.entries(input as Record)) { + const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); + if (isSensitive) { + sanitizedObj[key] = "***MASKED***"; + } else { + sanitizedObj[key] = sanitizeContext(val, seen); + } + } + + return sanitizedObj as unknown as T; +} + +/** + * Merges two partial ErrorContext objects into a single cohesive ErrorContext. + */ +export function mergeContext( + base: Partial = {}, + overlay: Partial = {} +): ErrorContext { + const merged: ErrorContext = { + ...base, + ...overlay, + }; + + // Merge tags + if (base.tags || overlay.tags) { + merged.tags = { + ...(base.tags || {}), + ...(overlay.tags || {}), + }; + } + + // Merge extra + if (base.extra || overlay.extra) { + merged.extra = { + ...(base.extra || {}), + ...(overlay.extra || {}), + }; + } + + // Merge breadcrumbs deduplicating by ID + if (base.breadcrumbs || overlay.breadcrumbs) { + const baseCrumbs = base.breadcrumbs || []; + const overlayCrumbs = overlay.breadcrumbs || []; + const seenIds = new Set(); + const combinedCrumbs: Breadcrumb[] = []; + + for (const c of [...baseCrumbs, ...overlayCrumbs]) { + if (!seenIds.has(c.id)) { + seenIds.add(c.id); + combinedCrumbs.push(c); + } + } + merged.breadcrumbs = combinedCrumbs; + } + + return merged; +} + +/** + * Attaches contextual information to an error instance. + * Merges with any existing context and sanitizes sensitive fields. + */ +export function attachErrorContext( + error: T, + context: Partial +): T { + if (!error || typeof error !== "object") { + return error; + } + + const existingContext = getErrorContext(error) || {}; + const merged = mergeContext(existingContext, { + timestamp: Date.now(), + ...context, + }); + + const sanitized = sanitizeContext(merged); + + // Attach to error.context (used by StellarSplitError and standard convention) + try { + Object.defineProperty(error, "context", { + value: sanitized, + writable: true, + enumerable: true, + configurable: true, + }); + } catch { + (error as unknown as { context: unknown }).context = sanitized; + } + + // Also attach to symbol for safe reflection + try { + (error as unknown as Record)[ERROR_CONTEXT_SYMBOL] = sanitized; + } catch { + // Ignore if object is frozen + } + + return error; +} + +/** + * Extracts attached ErrorContext from an error or object. + */ +export function getErrorContext(error: unknown): ErrorContext | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + + const errObj = error as Record; + if (errObj[ERROR_CONTEXT_SYMBOL]) { + return errObj[ERROR_CONTEXT_SYMBOL] as ErrorContext; + } + + if (errObj.context && typeof errObj.context === "object") { + return errObj.context as ErrorContext; + } + + return undefined; +} + +/** + * Checks if an error has attached error context. + */ +export function hasErrorContext(error: unknown): boolean { + return getErrorContext(error) !== undefined; +} + +/** + * Converts any Error or thrown object into a pure, JSON-safe SerializedError. + * Converts BigInt to strings and handles circular references safely. + */ +export function serializeError(error: unknown): SerializedError { + const timestamp = Date.now(); + + if (error instanceof StellarSplitError) { + return { + name: error.name, + message: error.message, + code: error.code, + stack: error.stack, + raw: error.raw, + context: error.context ? sanitizeContext(error.context) : undefined, + timestamp, + }; + } + + if (error instanceof Error) { + const context = getErrorContext(error); + const code = (error as { code?: string }).code; + const cause = (error as { cause?: unknown }).cause; + + return { + name: error.name || "Error", + message: error.message || "", + code, + stack: error.stack, + context: context ? sanitizeContext(context) : undefined, + cause: cause ? String(cause) : undefined, + timestamp, + }; + } + + if (typeof error === "object" && error !== null) { + const safeObj = sanitizeContext(error as Record); + return { + name: "ObjectError", + message: (error as { message?: string }).message || "Non-Error object thrown", + context: safeObj, + timestamp, + }; + } + + return { + name: "PrimitiveError", + message: String(error), + timestamp, + }; +} + +/** + * Serializes an ErrorContext safely to JSON string. + */ +export function serializeErrorContext(context: ErrorContext): string { + const sanitized = sanitizeContext(context); + const seen = new WeakSet(); + return JSON.stringify(sanitized, (_key, val) => { + if (typeof val === "bigint") { + return val.toString(); + } + if (typeof val === "object" && val !== null) { + if (seen.has(val)) return "[Circular]"; + seen.add(val); + } + return val; + }); +} + +/** + * Fluent builder for constructing and attaching rich error context. + */ +export class ErrorContextBuilder { + private context: Partial = {}; + + constructor(initial?: Partial) { + if (initial) { + this.context = { ...initial }; + } + } + + withOperation(operation: string): this { + this.context.operation = operation; + return this; + } + + withInvoiceId(invoiceId: string): this { + this.context.invoiceId = invoiceId; + return this; + } + + withAccountId(accountId: string): this { + this.context.accountId = accountId; + return this; + } + + withNetwork(network: string): this { + this.context.network = network; + return this; + } + + withContractId(contractId: string): this { + this.context.contractId = contractId; + return this; + } + + withTransactionHash(transactionHash: string): this { + this.context.transactionHash = transactionHash; + return this; + } + + withTag(key: string, value: string): this { + this.context.tags = { + ...(this.context.tags || {}), + [key]: value, + }; + return this; + } + + withTags(tags: Record): this { + this.context.tags = { + ...(this.context.tags || {}), + ...tags, + }; + return this; + } + + withExtra(key: string, value: unknown): this { + this.context.extra = { + ...(this.context.extra || {}), + [key]: value, + }; + return this; + } + + withBreadcrumbs(breadcrumbs: Breadcrumb[]): this { + this.context.breadcrumbs = [...breadcrumbs]; + return this; + } + + /** + * Automatically captures a snapshot of current breadcrumbs from the active tracker. + */ + includeCurrentBreadcrumbs(limit: number = 20): this { + const tracker = getDefaultBreadcrumbTracker(); + const crumbs = tracker.getBreadcrumbs({ limit }); + return this.withBreadcrumbs(crumbs); + } + + /** + * Builds the finalized ErrorContext object. + */ + build(): ErrorContext { + return { + timestamp: Date.now(), + ...this.context, + }; + } + + /** + * Directly attaches the built context to an Error instance. + */ + attachTo(error: T): T { + return attachErrorContext(error, this.build()); + } + + /** + * Wraps a function execution with this context. + */ + wrap(fn: () => T): T { + return withErrorContext(this.build(), fn); + } +} + +/** + * Creates a new ErrorContextBuilder instance. + */ +export function createErrorContext(initial?: Partial): ErrorContextBuilder { + return new ErrorContextBuilder(initial); +} + +/** + * Executes a sync or async function, intercepting any errors to attach contextual metadata before rethrowing. + */ +export function withErrorContext( + context: Partial, + fn: () => T +): T { + try { + const result = fn(); + if (result && typeof (result as unknown as Promise).then === "function") { + return (result as unknown as Promise).catch((err: unknown) => { + if (err instanceof Error) { + attachErrorContext(err, context); + } + throw err; + }) as unknown as T; + } + return result; + } catch (err) { + if (err instanceof Error) { + attachErrorContext(err, context); + } + throw err; + } +} + +/** + * Wraps an unknown error into a StellarSplitError with attached context and message. + */ +export function wrapError( + error: unknown, + message: string, + context?: Partial +): StellarSplitError { + const mergedContext = mergeContext( + getErrorContext(error) || {}, + context || {} + ); + + const raw = error instanceof Error ? error.stack || error.message : String(error); + const code = error instanceof StellarSplitError + ? error.code + : (error as { code?: string })?.code || "WRAPPED_ERROR"; + + const wrapped = new StellarSplitError( + `${message}: ${error instanceof Error ? error.message : String(error)}`, + code, + sanitizeContext(mergedContext), + raw + ); + + return attachErrorContext(wrapped, mergedContext); +} diff --git a/src/errorLogger.ts b/src/errorLogger.ts new file mode 100644 index 0000000..be7037a --- /dev/null +++ b/src/errorLogger.ts @@ -0,0 +1,541 @@ +/** + * Comprehensive error logging system for StellarSplit SDK. + * Supports log levels, flexible formatting (Text, JSON), and reliable storage sinks. + * + * Issue #1069 — Implement error logging + */ + +import { StellarSplitError } from "./errors.js"; + +/** Supported severity levels for logging. */ +export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; + +/** Numeric priority for log levels (higher is more severe). */ +export const LogLevelPriority: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + fatal: 4, +}; + +/** Checks whether a message at targetLevel should be logged given the active minLevel. */ +export function isLogLevelEnabled(minLevel: LogLevel, targetLevel: LogLevel): boolean { + return LogLevelPriority[targetLevel] >= LogLevelPriority[minLevel]; +} + +/** Normalizes a string to a valid LogLevel, falling back to defaultLevel. */ +export function parseLogLevel(value: string | undefined | null, defaultLevel: LogLevel = "info"): LogLevel { + if (!value) return defaultLevel; + const lower = value.toLowerCase().trim(); + if (lower in LogLevelPriority) { + return lower as LogLevel; + } + return defaultLevel; +} + +/** Structured log entry. */ +export interface LogEntry { + /** Timestamp in milliseconds since Unix epoch. */ + timestamp: number; + /** ISO 8601 formatted string timestamp. */ + isoTimestamp: string; + /** Severity level. */ + level: LogLevel; + /** Primary log message. */ + message: string; + /** Optional error details. */ + error?: { + name: string; + message: string; + code?: string; + stack?: string; + raw?: string; + context?: Record; + }; + /** Additional structured metadata. */ + context?: Record; + /** Optional tags for classification. */ + tags?: string[]; + /** Distributed trace or operation identifier. */ + traceId?: string; +} + +/** Formatter function contract for converting a LogEntry into a string. */ +export type LogFormatter = (entry: LogEntry) => string; + +/** Formats log entries as human-readable text. */ +export const TextLogFormatter: LogFormatter = (entry: LogEntry): string => { + const parts: string[] = [ + `[${entry.isoTimestamp}]`, + `[${entry.level.toUpperCase()}]`, + ]; + + if (entry.traceId) { + parts.push(`[${entry.traceId}]`); + } + + if (entry.tags && entry.tags.length > 0) { + parts.push(`[${entry.tags.join(",")}]`); + } + + parts.push(entry.message); + + if (entry.error) { + const errDetails: string[] = []; + if (entry.error.code) errDetails.push(`code=${entry.error.code}`); + errDetails.push(`error="${entry.error.name}: ${entry.error.message}"`); + parts.push(`(${errDetails.join(", ")})`); + } + + if (entry.context && Object.keys(entry.context).length > 0) { + try { + parts.push(`context=${safeStringify(entry.context)}`); + } catch { + parts.push(`context=[unserializable]`); + } + } + + return parts.join(" "); +}; + +/** Formats log entries as structured JSON strings. */ +export const JsonLogFormatter: LogFormatter = (entry: LogEntry): string => { + return safeStringify(entry); +}; + +/** + * Safe JSON serializer handling BigInt and circular structures. + */ +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + return JSON.stringify(value, (_key, val) => { + if (typeof val === "bigint") { + return val.toString(); + } + if (typeof val === "object" && val !== null) { + if (seen.has(val)) { + return "[Circular]"; + } + seen.add(val); + } + return val; + }); +} + +/** Filter criteria for querying stored logs. */ +export interface LogFilter { + /** Exact level match. */ + level?: LogLevel; + /** Minimum severity level match. */ + minLevel?: LogLevel; + /** Include entries at or after this epoch timestamp. */ + since?: number; + /** Include entries at or before this epoch timestamp. */ + until?: number; + /** Substring search across message, error message, or error code. */ + search?: string; + /** Must include at least one of these tags. */ + tags?: string[]; + /** Maximum number of records to return. */ + limit?: number; + /** Number of matching records to skip (for pagination). */ + offset?: number; +} + +/** Storage sink contract for error and operational logs. */ +export interface LogStorage { + /** Write a log entry to storage. */ + write(entry: LogEntry): void; + /** Retrieve logs matching filter criteria. */ + getLogs(filter?: LogFilter): LogEntry[]; + /** Clear all stored logs. */ + clear(): void; + /** Total count of stored logs. */ + count(): number; +} + +/** + * Reliable bounded in-memory ring-buffer storage for logs. + * When capacity is reached, the oldest entries are evicted first. + */ +export class MemoryLogStorage implements LogStorage { + private readonly maxEntries: number; + private entries: LogEntry[] = []; + + constructor(maxEntries: number = 1000) { + this.maxEntries = Math.max(1, maxEntries); + } + + write(entry: LogEntry): void { + if (this.entries.length >= this.maxEntries) { + this.entries.shift(); // Evict oldest + } + this.entries.push(entry); + } + + getLogs(filter?: LogFilter): LogEntry[] { + let result = this.entries; + + if (!filter) { + return [...result]; + } + + if (filter.level) { + result = result.filter((e) => e.level === filter.level); + } + + if (filter.minLevel) { + const minPri = LogLevelPriority[filter.minLevel]; + result = result.filter((e) => LogLevelPriority[e.level] >= minPri); + } + + if (filter.since !== undefined) { + result = result.filter((e) => e.timestamp >= filter.since!); + } + + if (filter.until !== undefined) { + result = result.filter((e) => e.timestamp <= filter.until!); + } + + if (filter.search) { + const query = filter.search.toLowerCase(); + result = result.filter((e) => { + if (e.message.toLowerCase().includes(query)) return true; + if (e.error?.message.toLowerCase().includes(query)) return true; + if (e.error?.code?.toLowerCase().includes(query)) return true; + return false; + }); + } + + if (filter.tags && filter.tags.length > 0) { + result = result.filter((e) => + e.tags ? filter.tags!.some((t) => e.tags!.includes(t)) : false + ); + } + + if (filter.offset && filter.offset > 0) { + result = result.slice(filter.offset); + } + + if (filter.limit !== undefined && filter.limit >= 0) { + result = result.slice(0, filter.limit); + } + + return [...result]; + } + + clear(): void { + this.entries = []; + } + + count(): number { + return this.entries.length; + } +} + +/** Console storage sink that formats and logs to console. */ +export class ConsoleLogStorage implements LogStorage { + private readonly formatter: LogFormatter; + private readonly enabled: boolean; + private memoryFallback?: MemoryLogStorage; + + constructor(formatter: LogFormatter = TextLogFormatter, enabled: boolean = true, retainHistory: boolean = false) { + this.formatter = formatter; + this.enabled = enabled; + if (retainHistory) { + this.memoryFallback = new MemoryLogStorage(200); + } + } + + write(entry: LogEntry): void { + if (this.memoryFallback) { + this.memoryFallback.write(entry); + } + + if (!this.enabled) return; + + const formatted = this.formatter(entry); + switch (entry.level) { + case "debug": + console.debug(formatted); + break; + case "info": + console.info(formatted); + break; + case "warn": + console.warn(formatted); + break; + case "error": + case "fatal": + console.error(formatted); + break; + } + } + + getLogs(filter?: LogFilter): LogEntry[] { + return this.memoryFallback ? this.memoryFallback.getLogs(filter) : []; + } + + clear(): void { + this.memoryFallback?.clear(); + } + + count(): number { + return this.memoryFallback ? this.memoryFallback.count() : 0; + } +} + +/** Multiplexes log entries to multiple storage sinks. */ +export class MultiLogStorage implements LogStorage { + private readonly sinks: LogStorage[]; + + constructor(sinks: LogStorage[]) { + this.sinks = sinks; + } + + write(entry: LogEntry): void { + for (const sink of this.sinks) { + try { + sink.write(entry); + } catch { + // Storage errors in one sink should not halt other sinks + } + } + } + + getLogs(filter?: LogFilter): LogEntry[] { + if (this.sinks.length === 0) return []; + return this.sinks[0].getLogs(filter); + } + + clear(): void { + for (const sink of this.sinks) { + sink.clear(); + } + } + + count(): number { + return this.sinks.length > 0 ? this.sinks[0].count() : 0; + } +} + +/** Configuration options for ErrorLogger. */ +export interface ErrorLoggerOptions { + /** Minimum log level to record. Defaults to 'info'. */ + minLevel?: LogLevel; + /** Storage sink. Defaults to MemoryLogStorage with 1,000 max entries. */ + storage?: LogStorage; + /** Formatting function for text/json representation. Defaults to TextLogFormatter. */ + formatter?: LogFormatter; + /** Default metadata attached to all logs created by this instance. */ + defaultContext?: Record; + /** Default tags attached to all logs created by this instance. */ + defaultTags?: string[]; + /** Optional function returning active trace ID. */ + traceIdProvider?: () => string | undefined; +} + +/** + * ErrorLogger provides structured, level-aware error and operational logging. + */ +export class ErrorLogger { + private minLevel: LogLevel; + private storage: LogStorage; + private formatter: LogFormatter; + private defaultContext: Record; + private defaultTags: string[]; + private traceIdProvider?: () => string | undefined; + + constructor(options: ErrorLoggerOptions = {}) { + this.minLevel = options.minLevel ?? "info"; + this.storage = options.storage ?? new MemoryLogStorage(1000); + this.formatter = options.formatter ?? TextLogFormatter; + this.defaultContext = options.defaultContext ? { ...options.defaultContext } : {}; + this.defaultTags = options.defaultTags ? [...options.defaultTags] : []; + this.traceIdProvider = options.traceIdProvider; + } + + /** Gets the active minimum log level. */ + getLevel(): LogLevel { + return this.minLevel; + } + + /** Sets the active minimum log level. */ + setLevel(level: LogLevel): void { + this.minLevel = level; + } + + /** Sets a new storage sink. */ + setStorage(storage: LogStorage): void { + this.storage = storage; + } + + /** Gets the configured storage sink. */ + getStorage(): LogStorage { + return this.storage; + } + + /** Sets the log formatter. */ + setFormatter(formatter: LogFormatter): void { + this.formatter = formatter; + } + + /** Gets the current formatter. */ + getFormatter(): LogFormatter { + return this.formatter; + } + + /** Creates a child logger with inherited settings and merged context/tags. */ + child(childContext: Record = {}, childTags: string[] = []): ErrorLogger { + return new ErrorLogger({ + minLevel: this.minLevel, + storage: this.storage, + formatter: this.formatter, + defaultContext: { ...this.defaultContext, ...childContext }, + defaultTags: Array.from(new Set([...this.defaultTags, ...childTags])), + traceIdProvider: this.traceIdProvider, + }); + } + + /** Primary logging method. Returns the created LogEntry or null if filtered out. */ + log( + level: LogLevel, + message: string, + options?: { + error?: unknown; + context?: Record; + tags?: string[]; + traceId?: string; + } + ): LogEntry | null { + if (!isLogLevelEnabled(this.minLevel, level)) { + return null; + } + + const now = Date.now(); + const traceId = options?.traceId ?? (this.traceIdProvider ? this.traceIdProvider() : undefined); + + let errorDetail: LogEntry["error"]; + if (options?.error) { + const err = options.error; + if (err instanceof StellarSplitError) { + errorDetail = { + name: err.name, + message: err.message, + code: err.code, + stack: err.stack, + raw: err.raw, + context: err.context, + }; + } else if (err instanceof Error) { + errorDetail = { + name: err.name, + message: err.message, + code: (err as { code?: string }).code, + stack: err.stack, + }; + } else { + errorDetail = { + name: "NonErrorThrown", + message: String(err), + }; + } + } + + const mergedContext = { + ...this.defaultContext, + ...(options?.context || {}), + }; + + const mergedTags = Array.from( + new Set([...this.defaultTags, ...(options?.tags || [])]) + ); + + const entry: LogEntry = { + timestamp: now, + isoTimestamp: new Date(now).toISOString(), + level, + message, + error: errorDetail, + context: Object.keys(mergedContext).length > 0 ? mergedContext : undefined, + tags: mergedTags.length > 0 ? mergedTags : undefined, + traceId, + }; + + this.storage.write(entry); + return entry; + } + + /** Log at DEBUG level. */ + debug(message: string, context?: Record, error?: unknown): LogEntry | null { + return this.log("debug", message, { context, error }); + } + + /** Log at INFO level. */ + info(message: string, context?: Record, error?: unknown): LogEntry | null { + return this.log("info", message, { context, error }); + } + + /** Log at WARN level. */ + warn(message: string, context?: Record, error?: unknown): LogEntry | null { + return this.log("warn", message, { context, error }); + } + + /** Log at ERROR level. */ + error(message: string, error?: unknown, context?: Record): LogEntry | null { + return this.log("error", message, { error, context }); + } + + /** Log at FATAL level. */ + fatal(message: string, error?: unknown, context?: Record): LogEntry | null { + return this.log("fatal", message, { error, context }); + } + + /** Retrieve stored logs matching filter criteria. */ + getLogs(filter?: LogFilter): LogEntry[] { + return this.storage.getLogs(filter); + } + + /** Clear all stored logs. */ + clear(): void { + this.storage.clear(); + } + + /** Total count of stored logs. */ + count(): number { + return this.storage.count(); + } + + /** Format a given log entry using the active formatter. */ + format(entry: LogEntry): string { + return this.formatter(entry); + } +} + +// Global default logger singleton +let defaultErrorLogger = new ErrorLogger({ minLevel: "info" }); + +/** Gets the default global ErrorLogger instance. */ +export function getDefaultErrorLogger(): ErrorLogger { + return defaultErrorLogger; +} + +/** Sets the default global ErrorLogger instance. */ +export function setDefaultErrorLogger(logger: ErrorLogger): void { + defaultErrorLogger = logger; +} + +/** Resets the default ErrorLogger to initial state. */ +export function resetDefaultErrorLogger(): void { + defaultErrorLogger = new ErrorLogger({ minLevel: "info" }); +} + +/** Convenience function to log an error with the default logger. */ +export function logError( + error: unknown, + message: string = "An error occurred", + context?: Record +): LogEntry | null { + return defaultErrorLogger.error(message, error, context); +} diff --git a/src/index.ts b/src/index.ts index bf64e06..82f5ccd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1714,3 +1714,88 @@ export type { RetirementManagerOptions, RetirementEventMap, } from "./invoiceRetirement.js"; + +// Error Logging (#1069) +export { + ErrorLogger, + MemoryLogStorage, + ConsoleLogStorage, + MultiLogStorage, + TextLogFormatter, + JsonLogFormatter, + LogLevelPriority, + isLogLevelEnabled, + parseLogLevel, + getDefaultErrorLogger, + setDefaultErrorLogger, + resetDefaultErrorLogger, + logError, +} from "./errorLogger.js"; +export type { + LogLevel, + LogEntry, + LogFilter, + LogFormatter, + LogStorage, + ErrorLoggerOptions, +} from "./errorLogger.js"; + +// Breadcrumb Tracking (#1070) +export { + BreadcrumbTracker, + RingBufferBreadcrumbStorage, + getDefaultBreadcrumbTracker, + setDefaultBreadcrumbTracker, + resetDefaultBreadcrumbTracker, + addBreadcrumb, + getBreadcrumbs, + clearBreadcrumbs, +} from "./breadcrumbTracker.js"; +export type { + Breadcrumb, + BreadcrumbCategory, + BreadcrumbLevel, + BreadcrumbStorage, + BreadcrumbFilter, + BreadcrumbTrackerOptions, +} from "./breadcrumbTracker.js"; + +// Error Context (#1071) +export { + ERROR_CONTEXT_SYMBOL, + sanitizeContext, + mergeContext, + attachErrorContext, + getErrorContext, + hasErrorContext, + serializeError, + serializeErrorContext, + ErrorContextBuilder, + createErrorContext, + withErrorContext, + wrapError, +} from "./errorContext.js"; +export type { + ErrorContext, + SerializedError, +} from "./errorContext.js"; + +// Error Aggregation (#1072) +export { + ErrorAggregator, + normalizeErrorMessage, + getDefaultErrorAggregator, + setDefaultErrorAggregator, + resetDefaultErrorAggregator, + aggregateError, +} from "./errorAggregator.js"; +export type { + GroupingStrategyType, + CustomGroupingResolver, + ErrorGroupingStrategy, + AggregatedErrorGroup, + ErrorPattern, + ReportOptions, + ErrorAggregationReport, + ErrorAggregatorOptions, +} from "./errorAggregator.js"; diff --git a/test/breadcrumbTracker.test.ts b/test/breadcrumbTracker.test.ts new file mode 100644 index 0000000..8d1b4c0 --- /dev/null +++ b/test/breadcrumbTracker.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + BreadcrumbTracker, + RingBufferBreadcrumbStorage, + getDefaultBreadcrumbTracker, + setDefaultBreadcrumbTracker, + resetDefaultBreadcrumbTracker, + addBreadcrumb, + getBreadcrumbs, + clearBreadcrumbs, +} from "../src/breadcrumbTracker.js"; + +describe("Issue #1070 — Breadcrumb Tracking", () => { + beforeEach(() => { + resetDefaultBreadcrumbTracker(); + }); + + describe("Storage Efficiency (RingBufferBreadcrumbStorage)", () => { + it("maintains a fixed maximum capacity and evicts oldest items in FIFO order", () => { + const storage = new RingBufferBreadcrumbStorage(3); + expect(storage.size()).toBe(0); + + storage.add({ + id: "1", + timestamp: 100, + category: "ui", + message: "Click 1", + }); + storage.add({ + id: "2", + timestamp: 200, + category: "ui", + message: "Click 2", + }); + storage.add({ + id: "3", + timestamp: 300, + category: "ui", + message: "Click 3", + }); + + expect(storage.size()).toBe(3); + expect(storage.getAll().map((b) => b.id)).toEqual(["1", "2", "3"]); + + // Add 4th item -> should evict item "1" and preserve chronological order + storage.add({ + id: "4", + timestamp: 400, + category: "ui", + message: "Click 4", + }); + + expect(storage.size()).toBe(3); + expect(storage.getAll().map((b) => b.id)).toEqual(["2", "3", "4"]); + + // Add 5th item -> should evict item "2" + storage.add({ + id: "5", + timestamp: 500, + category: "ui", + message: "Click 5", + }); + + expect(storage.size()).toBe(3); + expect(storage.getAll().map((b) => b.id)).toEqual(["3", "4", "5"]); + }); + + it("clears storage efficiently", () => { + const storage = new RingBufferBreadcrumbStorage(5); + storage.add({ id: "1", timestamp: 100, category: "http", message: "req" }); + expect(storage.size()).toBe(1); + + storage.clear(); + expect(storage.size()).toBe(0); + expect(storage.getAll()).toEqual([]); + }); + }); + + describe("Tracker Implementation & Rich Context Helpers", () => { + it("records basic breadcrumbs with automatic timestamp and ID", () => { + const tracker = new BreadcrumbTracker({ maxBreadcrumbs: 10 }); + const crumb = tracker.addBreadcrumb({ + category: "custom", + message: "Manual step", + level: "info", + data: { step: 1 }, + }); + + expect(crumb).not.toBeNull(); + expect(crumb!.id).toBeDefined(); + expect(crumb!.timestamp).toBeGreaterThan(0); + expect(crumb!.message).toBe("Manual step"); + expect(crumb!.data).toEqual({ step: 1 }); + expect(tracker.count()).toBe(1); + }); + + it("tracks rich HTTP request/response breadcrumbs", () => { + const tracker = new BreadcrumbTracker(); + const crumb = tracker.trackHttp("POST", "https://horizon-testnet.stellar.org/transactions", 200, 150, { + txId: "tx-abc", + }); + + expect(crumb).not.toBeNull(); + expect(crumb!.category).toBe("http"); + expect(crumb!.message).toContain("POST https://horizon-testnet.stellar.org/transactions [200]"); + expect(crumb!.data?.method).toBe("POST"); + expect(crumb!.data?.status).toBe(200); + expect(crumb!.data?.durationMs).toBe(150); + expect(crumb!.data?.txId).toBe("tx-abc"); + }); + + it("tracks rich transaction lifecycle events", () => { + const tracker = new BreadcrumbTracker(); + tracker.trackTransaction("0xabcdef123456", "payInvoice", "submitted"); + const failed = tracker.trackTransaction("0xabcdef123456", "payInvoice", "failed", { reason: "insufficient_balance" }); + + expect(tracker.count()).toBe(2); + expect(failed!.level).toBe("error"); + expect(failed!.data?.status).toBe("failed"); + expect(failed!.data?.reason).toBe("insufficient_balance"); + }); + + it("tracks rich RPC calls, state changes, and user actions", () => { + const tracker = new BreadcrumbTracker(); + + tracker.trackRpc("simulateTransaction", "https://soroban-rpc.stellar.org", "success", 42); + tracker.trackStateChange("Pending", "Paid", "inv-999"); + tracker.trackUserAction("submit_payment_button_clicked", { buttonId: "btn-pay" }); + tracker.trackError(new Error("Network timeout"), { retryAttempt: 2 }); + + expect(tracker.count()).toBe(4); + const crumbs = tracker.getBreadcrumbs(); + expect(crumbs[0].category).toBe("rpc"); + expect(crumbs[1].category).toBe("state"); + expect(crumbs[1].data?.fromState).toBe("Pending"); + expect(crumbs[1].data?.toState).toBe("Paid"); + expect(crumbs[2].category).toBe("ui"); + expect(crumbs[3].category).toBe("error"); + expect(crumbs[3].level).toBe("error"); + }); + + it("respects beforeAddBreadcrumb sanitization hook", () => { + const tracker = new BreadcrumbTracker({ + beforeAddBreadcrumb: (crumb) => { + if (crumb.message.includes("ignore_me")) return null; + return { + ...crumb, + message: `Sanitized: ${crumb.message}`, + }; + }, + }); + + tracker.addBreadcrumb({ message: "normal message" }); + tracker.addBreadcrumb({ message: "ignore_me message" }); + + expect(tracker.count()).toBe(1); + expect(tracker.getLastBreadcrumb()?.message).toBe("Sanitized: normal message"); + }); + }); + + describe("Fast Retrieval & Filtering", () => { + it("filters breadcrumbs by category, level, time window, and limit", () => { + const tracker = new BreadcrumbTracker({ maxBreadcrumbs: 20 }); + + tracker.addBreadcrumb({ category: "http", message: "req 1", timestamp: 100, level: "info" }); + tracker.addBreadcrumb({ category: "rpc", message: "rpc 1", timestamp: 200, level: "debug" }); + tracker.addBreadcrumb({ category: "http", message: "req 2", timestamp: 300, level: "warn" }); + tracker.addBreadcrumb({ category: "transaction", message: "tx 1", timestamp: 400, level: "error" }); + + // Filter by category + expect(tracker.getBreadcrumbs({ category: "http" })).toHaveLength(2); + expect(tracker.getBreadcrumbs({ category: ["http", "rpc"] })).toHaveLength(3); + + // Filter by level + expect(tracker.getBreadcrumbs({ level: "error" })).toHaveLength(1); + + // Filter by time range + expect(tracker.getBreadcrumbs({ since: 150, until: 350 })).toHaveLength(2); + + // Limit to latest N + const latest2 = tracker.getBreadcrumbs({ limit: 2 }); + expect(latest2).toHaveLength(2); + expect(latest2.map((b) => b.message)).toEqual(["req 2", "tx 1"]); + }); + + it("snapshot returns a safe independent copy of current breadcrumbs", () => { + const tracker = new BreadcrumbTracker(); + tracker.addBreadcrumb({ message: "Crumb A" }); + + const snap = tracker.snapshot(); + expect(snap).toHaveLength(1); + + tracker.addBreadcrumb({ message: "Crumb B" }); + expect(snap).toHaveLength(1); + expect(tracker.count()).toBe(2); + }); + }); + + describe("Global Default BreadcrumbTracker", () => { + it("supports top-level addBreadcrumb, getBreadcrumbs, and clearBreadcrumbs", () => { + addBreadcrumb({ category: "ui", message: "Global step 1" }); + addBreadcrumb({ category: "http", message: "Global step 2" }); + + expect(getBreadcrumbs()).toHaveLength(2); + + clearBreadcrumbs(); + expect(getBreadcrumbs()).toHaveLength(0); + }); + + it("allows replacing the default tracker instance", () => { + const customTracker = new BreadcrumbTracker({ maxBreadcrumbs: 5 }); + setDefaultBreadcrumbTracker(customTracker); + + expect(getDefaultBreadcrumbTracker()).toBe(customTracker); + }); + }); +}); diff --git a/test/errorAggregator.test.ts b/test/errorAggregator.test.ts new file mode 100644 index 0000000..e4e6b26 --- /dev/null +++ b/test/errorAggregator.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + ErrorAggregator, + normalizeErrorMessage, + getDefaultErrorAggregator, + setDefaultErrorAggregator, + resetDefaultErrorAggregator, + aggregateError, +} from "../src/errorAggregator.js"; +import { + StellarSplitError, + InvoiceNotFoundError, + DeadlinePassedError, +} from "../src/errors.js"; + +describe("Issue #1072 — Error Aggregation", () => { + beforeEach(() => { + resetDefaultErrorAggregator(); + }); + + describe("Message Normalization", () => { + it("normalizes dynamic identifiers in error messages", () => { + const msg = "Invoice inv-abc12345 payment of 500.50 failed for account GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 tx 0xabcdef1234567890"; + const normalized = normalizeErrorMessage(msg); + + expect(normalized).toContain(""); + expect(normalized).toContain(""); + expect(normalized).toContain(""); + expect(normalized).toContain(""); + expect(normalized).not.toContain("inv-abc12345"); + expect(normalized).not.toContain("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); + }); + }); + + describe("Aggregation & Grouping Logic", () => { + it("groups related errors logically by code", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + aggregator.record(new InvoiceNotFoundError("inv-1")); + aggregator.record(new InvoiceNotFoundError("inv-2")); + aggregator.record(new DeadlinePassedError("inv-3")); + + const groups = aggregator.getGroups(); + expect(groups).toHaveLength(2); + + const notFoundGroup = groups.find((g) => g.groupKey === "INVOICE_NOT_FOUND"); + expect(notFoundGroup).toBeDefined(); + expect(notFoundGroup?.count).toBe(2); + expect(notFoundGroup?.affectedInvoices).toEqual(expect.arrayContaining(["inv-1", "inv-2"])); + + const deadlineGroup = groups.find((g) => g.groupKey === "DEADLINE_PASSED"); + expect(deadlineGroup).toBeDefined(); + expect(deadlineGroup?.count).toBe(1); + }); + + it("groups related errors by normalized message", () => { + const aggregator = new ErrorAggregator({ strategy: "message" }); + + aggregator.record(new Error("Payment of 100 stroops to GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 failed")); + aggregator.record(new Error("Payment of 500 stroops to GCFE57IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 failed")); + + const groups = aggregator.getGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + expect(groups[0].groupKey).toContain(""); + }); + + it("supports custom grouping functions", () => { + const aggregator = new ErrorAggregator({ + strategy: (_err, context) => `component:${context?.component || "unknown"}`, + }); + + aggregator.record(new Error("Err 1"), { component: "indexer" }); + aggregator.record(new Error("Err 2"), { component: "indexer" }); + aggregator.record(new Error("Err 3"), { component: "relayer" }); + + const groups = aggregator.getGroups(); + expect(groups).toHaveLength(2); + expect(groups.find((g) => g.groupKey === "component:indexer")?.count).toBe(2); + expect(groups.find((g) => g.groupKey === "component:relayer")?.count).toBe(1); + }); + + it("computes common context across all errors in a group", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + aggregator.record(new StellarSplitError("Fail 1", "RPC_ERROR"), { + network: "testnet", + endpoint: "https://rpc1.stellar.org", + attempt: 1, + }); + + aggregator.record(new StellarSplitError("Fail 2", "RPC_ERROR"), { + network: "testnet", + endpoint: "https://rpc1.stellar.org", + attempt: 2, + }); + + const groups = aggregator.getGroups(); + expect(groups).toHaveLength(1); + expect(groups[0].commonContext).toEqual({ + network: "testnet", + endpoint: "https://rpc1.stellar.org", + }); + // attempt varied between 1 and 2, so it must not be in commonContext + expect(groups[0].commonContext.attempt).toBeUndefined(); + }); + + it("ingests batch errors efficiently", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + const batch = [ + new InvoiceNotFoundError("inv-1"), + new InvoiceNotFoundError("inv-2"), + new DeadlinePassedError("inv-3"), + ]; + + aggregator.recordBatch(batch); + expect(aggregator.totalCount()).toBe(3); + expect(aggregator.getGroups()).toHaveLength(2); + }); + }); + + describe("Pattern Detection & Visibility", () => { + it("detects error spike patterns", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + // Record 6 rapid errors within the current window + for (let i = 0; i < 6; i++) { + aggregator.record(new StellarSplitError("RPC timeout", "RPC_TIMEOUT")); + } + + const patterns = aggregator.detectPatterns(); + const spike = patterns.find((p) => p.type === "spike"); + + expect(spike).toBeDefined(); + expect(spike?.groupKey).toBe("RPC_TIMEOUT"); + expect(spike?.severity).toBe("high"); + expect(spike?.description).toContain("Spike detected"); + }); + + it("detects high frequency dominant error patterns", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + // 10 total errors: 8 are RATE_LIMITED (80%), 2 are NOT_FOUND (20%) + for (let i = 0; i < 8; i++) { + aggregator.record(new StellarSplitError("Rate limit", "RATE_LIMITED")); + } + for (let i = 0; i < 2; i++) { + aggregator.record(new StellarSplitError("Not found", "NOT_FOUND")); + } + + const patterns = aggregator.detectPatterns(); + const highFreq = patterns.find((p) => p.type === "high_frequency"); + + expect(highFreq).toBeDefined(); + expect(highFreq?.groupKey).toBe("RATE_LIMITED"); + expect(highFreq?.description).toContain("Dominant error pattern"); + }); + + it("detects recurring entity failures on the same invoice", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + aggregator.record(new StellarSplitError("Err A", "CODE_A"), { invoiceId: "inv-repeat-1" }); + aggregator.record(new StellarSplitError("Err B", "CODE_B"), { invoiceId: "inv-repeat-1" }); + aggregator.record(new StellarSplitError("Err C", "CODE_C"), { invoiceId: "inv-repeat-1" }); + + const patterns = aggregator.detectPatterns(); + const entityPattern = patterns.find((p) => p.type === "recurring_entity" && p.entityId === "inv-repeat-1"); + + expect(entityPattern).toBeDefined(); + expect(entityPattern?.description).toContain("Invoice \"inv-repeat-1\" has failed 3 times"); + }); + + it("detects cascading failures across multiple distinct error groups", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + aggregator.record(new StellarSplitError("Fail 1", "RPC_ERROR")); + aggregator.record(new StellarSplitError("Fail 2", "DB_ERROR")); + aggregator.record(new StellarSplitError("Fail 3", "AUTH_ERROR")); + + const patterns = aggregator.detectPatterns(); + const cascading = patterns.find((p) => p.type === "cascading_failure"); + + expect(cascading).toBeDefined(); + expect(cascading?.severity).toBe("critical"); + expect(cascading?.description).toContain("Cascading system failure"); + }); + }); + + describe("Clear Reporting (Text, Markdown, JSON)", () => { + it("generates a comprehensive report with clear text and markdown output", () => { + const aggregator = new ErrorAggregator({ strategy: "code" }); + + for (let i = 0; i < 3; i++) { + aggregator.record(new InvoiceNotFoundError(`inv-${i}`), { accountId: "GA123" }); + } + aggregator.record(new DeadlinePassedError("inv-99")); + + const report = aggregator.generateReport(); + + expect(report.totalErrors).toBe(4); + expect(report.uniqueGroups).toBe(2); + expect(report.topErrors).toHaveLength(2); + expect(report.topErrors[0].groupKey).toBe("INVOICE_NOT_FOUND"); + expect(report.topErrors[0].percentage).toBe(75); + + // Check text format + const text = report.formatAsText(); + expect(text).toContain("STELLARSPLIT ERROR REPORT"); + expect(text).toContain("Total Errors: 4 across 2 distinct group(s)"); + expect(text).toContain("INVOICE_NOT_FOUND"); + expect(text).toContain("GA123"); + + // Check markdown format + const md = report.formatAsMarkdown(); + expect(md).toContain("# StellarSplit Error Aggregation Report"); + expect(md).toContain("| Rank | Group | Count | Percentage |"); + expect(md).toContain("`INVOICE_NOT_FOUND`"); + + // Check JSON format + const json = report.toJSON(); + const parsed = JSON.parse(json); + expect(parsed.totalErrors).toBe(4); + expect(parsed.uniqueGroups).toBe(2); + }); + + it("clears aggregator and resets state", () => { + const aggregator = new ErrorAggregator(); + aggregator.record(new Error("Test error")); + expect(aggregator.totalCount()).toBe(1); + + aggregator.clear(); + expect(aggregator.totalCount()).toBe(0); + expect(aggregator.getGroups()).toHaveLength(0); + }); + + it("works with default global aggregator and aggregateError helper", () => { + const grp = aggregateError(new Error("Default helper error")); + expect(grp).toBeDefined(); + expect(getDefaultErrorAggregator().totalCount()).toBe(1); + }); + }); +}); diff --git a/test/errorContext.test.ts b/test/errorContext.test.ts new file mode 100644 index 0000000..9bbad69 --- /dev/null +++ b/test/errorContext.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + attachErrorContext, + getErrorContext, + hasErrorContext, + sanitizeContext, + mergeContext, + serializeError, + serializeErrorContext, + createErrorContext, + withErrorContext, + wrapError, + ERROR_CONTEXT_SYMBOL, +} from "../src/errorContext.js"; +import { StellarSplitError, InvoiceNotFoundError } from "../src/errors.js"; +import { addBreadcrumb, clearBreadcrumbs } from "../src/breadcrumbTracker.js"; + +describe("Issue #1071 — Error Context", () => { + beforeEach(() => { + clearBreadcrumbs(); + }); + + describe("Context Attachment", () => { + it("attaches context to standard Error instances and retrieves it", () => { + const err = new Error("Payment execution failed"); + expect(hasErrorContext(err)).toBe(false); + + attachErrorContext(err, { + operation: "pay", + invoiceId: "inv-42", + network: "testnet", + }); + + expect(hasErrorContext(err)).toBe(true); + const ctx = getErrorContext(err); + expect(ctx).toBeDefined(); + expect(ctx?.operation).toBe("pay"); + expect(ctx?.invoiceId).toBe("inv-42"); + expect(ctx?.network).toBe("testnet"); + expect(ctx?.timestamp).toBeGreaterThan(0); + expect((err as unknown as Record).context).toBeDefined(); + expect((err as unknown as Record)[ERROR_CONTEXT_SYMBOL]).toBeDefined(); + }); + + it("attaches context to StellarSplitError instances and merges with existing context", () => { + const err = new InvoiceNotFoundError("inv-99"); + expect(err.context).toEqual({ invoiceId: "inv-99" }); + + attachErrorContext(err, { + operation: "getInvoice", + accountId: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + tags: { env: "staging" }, + }); + + const ctx = getErrorContext(err); + expect(ctx?.invoiceId).toBe("inv-99"); + expect(ctx?.operation).toBe("getInvoice"); + expect(ctx?.accountId).toBe("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); + expect(ctx?.tags).toEqual({ env: "staging" }); + }); + }); + + describe("Utilities: Sanitization, Merge & Serialization", () => { + it("sanitizeContext masks Stellar secret seeds and credential keys", () => { + const sensitiveData = { + seed: "SBVD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + apiKey: "sk_live_1234567890", + nested: { + userPassword: "SuperSecretPassword123!", + authToken: "bearer xyz-token", + publicAddress: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + description: "Sent from account using secret SBVD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 for auth", + }; + + const sanitized = sanitizeContext(sensitiveData); + + expect(sanitized.seed).toBe("***MASKED***"); + expect(sanitized.apiKey).toBe("***MASKED***"); + expect(sanitized.nested.userPassword).toBe("***MASKED***"); + expect(sanitized.nested.authToken).toBe("***MASKED***"); + expect(sanitized.nested.publicAddress).toBe("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); + expect(sanitized.description).toContain("S***[MASKED]"); + }); + + it("sanitizeContext safely handles BigInt and circular references without crashing", () => { + const circular: Record = { + amount: 5000000000n, + }; + circular.loop = circular; + + const sanitized = sanitizeContext(circular); + + expect(sanitized.amount).toBe("5000000000"); + expect(sanitized.loop).toBe("[Circular]"); + }); + + it("mergeContext combines tags, extra, and breadcrumbs", () => { + const base = { + operation: "batchPay", + tags: { region: "us-east" }, + extra: { step: 1 }, + breadcrumbs: [{ id: "bc-1", timestamp: 100, category: "http", message: "req 1" }], + }; + + const overlay = { + invoiceId: "inv-1", + tags: { tier: "premium" }, + extra: { step: 2, retried: true }, + breadcrumbs: [{ id: "bc-2", timestamp: 200, category: "rpc", message: "rpc 1" }], + }; + + const merged = mergeContext(base, overlay); + + expect(merged.operation).toBe("batchPay"); + expect(merged.invoiceId).toBe("inv-1"); + expect(merged.tags).toEqual({ region: "us-east", tier: "premium" }); + expect(merged.extra).toEqual({ step: 2, retried: true }); + expect(merged.breadcrumbs).toHaveLength(2); + expect(merged.breadcrumbs?.map((b) => b.id)).toEqual(["bc-1", "bc-2"]); + }); + + it("serializeError produces a pure JSON-serializable object", () => { + const circular: Record = { amount: 1000n }; + circular.self = circular; + + const err = new StellarSplitError("Soroban contract failure", "CONTRACT_PANIC", circular, "raw-panic-str"); + const serialized = serializeError(err); + + expect(serialized.name).toBe("StellarSplitError"); + expect(serialized.message).toBe("Soroban contract failure"); + expect(serialized.code).toBe("CONTRACT_PANIC"); + expect(serialized.raw).toBe("raw-panic-str"); + expect(serialized.timestamp).toBeGreaterThan(0); + + // Must be safely JSON serializable with zero errors + const json = JSON.stringify(serialized); + expect(typeof json).toBe("string"); + const parsed = JSON.parse(json); + expect(parsed.context.amount).toBe("1000"); + expect(parsed.context.self).toBe("[Circular]"); + }); + + it("serializeErrorContext serializes context directly to JSON string", () => { + const jsonStr = serializeErrorContext({ + operation: "createInvoice", + extra: { balance: 999999999999999999n }, + }); + + expect(jsonStr).toContain('"operation":"createInvoice"'); + expect(jsonStr).toContain('"balance":"999999999999999999"'); + }); + }); + + describe("Helpers: ErrorContextBuilder & withErrorContext", () => { + it("ErrorContextBuilder fluently constructs and attaches context", () => { + addBreadcrumb({ category: "ui", message: "Step 1 click" }); + addBreadcrumb({ category: "rpc", message: "Step 2 send" }); + + const err = new Error("Failed during execution"); + createErrorContext() + .withOperation("closeRound") + .withInvoiceId("inv-round-1") + .withAccountId("GA...") + .withNetwork("public") + .withTag("component", "roundManager") + .withExtra("roundEnd", 1800000000) + .includeCurrentBreadcrumbs() + .attachTo(err); + + const ctx = getErrorContext(err); + expect(ctx?.operation).toBe("closeRound"); + expect(ctx?.invoiceId).toBe("inv-round-1"); + expect(ctx?.tags?.component).toBe("roundManager"); + expect(ctx?.extra?.roundEnd).toBe(1800000000); + expect(ctx?.breadcrumbs).toHaveLength(2); + }); + + it("withErrorContext automatically decorates synchronous errors", () => { + expect(() => { + withErrorContext({ operation: "syncCalc", invoiceId: "inv-calc" }, () => { + throw new Error("Division by zero"); + }); + }).toThrow("Division by zero"); + + try { + withErrorContext({ operation: "syncCalc", invoiceId: "inv-calc" }, () => { + throw new Error("Division by zero"); + }); + } catch (e) { + const ctx = getErrorContext(e); + expect(ctx?.operation).toBe("syncCalc"); + expect(ctx?.invoiceId).toBe("inv-calc"); + } + }); + + it("withErrorContext automatically decorates asynchronous rejected promises", async () => { + await expect( + withErrorContext({ operation: "asyncFetch", network: "testnet" }, async () => { + throw new Error("Horizon offline"); + }) + ).rejects.toThrow("Horizon offline"); + + try { + await withErrorContext({ operation: "asyncFetch", network: "testnet" }, async () => { + throw new Error("Horizon offline"); + }); + } catch (e) { + const ctx = getErrorContext(e); + expect(ctx?.operation).toBe("asyncFetch"); + expect(ctx?.network).toBe("testnet"); + } + }); + + it("wrapError creates a StellarSplitError wrapping another error with context", () => { + const original = new Error("Connection refused"); + const wrapped = wrapError(original, "Failed to submit invoice", { + operation: "submitInvoice", + invoiceId: "inv-wrap-1", + }); + + expect(wrapped).toBeInstanceOf(StellarSplitError); + expect(wrapped.message).toContain("Failed to submit invoice: Connection refused"); + expect(wrapped.context?.operation).toBe("submitInvoice"); + expect(wrapped.context?.invoiceId).toBe("inv-wrap-1"); + }); + }); +}); diff --git a/test/errorLogger.test.ts b/test/errorLogger.test.ts new file mode 100644 index 0000000..4a2a922 --- /dev/null +++ b/test/errorLogger.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + ErrorLogger, + MemoryLogStorage, + ConsoleLogStorage, + MultiLogStorage, + TextLogFormatter, + JsonLogFormatter, + LogLevelPriority, + isLogLevelEnabled, + parseLogLevel, + getDefaultErrorLogger, + setDefaultErrorLogger, + resetDefaultErrorLogger, + logError, + type LogEntry, +} from "../src/errorLogger.js"; +import { StellarSplitError } from "../src/errors.js"; + +describe("Issue #1069 — Error Logging", () => { + beforeEach(() => { + resetDefaultErrorLogger(); + }); + + describe("Log Levels", () => { + it("has clear numeric priorities", () => { + expect(LogLevelPriority.debug).toBeLessThan(LogLevelPriority.info); + expect(LogLevelPriority.info).toBeLessThan(LogLevelPriority.warn); + expect(LogLevelPriority.warn).toBeLessThan(LogLevelPriority.error); + expect(LogLevelPriority.error).toBeLessThan(LogLevelPriority.fatal); + }); + + it("evaluates isLogLevelEnabled correctly", () => { + expect(isLogLevelEnabled("warn", "debug")).toBe(false); + expect(isLogLevelEnabled("warn", "info")).toBe(false); + expect(isLogLevelEnabled("warn", "warn")).toBe(true); + expect(isLogLevelEnabled("warn", "error")).toBe(true); + expect(isLogLevelEnabled("warn", "fatal")).toBe(true); + }); + + it("parses log level strings with fallback", () => { + expect(parseLogLevel("DEBUG")).toBe("debug"); + expect(parseLogLevel(" warn ")).toBe("warn"); + expect(parseLogLevel("invalid", "error")).toBe("error"); + expect(parseLogLevel(null)).toBe("info"); + }); + }); + + describe("Formatting", () => { + const sampleEntry: LogEntry = { + timestamp: 1700000000000, + isoTimestamp: new Date(1700000000000).toISOString(), + level: "error", + message: "Transaction failed", + traceId: "trace-xyz", + tags: ["stellar", "rpc"], + error: { + name: "StellarSplitError", + message: "Deadline passed", + code: "DEADLINE_PASSED", + }, + context: { + invoiceId: "inv-123", + amount: 1000n, // BigInt handling + }, + }; + + it("TextLogFormatter produces a clear human-readable string", () => { + const text = TextLogFormatter(sampleEntry); + expect(text).toContain("[ERROR]"); + expect(text).toContain("[trace-xyz]"); + expect(text).toContain("[stellar,rpc]"); + expect(text).toContain("Transaction failed"); + expect(text).toContain("code=DEADLINE_PASSED"); + expect(text).toContain("Deadline passed"); + expect(text).toContain("inv-123"); + }); + + it("JsonLogFormatter produces valid JSON and handles BigInt and circular structures", () => { + const circular: Record = { key: "value" }; + circular.self = circular; + + const entryWithCircular: LogEntry = { + ...sampleEntry, + context: circular, + }; + + const jsonStr = JsonLogFormatter(entryWithCircular); + const parsed = JSON.parse(jsonStr); + + expect(parsed.level).toBe("error"); + expect(parsed.message).toBe("Transaction failed"); + expect(parsed.traceId).toBe("trace-xyz"); + expect(parsed.context.key).toBe("value"); + expect(parsed.context.self).toBe("[Circular]"); + }); + }); + + describe("Storage Reliability", () => { + it("MemoryLogStorage reliably stores entries and respects maxEntries capacity", () => { + const storage = new MemoryLogStorage(3); + + storage.write({ + timestamp: 1000, + isoTimestamp: new Date(1000).toISOString(), + level: "info", + message: "Entry 1", + }); + storage.write({ + timestamp: 2000, + isoTimestamp: new Date(2000).toISOString(), + level: "warn", + message: "Entry 2", + }); + storage.write({ + timestamp: 3000, + isoTimestamp: new Date(3000).toISOString(), + level: "error", + message: "Entry 3", + }); + + expect(storage.count()).toBe(3); + + // Add a 4th entry; entry 1 should be evicted + storage.write({ + timestamp: 4000, + isoTimestamp: new Date(4000).toISOString(), + level: "fatal", + message: "Entry 4", + }); + + expect(storage.count()).toBe(3); + const logs = storage.getLogs(); + expect(logs.map((l) => l.message)).toEqual(["Entry 2", "Entry 3", "Entry 4"]); + }); + + it("MemoryLogStorage filters by level, minLevel, time, search, and pagination", () => { + const storage = new MemoryLogStorage(10); + storage.write({ + timestamp: 100, + isoTimestamp: new Date(100).toISOString(), + level: "debug", + message: "Cache hit", + tags: ["cache"], + }); + storage.write({ + timestamp: 200, + isoTimestamp: new Date(200).toISOString(), + level: "info", + message: "Payment processed", + tags: ["payment"], + }); + storage.write({ + timestamp: 300, + isoTimestamp: new Date(300).toISOString(), + level: "error", + message: "Horizon RPC unreachable", + tags: ["network", "rpc"], + }); + + // Filter by minLevel + expect(storage.getLogs({ minLevel: "warn" })).toHaveLength(1); + expect(storage.getLogs({ minLevel: "warn" })[0].level).toBe("error"); + + // Filter by search + expect(storage.getLogs({ search: "payment" })).toHaveLength(1); + expect(storage.getLogs({ search: "horizon" })).toHaveLength(1); + + // Filter by tags + expect(storage.getLogs({ tags: ["cache"] })).toHaveLength(1); + + // Filter by time range + expect(storage.getLogs({ since: 150, until: 250 })).toHaveLength(1); + + // Pagination + expect(storage.getLogs({ limit: 1, offset: 1 })[0].message).toBe("Payment processed"); + + // Clear + storage.clear(); + expect(storage.count()).toBe(0); + }); + + it("ConsoleLogStorage formats and outputs to console methods", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const storage = new ConsoleLogStorage(TextLogFormatter, true, true); + + storage.write({ + timestamp: 1000, + isoTimestamp: new Date(1000).toISOString(), + level: "error", + message: "System failure", + }); + + expect(consoleSpy).toHaveBeenCalled(); + expect(storage.count()).toBe(1); + consoleSpy.mockRestore(); + }); + + it("MultiLogStorage multiplexes to multiple sinks", () => { + const mem1 = new MemoryLogStorage(5); + const mem2 = new MemoryLogStorage(5); + const multi = new MultiLogStorage([mem1, mem2]); + + multi.write({ + timestamp: 1000, + isoTimestamp: new Date(1000).toISOString(), + level: "info", + message: "Multicast test", + }); + + expect(mem1.count()).toBe(1); + expect(mem2.count()).toBe(1); + }); + }); + + describe("ErrorLogger Class & API", () => { + it("logs messages and filters below minimum level", () => { + const logger = new ErrorLogger({ minLevel: "warn" }); + + const d = logger.debug("Should be filtered"); + const i = logger.info("Should be filtered"); + const w = logger.warn("Warning logged"); + const e = logger.error("Error logged"); + + expect(d).toBeNull(); + expect(i).toBeNull(); + expect(w).not.toBeNull(); + expect(e).not.toBeNull(); + expect(logger.count()).toBe(2); + }); + + it("logs StellarSplitError with code, raw, and context", () => { + const logger = new ErrorLogger({ minLevel: "debug" }); + const splitErr = new StellarSplitError("Invoice not found", "INVOICE_NOT_FOUND", { id: "inv-1" }, "raw-panic"); + + const entry = logger.error("Fetch invoice failed", splitErr, { attempt: 3 }); + + expect(entry).not.toBeNull(); + expect(entry!.error?.name).toBe("StellarSplitError"); + expect(entry!.error?.code).toBe("INVOICE_NOT_FOUND"); + expect(entry!.error?.raw).toBe("raw-panic"); + expect(entry!.context).toEqual({ attempt: 3 }); + }); + + it("creates child loggers with inherited settings and merged context", () => { + const parent = new ErrorLogger({ + minLevel: "info", + defaultContext: { service: "split-sdk" }, + defaultTags: ["base"], + }); + + const child = parent.child({ component: "txBuilder" }, ["tx"]); + + const entry = child.info("Building tx"); + expect(entry!.context).toEqual({ service: "split-sdk", component: "txBuilder" }); + expect(entry!.tags).toEqual(["base", "tx"]); + }); + + it("uses default global logger and logError helper", () => { + const logger = getDefaultErrorLogger(); + logger.setLevel("debug"); + + const entry = logError(new Error("Database disconnected"), "Fatal DB crash", { host: "db1" }); + expect(entry).not.toBeNull(); + expect(entry!.level).toBe("error"); + expect(entry!.message).toBe("Fatal DB crash"); + expect(logger.count()).toBe(1); + }); + }); +});