Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ export interface StellarSplitClientConfig {
*/
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;
};
* Optional idempotency configuration for write methods.
* When provided, duplicate submissions are detected and short-circuited.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ export type {
AuditLogFetcher,
EventFetcher,
} from "./disputeEvidenceBundler.js";

export { UsageAnalyticsCollector, wrapWithAnalytics } from "./usageAnalytics.js";
export type {
UsageAnalyticsConfig,
FeatureCountSnapshot,
} from "./usageAnalytics.js";
export { IdempotencyManager } from "./idempotency.js";
export type { IdempotencyConfig } from "./idempotency.js";

Expand Down
125 changes: 125 additions & 0 deletions src/usageAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;

export class UsageAnalyticsCollector {
private readonly counts: Record<string, number> = {};
private readonly config: UsageAnalyticsConfig;
private timer: ReturnType<typeof setInterval> | 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<void> {
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<T extends object>(
client: T,
config: Pick<StellarSplitClientConfig, "usageAnalytics">,
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 };
}
124 changes: 124 additions & 0 deletions test/usageAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading