Skip to content

Commit faf2e27

Browse files
authored
Merge pull request #951 from Mac-5/drips/902-903-904-905
2 parents f3a55f4 + 7536964 commit faf2e27

7 files changed

Lines changed: 858 additions & 133 deletions

File tree

‎docs/TELEMETRY_HOOKS.md‎

Lines changed: 85 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SDK Telemetry Hooks
22

33
> **Issue #362**: Add opt-in telemetry hooks for error and performance monitoring
4+
> **Issue #903**: Add SDK metrics export in Prometheus format
45
56
## Overview
67

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

2123
## Installation
2224

@@ -62,6 +64,88 @@ client.setTelemetryHooks({
6264
client.clearTelemetryHooks();
6365
```
6466

67+
## Prometheus Metrics Export
68+
69+
> **Issue #903**: Add SDK metrics export in Prometheus format
70+
71+
The SDK can export the metrics it collects through the telemetry hooks in the
72+
[Prometheus text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/),
73+
so they can be scraped by a Prometheus server or any compatible agent.
74+
75+
### Enabling Metrics Collection
76+
77+
Metrics are collected from the same `onCallStart` / `onCallEnd` / `onError`
78+
events used by the telemetry hooks. Register the built-in metrics collector to
79+
start recording them:
80+
81+
```typescript
82+
import { createPrometheusMetrics } from "@stellar-split/sdk";
83+
84+
const metrics = createPrometheusMetrics();
85+
86+
client.setTelemetryHooks({
87+
onError: metrics.onError,
88+
onCallStart: metrics.onCallStart,
89+
onCallEnd: metrics.onCallEnd,
90+
});
91+
```
92+
93+
### Exposing the Metrics Endpoint
94+
95+
Call `metrics.export()` to obtain the current snapshot rendered in Prometheus
96+
text format. Serve it from any HTTP handler (Express, Fastify, a serverless
97+
function, etc.):
98+
99+
```typescript
100+
import express from "express";
101+
102+
const app = express();
103+
104+
app.get("/metrics", (_req, res) => {
105+
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
106+
res.send(metrics.export());
107+
});
108+
109+
app.listen(9464);
110+
```
111+
112+
### Exported Metrics
113+
114+
| Metric | Type | Labels | Description |
115+
| --- | --- | --- | --- |
116+
| `stellar_split_sdk_calls_total` | counter | `method`, `success` | Total number of SDK calls, split by outcome |
117+
| `stellar_split_sdk_call_duration_seconds` | histogram | `method` | Duration of SDK calls in seconds |
118+
| `stellar_split_sdk_errors_total` | counter | `method` | Total number of SDK errors |
119+
| `stellar_split_sdk_in_flight_calls` | gauge | `method` | SDK calls currently in progress |
120+
121+
Example output:
122+
123+
```
124+
# HELP stellar_split_sdk_calls_total Total number of SDK calls.
125+
# TYPE stellar_split_sdk_calls_total counter
126+
stellar_split_sdk_calls_total{method="createInvoice",success="true"} 12
127+
stellar_split_sdk_calls_total{method="createInvoice",success="false"} 1
128+
# HELP stellar_split_sdk_call_duration_seconds Duration of SDK calls in seconds.
129+
# TYPE stellar_split_sdk_call_duration_seconds histogram
130+
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="0.1"} 8
131+
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="0.5"} 12
132+
stellar_split_sdk_call_duration_seconds_bucket{method="createInvoice",le="+Inf"} 13
133+
stellar_split_sdk_call_duration_seconds_sum{method="createInvoice"} 1.842
134+
stellar_split_sdk_call_duration_seconds_count{method="createInvoice"} 13
135+
# HELP stellar_split_sdk_errors_total Total number of SDK errors.
136+
# TYPE stellar_split_sdk_errors_total counter
137+
stellar_split_sdk_errors_total{method="createInvoice"} 1
138+
# HELP stellar_split_sdk_in_flight_calls SDK calls currently in progress.
139+
# TYPE stellar_split_sdk_in_flight_calls gauge
140+
stellar_split_sdk_in_flight_calls{method="createInvoice"} 0
141+
```
142+
143+
### Resetting Metrics
144+
145+
```typescript
146+
metrics.reset();
147+
```
148+
65149
## Hook Signatures
66150

67151
### `onError`
@@ -308,112 +392,4 @@ Console output:
308392

309393
- **Zero overhead when not configured**: Hooks have no performance impact when not registered
310394
- **Minimal overhead when configured**: Hook execution is synchronous and fast
311-
- **Fire-and-forget**: Hook errors never block SDK operations
312-
- **No memory leaks**: Hooks are properly cleaned up when cleared
313-
314-
## TypeScript Support
315-
316-
All hook types are fully typed for IDE autocomplete and type safety:
317-
318-
```typescript
319-
import type {
320-
TelemetryHooks,
321-
TelemetryErrorContext,
322-
TelemetryCallStartParams,
323-
TelemetryCallEndParams,
324-
} from "@stellar-split/sdk";
325-
326-
const hooks: TelemetryHooks = {
327-
onError: (error, context) => {
328-
// `error` is typed as StellarSplitError
329-
// `context` is typed as TelemetryErrorContext
330-
console.log(error.code, context.method);
331-
},
332-
onCallStart: (params) => {
333-
// `params` is typed as TelemetryCallStartParams
334-
console.log(params.method, params.timestamp);
335-
},
336-
onCallEnd: (params) => {
337-
// `params` is typed as TelemetryCallEndParams
338-
console.log(params.success, params.durationMs);
339-
},
340-
};
341-
```
342-
343-
## Best Practices
344-
345-
1. **Keep hooks lightweight**: Avoid heavy computation in hooks
346-
2. **Use async operations carefully**: If you need to make async calls, don't await them in hooks
347-
3. **Handle hook errors gracefully**: Expect hooks to fail occasionally (network issues, etc.)
348-
4. **Sanitize sensitive data**: The SDK provides basic sanitization, but you may want additional filtering
349-
5. **Test your hooks**: Ensure your monitoring code doesn't introduce bugs
350-
351-
## Troubleshooting
352-
353-
### Hook not being called
354-
355-
Ensure the hook is registered before making SDK calls:
356-
357-
```typescript
358-
client.setTelemetryHooks({ onError });
359-
await client.createInvoice(params); // Hook will be called
360-
```
361-
362-
### Hook exceptions appearing in console
363-
364-
This is expected fire-and-forget behavior. Fix the exception in your hook code:
365-
366-
```typescript
367-
client.setTelemetryHooks({
368-
onError: (error, context) => {
369-
try {
370-
// Your monitoring code
371-
sendToSentry(error);
372-
} catch (err) {
373-
// Handle gracefully
374-
console.warn("Failed to send error to Sentry:", err);
375-
}
376-
},
377-
});
378-
```
379-
380-
## Migration Guide
381-
382-
If you were using custom error handling before:
383-
384-
```typescript
385-
// Before
386-
try {
387-
await client.createInvoice(params);
388-
} catch (error) {
389-
trackError(error);
390-
throw error;
391-
}
392-
```
393-
394-
```typescript
395-
// After
396-
client.setTelemetryHooks({
397-
onError: (error, context) => trackError(error, context),
398-
});
399-
400-
await client.createInvoice(params); // Error tracking happens automatically
401-
```
402-
403-
## API Reference
404-
405-
### `client.setTelemetryHooks(hooks: TelemetryHooks): void`
406-
407-
Register telemetry hooks. Replaces any previously registered hooks.
408-
409-
### `client.clearTelemetryHooks(): void`
410-
411-
Remove all registered telemetry hooks.
412-
413-
## Related Issues
414-
415-
- [#362: Add SDK telemetry hooks for error and performance monitoring](https://github.com/Stellar-split/split-sdk/issues/362)
416-
417-
## License
418-
419-
MIT
395+
- **Fire-and-forget**: Hook errors

‎src/__tests__/invoiceBatchProcessor.test.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@
55
* 1. A batch where one invoice throws continues processing remaining invoices.
66
* 2. The result object includes `succeeded` and `failed` arrays with correct contents.
77
* 3. A batch where all invoices fail returns an empty `succeeded` array.
8+
*
9+
* Transaction builder helper tests for complex multi-op invoices (#904).
10+
*
11+
* These tests verify that:
12+
* 4. A multi-op invoice composes all operations into a single transaction.
13+
* 5. Lifecycle events are emitted for build/add/complete.
14+
* 6. Building an invoice with no operations is rejected.
815
*/
916

1017
import { describe, it, expect, vi } from "vitest";
1118
import { InvoiceBatchProcessor } from "../invoiceBatchProcessor.js";
1219
import type { InvoicePaymentSubmitter } from "../invoiceBatchProcessor.js";
20+
import {
21+
TransactionBuilder,
22+
type TransactionOperation,
23+
type TransactionBuilderEvent,
24+
} from "../transactionBuilder.js";
1325

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

37+
/** Build a simple payment operation for the given invoice. */
38+
function paymentOp(invoiceId: string, amount: bigint): TransactionOperation {
39+
return {
40+
type: "payment",
41+
invoiceId,
42+
amount,
43+
asset: "native",
44+
};
45+
}
46+
2547
// ---------------------------------------------------------------------------
2648
// Tests – partial-failure handling
2749
// ---------------------------------------------------------------------------
@@ -142,3 +164,97 @@ describe("InvoiceBatchProcessor – partial-failure handling", () => {
142164
expect(failed.every((r) => r.error === "network error")).toBe(true);
143165
});
144166
});
167+
168+
// ---------------------------------------------------------------------------
169+
// Tests – transaction builder helper (#904)
170+
// ---------------------------------------------------------------------------
171+
172+
describe("TransactionBuilder – complex multi-op invoices", () => {
173+
// ── Criterion 4: multi-op composition ────────────────────────────────────
174+
175+
it("composes multiple operations into a single transaction", () => {
176+
const builder = new TransactionBuilder({ payer: "GPAYER" });
177+
178+
builder
179+
.addOperation(paymentOp("inv1", 10n))
180+
.addOperation(paymentOp("inv2", 20n))
181+
.addOperation({
182+
type: "memo",
183+
invoiceId: "inv1",
184+
text: "batch settlement",
185+
});
186+
187+
const tx = builder.build();
188+
189+
expect(tx.payer).toBe("GPAYER");
190+
expect(tx.operations).toHaveLength(3);
191+
expect(tx.operations.map((op) => op.type)).toEqual([
192+
"payment",
193+
"payment",
194+
"memo",
195+
]);
196+
expect(tx.operations[0]).toMatchObject({ invoiceId: "inv1", amount: 10n });
197+
expect(tx.operations[1]).toMatchObject({ invoiceId: "inv2", amount: 20n });
198+
});
199+
200+
it("supports adding a batch of operations at once", () => {
201+
const builder = new TransactionBuilder({ payer: "GPAYER" });
202+
203+
builder.addOperations([
204+
paymentOp("inv1", 1n),
205+
paymentOp("inv2", 2n),
206+
paymentOp("inv3", 3n),
207+
]);
208+
209+
const tx = builder.build();
210+
expect(tx.operations).toHaveLength(3);
211+
expect(tx.operations.map((op) => op.invoiceId)).toEqual([
212+
"inv1",
213+
"inv2",
214+
"inv3",
215+
]);
216+
});
217+
218+
// ── Criterion 5: lifecycle event emission ────────────────────────────────
219+
220+
it("emits build/add/complete lifecycle events", () => {
221+
const builder = new TransactionBuilder({ payer: "GPAYER" });
222+
const events: TransactionBuilderEvent[] = [];
223+
builder.on((event) => events.push(event));
224+
225+
builder.addOperation(paymentOp("inv1", 5n));
226+
builder.addOperation(paymentOp("inv2", 5n));
227+
const tx = builder.build();
228+
builder.complete(tx);
229+
230+
expect(events.map((e) => e.type)).toEqual([
231+
"add",
232+
"add",
233+
"build",
234+
"complete",
235+
]);
236+
expect(events[0]).toMatchObject({ type: "add", operationCount: 1 });
237+
expect(events[1]).toMatchObject({ type: "add", operationCount: 2 });
238+
expect(events[2]).toMatchObject({ type: "build", operationCount: 2 });
239+
expect(events[3]).toMatchObject({ type: "complete", operationCount: 2 });
240+
});
241+
242+
it("allows unsubscribing from lifecycle events", () => {
243+
const builder = new TransactionBuilder({ payer: "GPAYER" });
244+
const listener = vi.fn();
245+
const off = builder.on(listener);
246+
247+
builder.addOperation(paymentOp("inv1", 1n));
248+
off();
249+
builder.addOperation(paymentOp("inv2", 1n));
250+
251+
expect(listener).toHaveBeenCalledTimes(1);
252+
});
253+
254+
// ── Criterion 6: empty transaction rejected ──────────────────────────────
255+
256+
it("throws when building a transaction with no operations", () => {
257+
const builder = new TransactionBuilder({ payer: "GPAYER" });
258+
expect(() => builder.build()).toThrow(/no operations/i);
259+
});
260+
});

0 commit comments

Comments
 (0)