From b59d69ea309e2b9211535cbe62d8e46456e57b50 Mon Sep 17 00:00:00 2001 From: adefemiesther1-debug Date: Fri, 26 Jun 2026 11:34:03 +0000 Subject: [PATCH] feat(sdk): implement proxy-based anonymous feature usage analytics collector --- src/client.ts | 13 ++++ src/index.ts | 6 ++ src/usageAnalytics.ts | 125 ++++++++++++++++++++++++++++++++++++ test/usageAnalytics.test.ts | 124 +++++++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 src/usageAnalytics.ts create mode 100644 test/usageAnalytics.test.ts diff --git a/src/client.ts b/src/client.ts index d429d3a..8b2624d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -164,6 +164,19 @@ export interface StellarSplitClientConfig { * Required when calling buildSponsoredOnboarding from src/sponsorship.ts. */ sponsorAccount?: string; + /** + * Optional anonymous feature-usage analytics configuration. + * When enabled, method call frequencies are collected and periodically flushed + * to the provided endpoint. No arguments or PII are ever captured. + */ + usageAnalytics?: { + /** Set to true to enable collection. Default: false. */ + enabled: boolean; + /** POST endpoint that receives flush payloads. */ + endpoint?: string; + /** Flush interval in milliseconds. Default: 60_000. */ + flushIntervalMs?: number; + }; } /** Network configuration. */ diff --git a/src/index.ts b/src/index.ts index 9aaced1..b6b5939 100644 --- a/src/index.ts +++ b/src/index.ts @@ -294,3 +294,9 @@ export type { AuditLogFetcher, EventFetcher, } from "./disputeEvidenceBundler.js"; + +export { UsageAnalyticsCollector, wrapWithAnalytics } from "./usageAnalytics.js"; +export type { + UsageAnalyticsConfig, + FeatureCountSnapshot, +} from "./usageAnalytics.js"; diff --git a/src/usageAnalytics.ts b/src/usageAnalytics.ts new file mode 100644 index 0000000..e440809 --- /dev/null +++ b/src/usageAnalytics.ts @@ -0,0 +1,125 @@ +/** + * Anonymous feature-usage analytics collector. + * + * Wraps a StellarSplitClient instance with a Proxy that counts method + * invocations by name. Zero arguments are captured. Strictly opt-in via + * config.usageAnalytics.enabled === true. + */ + +import type { StellarSplitClientConfig } from "./client.js"; + +export interface UsageAnalyticsConfig { + enabled: boolean; + endpoint?: string; + flushIntervalMs?: number; +} + +/** Snapshot of accumulated call counts, keyed by method name. */ +export type FeatureCountSnapshot = Record; + +export class UsageAnalyticsCollector { + private readonly counts: Record = {}; + private readonly config: UsageAnalyticsConfig; + private timer: ReturnType | null = null; + + constructor(config: UsageAnalyticsConfig) { + this.config = config; + if (config.enabled && (config.flushIntervalMs ?? 60_000) > 0) { + this.timer = setInterval( + () => void this.flush(), + config.flushIntervalMs ?? 60_000 + ); + // Don't block Node.js exit + if (typeof this.timer === "object" && this.timer !== null && "unref" in this.timer) { + (this.timer as NodeJS.Timeout).unref(); + } + } + } + + /** Increment the counter for a method name. No-op when disabled. */ + record(method: string): void { + if (!this.config.enabled) return; + this.counts[method] = (this.counts[method] ?? 0) + 1; + } + + /** Return a copy of the current counts. */ + getCounts(): FeatureCountSnapshot { + return { ...this.counts }; + } + + /** + * Dispatch accumulated counts to the configured endpoint and reset. + * Safe to call even when disabled (becomes a no-op). + */ + async flush(): Promise { + if (!this.config.enabled) return; + + const snapshot = this.getCounts(); + // Reset before sending so the next window starts clean even on send failure + for (const key of Object.keys(this.counts)) { + delete this.counts[key]; + } + + if (this.config.endpoint && Object.keys(snapshot).length > 0) { + try { + await fetch(this.config.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ featureCounts: snapshot }), + }); + } catch { + // Non-blocking; analytics failures must never surface to the caller. + } + } + } + + /** Stop the background flush timer. */ + destroy(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } +} + +/** + * Wrap a StellarSplitClient instance with a Proxy that records method calls. + * If usageAnalytics is disabled the original instance is returned unchanged. + * + * @param client - The client instance to wrap. + * @param config - Full client config (analytics config read from .usageAnalytics). + * @param collector - Pre-constructed collector (allows injection in tests). + * @returns The (possibly proxied) client and the collector. + */ +export function wrapWithAnalytics( + client: T, + config: Pick, + collector?: UsageAnalyticsCollector +): { proxy: T; collector: UsageAnalyticsCollector } { + const analyticsConfig: UsageAnalyticsConfig = { + enabled: config.usageAnalytics?.enabled ?? false, + endpoint: config.usageAnalytics?.endpoint, + flushIntervalMs: config.usageAnalytics?.flushIntervalMs, + }; + + const instance = collector ?? new UsageAnalyticsCollector(analyticsConfig); + + if (!analyticsConfig.enabled) { + return { proxy: client, collector: instance }; + } + + const proxy = new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value === "function" && typeof prop === "string") { + return function (this: unknown, ...args: unknown[]) { + instance.record(prop); + return (value as (...a: unknown[]) => unknown).apply(this ?? target, args); + }; + } + return value; + }, + }); + + return { proxy, collector: instance }; +} diff --git a/test/usageAnalytics.test.ts b/test/usageAnalytics.test.ts new file mode 100644 index 0000000..6050381 --- /dev/null +++ b/test/usageAnalytics.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + UsageAnalyticsCollector, + wrapWithAnalytics, +} from "../src/usageAnalytics.js"; + +// Minimal stub that mimics the shape of StellarSplitClient +const makeStub = () => ({ + getInvoice: vi.fn().mockResolvedValue({}), + pay: vi.fn().mockResolvedValue({}), + createInvoice: vi.fn().mockResolvedValue({}), +}); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("usageAnalytics – opt-in guard", () => { + it("records nothing when enabled is false", async () => { + const stub = makeStub(); + const { proxy, collector } = wrapWithAnalytics( + stub, + { usageAnalytics: { enabled: false } } + ); + + await proxy.getInvoice("1"); + await proxy.pay({ payer: "", invoiceId: "1", amount: 0n }); + + expect(collector.getCounts()).toEqual({}); + }); + + it("returns the original instance unchanged when disabled", () => { + const stub = makeStub(); + const { proxy } = wrapWithAnalytics(stub, { usageAnalytics: { enabled: false } }); + expect(proxy).toBe(stub); + }); +}); + +describe("usageAnalytics – mathematical accuracy", () => { + it("increments counters exactly once per call", async () => { + const stub = makeStub(); + const { proxy, collector } = wrapWithAnalytics( + stub, + { usageAnalytics: { enabled: true } } + ); + + await proxy.getInvoice("1"); + await proxy.getInvoice("2"); + await proxy.getInvoice("3"); + await proxy.pay({ payer: "", invoiceId: "1", amount: 0n }); + + const counts = collector.getCounts(); + expect(counts["getInvoice"]).toBe(3); + expect(counts["pay"]).toBe(1); + }); + + it("counts multiple distinct methods independently", async () => { + const stub = makeStub(); + const { proxy, collector } = wrapWithAnalytics( + stub, + { usageAnalytics: { enabled: true } } + ); + + await proxy.createInvoice({} as never); + await proxy.createInvoice({} as never); + await proxy.pay({} as never); + + const counts = collector.getCounts(); + expect(counts["createInvoice"]).toBe(2); + expect(counts["pay"]).toBe(1); + }); +}); + +describe("usageAnalytics – flush / state reset", () => { + it("flush resets all counters to zero", async () => { + const collector = new UsageAnalyticsCollector({ enabled: true }); + collector.record("getInvoice"); + collector.record("getInvoice"); + collector.record("pay"); + + await collector.flush(); + + expect(collector.getCounts()).toEqual({}); + }); + + it("counters restart from zero after flush", async () => { + const collector = new UsageAnalyticsCollector({ enabled: true }); + collector.record("pay"); + await collector.flush(); + + collector.record("pay"); + expect(collector.getCounts()["pay"]).toBe(1); + }); + + it("flush POSTs snapshot before clearing when endpoint configured", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const collector = new UsageAnalyticsCollector({ + enabled: true, + endpoint: "https://analytics.example.com/ingest", + }); + collector.record("getInvoice"); + await collector.flush(); + + expect(mockFetch).toHaveBeenCalledWith( + "https://analytics.example.com/ingest", + expect.objectContaining({ method: "POST" }) + ); + expect(collector.getCounts()).toEqual({}); + }); + + it("flush is a no-op when disabled", async () => { + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const collector = new UsageAnalyticsCollector({ enabled: false }); + collector.record("getInvoice"); // should do nothing + await collector.flush(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(collector.getCounts()).toEqual({}); + }); +});