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
194 changes: 85 additions & 109 deletions docs/TELEMETRY_HOOKS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SDK Telemetry Hooks

> **Issue #362**: Add opt-in telemetry hooks for error and performance monitoring
> **Issue #903**: Add SDK metrics export in Prometheus format

## Overview

Expand All @@ -17,6 +18,7 @@ All hooks are **fire-and-forget** — exceptions within hooks do not propagate t
- ✅ Full TypeScript type safety
- ✅ Zero dependencies
- ✅ Opt-in (no performance impact when not configured)
- ✅ Prometheus-format metrics export (see [Prometheus Metrics Export](#prometheus-metrics-export))

## Installation

Expand Down Expand Up @@ -62,6 +64,88 @@ client.setTelemetryHooks({
client.clearTelemetryHooks();
```

## Prometheus Metrics Export

> **Issue #903**: Add SDK metrics export in Prometheus format

The SDK can export the metrics it collects through the telemetry hooks in the
[Prometheus text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/),
so they can be scraped by a Prometheus server or any compatible agent.

### Enabling Metrics Collection

Metrics are collected from the same `onCallStart` / `onCallEnd` / `onError`
events used by the telemetry hooks. Register the built-in metrics collector to
start recording them:

```typescript
import { createPrometheusMetrics } from "@stellar-split/sdk";

const metrics = createPrometheusMetrics();

client.setTelemetryHooks({
onError: metrics.onError,
onCallStart: metrics.onCallStart,
onCallEnd: metrics.onCallEnd,
});
```

### Exposing the Metrics Endpoint

Call `metrics.export()` to obtain the current snapshot rendered in Prometheus
text format. Serve it from any HTTP handler (Express, Fastify, a serverless
function, etc.):

```typescript
import express from "express";

const app = express();

app.get("/metrics", (_req, res) => {
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
res.send(metrics.export());
});

app.listen(9464);
```

### Exported Metrics

| Metric | Type | Labels | Description |
| --- | --- | --- | --- |
| `stellar_split_sdk_calls_total` | counter | `method`, `success` | Total number of SDK calls, split by outcome |
| `stellar_split_sdk_call_duration_seconds` | histogram | `method` | Duration of SDK calls in seconds |
| `stellar_split_sdk_errors_total` | counter | `method` | Total number of SDK errors |
| `stellar_split_sdk_in_flight_calls` | gauge | `method` | SDK calls currently in progress |

Example output:

```
# HELP stellar_split_sdk_calls_total Total number of SDK calls.
# TYPE stellar_split_sdk_calls_total counter
stellar_split_sdk_calls_total{method="createInvoice",success="true"} 12
stellar_split_sdk_calls_total{method="createInvoice",success="false"} 1
# HELP stellar_split_sdk_call_duration_seconds Duration of SDK calls in seconds.
# TYPE stellar_split_sdk_call_duration_seconds histogram
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="0.1"} 8
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="0.5"} 12
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="+Inf"} 13
stellar_split_sdk_call_duration_seconds_sum{method="createInvoice"} 1.842
stellar_split_sdk_call_duration_seconds_count{method="createInvoice"} 13
# HELP stellar_split_sdk_errors_total Total number of SDK errors.
# TYPE stellar_split_sdk_errors_total counter
stellar_split_sdk_errors_total{method="createInvoice"} 1
# HELP stellar_split_sdk_in_flight_calls SDK calls currently in progress.
# TYPE stellar_split_sdk_in_flight_calls gauge
stellar_split_sdk_in_flight_calls{method="createInvoice"} 0
```

### Resetting Metrics

```typescript
metrics.reset();
```

## Hook Signatures

### `onError`
Expand Down Expand Up @@ -308,112 +392,4 @@ Console output:

- **Zero overhead when not configured**: Hooks have no performance impact when not registered
- **Minimal overhead when configured**: Hook execution is synchronous and fast
- **Fire-and-forget**: Hook errors never block SDK operations
- **No memory leaks**: Hooks are properly cleaned up when cleared

## TypeScript Support

All hook types are fully typed for IDE autocomplete and type safety:

```typescript
import type {
TelemetryHooks,
TelemetryErrorContext,
TelemetryCallStartParams,
TelemetryCallEndParams,
} from "@stellar-split/sdk";

const hooks: TelemetryHooks = {
onError: (error, context) => {
// `error` is typed as StellarSplitError
// `context` is typed as TelemetryErrorContext
console.log(error.code, context.method);
},
onCallStart: (params) => {
// `params` is typed as TelemetryCallStartParams
console.log(params.method, params.timestamp);
},
onCallEnd: (params) => {
// `params` is typed as TelemetryCallEndParams
console.log(params.success, params.durationMs);
},
};
```

## Best Practices

1. **Keep hooks lightweight**: Avoid heavy computation in hooks
2. **Use async operations carefully**: If you need to make async calls, don't await them in hooks
3. **Handle hook errors gracefully**: Expect hooks to fail occasionally (network issues, etc.)
4. **Sanitize sensitive data**: The SDK provides basic sanitization, but you may want additional filtering
5. **Test your hooks**: Ensure your monitoring code doesn't introduce bugs

## Troubleshooting

### Hook not being called

Ensure the hook is registered before making SDK calls:

```typescript
client.setTelemetryHooks({ onError });
await client.createInvoice(params); // Hook will be called
```

### Hook exceptions appearing in console

This is expected fire-and-forget behavior. Fix the exception in your hook code:

```typescript
client.setTelemetryHooks({
onError: (error, context) => {
try {
// Your monitoring code
sendToSentry(error);
} catch (err) {
// Handle gracefully
console.warn("Failed to send error to Sentry:", err);
}
},
});
```

## Migration Guide

If you were using custom error handling before:

```typescript
// Before
try {
await client.createInvoice(params);
} catch (error) {
trackError(error);
throw error;
}
```

```typescript
// After
client.setTelemetryHooks({
onError: (error, context) => trackError(error, context),
});

await client.createInvoice(params); // Error tracking happens automatically
```

## API Reference

### `client.setTelemetryHooks(hooks: TelemetryHooks): void`

Register telemetry hooks. Replaces any previously registered hooks.

### `client.clearTelemetryHooks(): void`

Remove all registered telemetry hooks.

## Related Issues

- [#362: Add SDK telemetry hooks for error and performance monitoring](https://github.com/Stellar-split/split-sdk/issues/362)

## License

MIT
- **Fire-and-forget**: Hook errors
116 changes: 116 additions & 0 deletions src/__tests__/invoiceBatchProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@
* 1. A batch where one invoice throws continues processing remaining invoices.
* 2. The result object includes `succeeded` and `failed` arrays with correct contents.
* 3. A batch where all invoices fail returns an empty `succeeded` array.
*
* Transaction builder helper tests for complex multi-op invoices (#904).
*
* These tests verify that:
* 4. A multi-op invoice composes all operations into a single transaction.
* 5. Lifecycle events are emitted for build/add/complete.
* 6. Building an invoice with no operations is rejected.
*/

import { describe, it, expect, vi } from "vitest";
import { InvoiceBatchProcessor } from "../invoiceBatchProcessor.js";
import type { InvoicePaymentSubmitter } from "../invoiceBatchProcessor.js";
import {
TransactionBuilder,
type TransactionOperation,
type TransactionBuilderEvent,
} from "../transactionBuilder.js";

// ---------------------------------------------------------------------------
// Helpers
Expand All @@ -22,6 +34,16 @@ async function drain<T>(iter: AsyncIterableIterator<T>): Promise<T[]> {
return results;
}

/** Build a simple payment operation for the given invoice. */
function paymentOp(invoiceId: string, amount: bigint): TransactionOperation {
return {
type: "payment",
invoiceId,
amount,
asset: "native",
};
}

// ---------------------------------------------------------------------------
// Tests – partial-failure handling
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -142,3 +164,97 @@ describe("InvoiceBatchProcessor – partial-failure handling", () => {
expect(failed.every((r) => r.error === "network error")).toBe(true);
});
});

// ---------------------------------------------------------------------------
// Tests – transaction builder helper (#904)
// ---------------------------------------------------------------------------

describe("TransactionBuilder – complex multi-op invoices", () => {
// ── Criterion 4: multi-op composition ────────────────────────────────────

it("composes multiple operations into a single transaction", () => {
const builder = new TransactionBuilder({ payer: "GPAYER" });

builder
.addOperation(paymentOp("inv1", 10n))
.addOperation(paymentOp("inv2", 20n))
.addOperation({
type: "memo",
invoiceId: "inv1",
text: "batch settlement",
});

const tx = builder.build();

expect(tx.payer).toBe("GPAYER");
expect(tx.operations).toHaveLength(3);
expect(tx.operations.map((op) => op.type)).toEqual([
"payment",
"payment",
"memo",
]);
expect(tx.operations[0]).toMatchObject({ invoiceId: "inv1", amount: 10n });
expect(tx.operations[1]).toMatchObject({ invoiceId: "inv2", amount: 20n });
});

it("supports adding a batch of operations at once", () => {
const builder = new TransactionBuilder({ payer: "GPAYER" });

builder.addOperations([
paymentOp("inv1", 1n),
paymentOp("inv2", 2n),
paymentOp("inv3", 3n),
]);

const tx = builder.build();
expect(tx.operations).toHaveLength(3);
expect(tx.operations.map((op) => op.invoiceId)).toEqual([
"inv1",
"inv2",
"inv3",
]);
});

// ── Criterion 5: lifecycle event emission ────────────────────────────────

it("emits build/add/complete lifecycle events", () => {
const builder = new TransactionBuilder({ payer: "GPAYER" });
const events: TransactionBuilderEvent[] = [];
builder.on((event) => events.push(event));

builder.addOperation(paymentOp("inv1", 5n));
builder.addOperation(paymentOp("inv2", 5n));
const tx = builder.build();
builder.complete(tx);

expect(events.map((e) => e.type)).toEqual([
"add",
"add",
"build",
"complete",
]);
expect(events[0]).toMatchObject({ type: "add", operationCount: 1 });
expect(events[1]).toMatchObject({ type: "add", operationCount: 2 });
expect(events[2]).toMatchObject({ type: "build", operationCount: 2 });
expect(events[3]).toMatchObject({ type: "complete", operationCount: 2 });
});

it("allows unsubscribing from lifecycle events", () => {
const builder = new TransactionBuilder({ payer: "GPAYER" });
const listener = vi.fn();
const off = builder.on(listener);

builder.addOperation(paymentOp("inv1", 1n));
off();
builder.addOperation(paymentOp("inv2", 1n));

expect(listener).toHaveBeenCalledTimes(1);
});

// ── Criterion 6: empty transaction rejected ──────────────────────────────

it("throws when building a transaction with no operations", () => {
const builder = new TransactionBuilder({ payer: "GPAYER" });
expect(() => builder.build()).toThrow(/no operations/i);
});
});
Loading