Skip to content

Commit 9f91f22

Browse files
authored
Merge pull request #714 from greatest0fallt1me/feat/circuit-breaker-metrics
Add circuit-breaker state and transition metrics
2 parents 6e99e66 + 25a6950 commit 9f91f22

4 files changed

Lines changed: 74 additions & 1 deletion

File tree

docs/starknet/circuit-breaker.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ Circuit breaker state is exposed in the diagnostics endpoint (`GET /diagnostics/
7373
- **recentFailureCount**: Number of failures in the current rolling window
7474
- **openedAt**: Timestamp when circuit opened (milliseconds since epoch), or `null` if closed
7575

76+
### Metrics
77+
78+
The Starknet client exposes two process-local metrics for each RPC endpoint:
79+
80+
- `starknet_circuit_breaker_state`, a gauge where `0=CLOSED`, `1=OPEN`, and `2=HALF_OPEN`.
81+
- `starknet_circuit_breaker_transitions_total`, a counter labeled with the endpoint and transition name, such as `CLOSED_to_OPEN`.
82+
83+
Alert when an endpoint remains `OPEN` (state `1`) for longer than the normal RPC recovery window, or when `CLOSED_to_OPEN` transitions repeat rapidly. A useful starting threshold is state `1` for more than five minutes, adjusted for the configured circuit-breaker cooldown and the number of healthy fallback endpoints.
84+
7685
## Example Scenarios
7786

7887
### Scenario 1: Endpoint Degrades
@@ -112,7 +121,7 @@ Failures are tracked with timestamps in a rolling window. Old failures outside t
112121

113122
### Half-Open Behavior
114123

115-
In HALF_OPEN state, only one probe call is allowed through at a time. This prevents a "thundering herd" of simultaneous recovery attempts that could overwhelm a recovering endpoint.
124+
In HALF_OPEN state, only one probe call is allowed through at a time. This prevents a thundering herd of simultaneous recovery attempts that could overwhelm a recovering endpoint.
116125

117126
### Success Threshold Rationale
118127

src/starknet/circuit-breaker.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
snapshotCircuitBreaker,
66
type CircuitBreakerOptions,
77
} from "./circuit-breaker.js";
8+
import {
9+
getStarknetMetricsSnapshot,
10+
resetStarknetMetrics,
11+
} from "./client-metrics.js";
812

913
const ENDPOINT = "https://rpc.example.com";
1014

@@ -40,6 +44,23 @@ describe("CircuitOpenError", () => {
4044
});
4145
});
4246

47+
describe("circuit breaker metrics", () => {
48+
beforeEach(() => resetStarknetMetrics());
49+
50+
it("records the current state and every transition with endpoint labels", () => {
51+
const breaker = makeBreaker({ failureThreshold: 1 });
52+
breaker.recordFailure();
53+
54+
const { gauges, counters } = getStarknetMetricsSnapshot();
55+
expect(gauges['starknet_circuit_breaker_state{endpoint="https://rpc.example.com"}']).toBe(1);
56+
expect(
57+
counters[
58+
'starknet_circuit_breaker_transitions_total{endpoint="https://rpc.example.com",transition="CLOSED_to_OPEN"}'
59+
],
60+
).toBe(1);
61+
});
62+
});
63+
4364
// ---------------------------------------------------------------------------
4465
// Initial state
4566
// ---------------------------------------------------------------------------

src/starknet/circuit-breaker.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,21 @@
1515
* → on failure: resets to OPEN, starting the cooldown again
1616
*/
1717

18+
import {
19+
incStarknetMetric,
20+
labeledStarknetMetric,
21+
setStarknetGauge,
22+
STARKNET_METRICS,
23+
} from "./client-metrics.js";
24+
1825
export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
1926

27+
const CIRCUIT_STATE_VALUE: Record<CircuitState, number> = {
28+
CLOSED: 0,
29+
OPEN: 1,
30+
HALF_OPEN: 2,
31+
};
32+
2033
export interface CircuitBreakerOptions {
2134
/** Number of failures in the rolling window that open the circuit. */
2235
failureThreshold: number;
@@ -51,6 +64,7 @@ export class EndpointCircuitBreaker {
5164
constructor(endpointUrl: string, options: CircuitBreakerOptions) {
5265
this.endpointUrl = endpointUrl;
5366
this.options = options;
67+
this.recordStateMetric("CLOSED");
5468
}
5569

5670
/** Current circuit state. */
@@ -148,6 +162,13 @@ export class EndpointCircuitBreaker {
148162
private transitionTo(next: CircuitState): void {
149163
const previous = this.state;
150164
this.state = next;
165+
this.recordStateMetric(next);
166+
incStarknetMetric(
167+
labeledStarknetMetric(STARKNET_METRICS.CIRCUIT_BREAKER_TRANSITIONS, {
168+
endpoint: this.endpointUrl,
169+
transition: `${previous}_to_${next}`,
170+
}),
171+
);
151172

152173
if (next === "OPEN") {
153174
this.openedAt = Date.now();
@@ -174,6 +195,15 @@ export class EndpointCircuitBreaker {
174195
}
175196
}
176197

198+
private recordStateMetric(state: CircuitState): void {
199+
setStarknetGauge(
200+
labeledStarknetMetric(STARKNET_METRICS.CIRCUIT_BREAKER_STATE, {
201+
endpoint: this.endpointUrl,
202+
}),
203+
CIRCUIT_STATE_VALUE[state],
204+
);
205+
}
206+
177207
private pruneOldFailures(): void {
178208
const cutoff = Date.now() - this.options.windowMs;
179209
this.failureTimestamps = this.failureTimestamps.filter((ts) => ts > cutoff);

src/starknet/client-metrics.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ export function resetStarknetMetrics(): void {
9595
for (const k of Object.keys(gauges)) delete gauges[k];
9696
}
9797

98+
/** Build a Prometheus-style metric key without allowing label syntax injection. */
99+
export function labeledStarknetMetric(
100+
name: string,
101+
labels: Record<string, string>,
102+
): string {
103+
const encoded = Object.entries(labels)
104+
.map(([key, value]) => `${key}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`)
105+
.join(",");
106+
return `${name}{${encoded}}`;
107+
}
108+
98109
// ---------------------------------------------------------------------------
99110
// Structured logging
100111
// ---------------------------------------------------------------------------
@@ -154,4 +165,6 @@ export const STARKNET_METRICS = {
154165
NETWORK_INFO_FETCHES: "starknet_network_info_fetches_total",
155166
NETWORK_INFO_DEDUPED: "starknet_network_info_deduped_total",
156167
NETWORK_INFO_ERRORS: "starknet_network_info_errors_total",
168+
CIRCUIT_BREAKER_STATE: "starknet_circuit_breaker_state",
169+
CIRCUIT_BREAKER_TRANSITIONS: "starknet_circuit_breaker_transitions_total",
157170
} as const;

0 commit comments

Comments
 (0)