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
55 changes: 55 additions & 0 deletions examples/telemetry-hooks-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,59 @@ client.setTelemetryHooks({
console.log("\nEven if hooks throw exceptions, SDK operations continue normally.");
console.log("Hook exceptions are logged to console but don't propagate to your code.");

// Example 8: Built-in SDK performance profiling
console.log("\n=== Example 8: Built-in Performance Profiling ===");

// The SDK ships with a built-in profiler that records operation timings
// and emits lifecycle events (start/stop/mark/measure) without any extra setup.
client.startProfiling();

// Subscribe to profiling lifecycle events
const unsubscribeProfiling = client.onProfilingEvent((event) => {
switch (event.type) {
case "start":
console.log(`🟢 Profiling started at ${new Date(event.timestamp).toISOString()}`);
break;
case "mark":
console.log(`📍 Mark "${event.name}" at ${event.timestamp}`);
break;
case "measure":
console.log(`⏱️ Measure "${event.name}": ${event.durationMs}ms`);
break;
case "stop":
console.log(`🔴 Profiling stopped at ${new Date(event.timestamp).toISOString()}`);
break;
}
});

async function demonstrateProfiling() {
console.log("\n=== Running Profiled SDK Operations ===\n");

client.mark("before-getInvoice");
try {
await client.getInvoice("789");
} catch (error) {
console.log("Expected error caught in main code");
}
client.mark("after-getInvoice");
client.measure("getInvoice-roundtrip", "before-getInvoice", "after-getInvoice");

// Retrieve aggregated profiling metrics
const metrics = client.getProfilingMetrics();
console.log("\n📊 Built-in Profiling Metrics:");
for (const [name, stats] of Object.entries(metrics)) {
console.log(` ${name}:`);
console.log(` Calls: ${stats.calls}`);
console.log(` Avg: ${stats.avgMs.toFixed(2)}ms`);
console.log(` Min: ${stats.minMs}ms, Max: ${stats.maxMs}ms`);
}

// Stop profiling and clean up the event subscription
client.stopProfiling();
unsubscribeProfiling();
console.log("✅ Profiling stopped and listener removed");
}

demonstrateProfiling().catch(console.error);

export { client, perfMonitor, errorTracker, analytics };
100 changes: 100 additions & 0 deletions src/audit/AuditTrailHasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,106 @@ export class AuditTrailHasher {
return entry;
}

/**
* Records an invoice audit entry scoped to a tenant, enabling cross-tenant auditing.
* Emits an 'invoice.audited' lifecycle event.
*/
async auditInvoice(tenantId: string, invoiceId: string, event: AuditEvent): Promise<CrossTenantAuditRecord> {
const entry = await this.append(event);
const record: CrossTenantAuditRecord = {
tenantId,
invoiceId,
event,
entry,
recordedAt: Date.now(),
};
this.crossTenantRecords.push(record);
this.emit({
type: 'invoice.audited',
tenantId,
invoiceId,
entry,
timestamp: record.recordedAt,
});
return record;
}

/**
* Records a cross-tenant access attempt against an invoice and emits a
* 'invoice.cross-tenant-access' event so consumers can react to it.
*/
async recordCrossTenantAccess(
accessingTenantId: string,
invoiceTenantId: string,
invoiceId: string,
event: AuditEvent,
): Promise<CrossTenantAuditRecord> {
const entry = await this.append(event);
const record: CrossTenantAuditRecord = {
tenantId: accessingTenantId,
invoiceId,
event,
entry,
recordedAt: Date.now(),
};
this.crossTenantRecords.push(record);
this.emit({
type: 'invoice.cross-tenant-access',
tenantId: accessingTenantId,
invoiceId,
entry,
timestamp: record.recordedAt,
});
return record;
}

/**
* Queries recorded cross-tenant audit entries, optionally filtered by tenant
* and/or invoice. Returns a defensive copy to preserve isolation.
*/
queryCrossTenantAudits(query: CrossTenantAuditQuery = {}): CrossTenantAuditRecord[] {
return this.crossTenantRecords
.filter(r => (query.tenantId === undefined || r.tenantId === query.tenantId))
.filter(r => (query.invoiceId === undefined || r.invoiceId === query.invoiceId))
.map(r => ({ ...r }));
}

/**
* Verifies that a tenant's recorded audit entries are intact and match the
* expected chain root, enforcing cross-tenant isolation.
*/
async verifyTenantAudit(
tenantId: string,
expectedRoot: AuditTrailRoot,
): Promise<{ valid: boolean; mismatchAt?: number; length?: number }> {
const tenantEntries = this.crossTenantRecords
.filter(r => r.tenantId === tenantId)
.map(r => r.entry);
const scoped = new AuditTrailHasher(tenantEntries);
return scoped.verify(expectedRoot);
}

/**
* Registers a listener for cross-tenant audit lifecycle events.
*/
on(listener: CrossTenantAuditListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}

/**
* Removes a previously registered listener.
*/
off(listener: CrossTenantAuditListener): void {
this.listeners.delete(listener);
}

private emit(event: CrossTenantAuditEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}

/**
* Computes a Merkle root over all current chain entry hashes using pairwise SHA-256 combining
*/
Expand Down
105 changes: 105 additions & 0 deletions src/auditLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export class AuditLogger {
}

log(entry: AuditEntry): void {
this.debugLog("log", {
method: entry.method,
success: entry.success,
durationMs: entry.durationMs,
});
this.sink(entry);
this.emit(entry);
}
Expand Down Expand Up @@ -202,4 +207,104 @@ export class AuditLogger {
async exportSplitAuditTrail(invoiceId: string): Promise<SplitAuditEntry[]> {
return [...(this.splitAuditTrails.get(invoiceId) ?? [])];
}

/**
* Subscribe to cross-tenant invoice audit lifecycle events.
*
* @returns an unsubscribe function.
*/
onCrossTenantAudit(listener: CrossTenantAuditEventListener): () => void {
this.crossTenantListeners.add(listener);
return () => {
this.crossTenantListeners.delete(listener);
};
}

/**
* Record a cross-tenant invoice audit entry.
*
* Persists the entry to the in-memory cross-tenant trail, writes a
* sanitized `AuditEntry` to the configured sink, and emits the appropriate
* lifecycle event:
* - `cross_tenant_access_detected` when the actor differs from the owner,
* - `cross_tenant_access_denied` when such access is unauthorized,
* - `invoice_audited` for every recorded entry.
*/
recordCrossTenantInvoiceAudit(
entry: CrossTenantInvoiceAuditEntry,
): void {
this.crossTenantAudits.push(entry);

this.log({
timestamp: entry.timestamp,
method: "cross_tenant_invoice_audit",
params: this.sanitize({
ownerTenantId: entry.ownerTenantId,
actorTenantId: entry.actorTenantId,
invoiceId: entry.invoiceId,
action: entry.action,
authorized: entry.authorized,
...(entry.metadata ?? {}),
}),
success: entry.authorized,
durationMs: 0,
});

const isCrossTenant = entry.actorTenantId !== entry.ownerTenantId;
if (isCrossTenant) {
this.emitCrossTenantAudit({
type: "cross_tenant_access_detected",
entry,
});
if (!entry.authorized) {
this.emitCrossTenantAudit({
type: "cross_tenant_access_denied",
entry,
});
}
}
this.emitCrossTenantAudit({ type: "invoice_audited", entry });
}

/**
* Query recorded cross-tenant invoice audit entries.
*
* All filters are optional and combined with AND semantics. Results are
* returned in the order they were recorded.
*/
queryCrossTenantInvoiceAudits(filter?: {
ownerTenantId?: string;
actorTenantId?: string;
invoiceId?: string;
action?: string;
authorized?: boolean;
}): CrossTenantInvoiceAuditEntry[] {
return this.crossTenantAudits.filter((entry) => {
if (filter?.ownerTenantId && entry.ownerTenantId !== filter.ownerTenantId) {
return false;
}
if (filter?.actorTenantId && entry.actorTenantId !== filter.actorTenantId) {
return false;
}
if (filter?.invoiceId && entry.invoiceId !== filter.invoiceId) {
return false;
}
if (filter?.action && entry.action !== filter.action) {
return false;
}
if (
filter?.authorized !== undefined &&
entry.authorized !== filter.authorized
) {
return false;
}
return true;
});
}

private emitCrossTenantAudit(event: CrossTenantAuditEvent): void {
for (const listener of this.crossTenantListeners) {
listener(event);
}
}
}
27 changes: 25 additions & 2 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class SimpleCache<T> {
private maxEntries: number;
private readonly listeners = new Set<CacheEventListener>();

constructor(config?: number | { enabled?: boolean; ttl?: Record<string, number>; ttlMs?: number; maxEntries?: number }) {
constructor(config?: number | { enabled?: boolean; ttl?: Record<string, number>; ttlMs?: number; maxEntries?: number; debug?: boolean | DebugModeOptions }) {
if (typeof config === "number") {
this.enabled = true;
this.maxEntries = 1000;
Expand All @@ -51,6 +51,18 @@ export class SimpleCache<T> {
this.ttlConfig["default"] = config.ttlMs;
}
}
this.debug = new DebugMode(
typeof config === "object" && config?.debug !== undefined
? typeof config.debug === "boolean"
? { enabled: config.debug }
: config.debug
: undefined
);
}

/** Access the debug-mode controller for this cache instance. */
getDebugMode(): DebugMode {
return this.debug;
}

/**
Expand Down Expand Up @@ -147,10 +159,12 @@ export class SimpleCache<T> {
this.emit("invalidate", key);
}
}
this.debug.log(`[cache] invalidate ${methodOrKey}`);
}

clear(): void {
this.store.clear();
this.debug.log("[cache] clear");
}

getStats(): CacheStats {
Expand Down Expand Up @@ -213,9 +227,18 @@ export class Cache<V> {
/**
* @param ttlMs Time-to-live in milliseconds. Omit (or pass `undefined`)
* for no-expiry behaviour.
* @param debug Optional debug-mode configuration for verbose logging.
*/
constructor(ttlMs?: number) {
constructor(ttlMs?: number, debug?: boolean | DebugModeOptions) {
this.ttlMs = ttlMs;
this.debug = new DebugMode(
typeof debug === "boolean" ? { enabled: debug } : debug
);
}

/** Access the debug-mode controller for this cache instance. */
getDebugMode(): DebugMode {
return this.debug;
}

/**
Expand Down
Loading