limitforge 0.0.0-stage → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +461 -3
- package/index.d.ts +192 -0
- package/package.json +81 -4
- package/src/benchmark/benchmarkRunner.js +235 -0
- package/src/index.js +30 -0
- package/src/limiter/rateLimiter.js +670 -0
- package/src/resilience/circuitBreaker.js +216 -0
- package/src/resilience/errors.js +29 -0
- package/src/resilience/timeoutGuard.js +24 -0
- package/src/scripts/fixedWindow.lua +10 -0
- package/src/scripts/slidingWindow.lua +26 -0
- package/src/scripts/tokenBucket.lua +66 -0
- package/src/stores/memoryStore.js +238 -0
- package/src/stores/redisStore.js +213 -0
- package/src/stores/resilientStore.js +121 -0
- package/src/telemetry/metricsCollector.js +212 -0
- package/src/telemetry/openTelemetryBridge.js +133 -0
- package/src/telemetry/prometheusExporter.js +131 -0
- package/src/telemetry/telemetryEvents.js +58 -0
- package/src/utils/keyBuilder.js +41 -0
- package/src/utils/routeNormalizer.js +49 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
const FIXED_WINDOW_LUA = fs.readFileSync(path.join(__dirname, '../scripts/fixedWindow.lua'), 'utf-8');
|
|
10
|
+
const SLIDING_WINDOW_LUA = fs.readFileSync(path.join(__dirname, '../scripts/slidingWindow.lua'), 'utf-8');
|
|
11
|
+
const TOKEN_BUCKET_LUA = fs.readFileSync(path.join(__dirname, '../scripts/tokenBucket.lua'), 'utf-8');
|
|
12
|
+
|
|
13
|
+
export class RedisStore {
|
|
14
|
+
constructor(options) {
|
|
15
|
+
if (!options || typeof options !== 'object') {
|
|
16
|
+
throw new TypeError('SmartRate: RedisStore options must be an object.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!options.client || typeof options.client !== 'object') {
|
|
20
|
+
throw new TypeError(
|
|
21
|
+
'SmartRate: RedisStore requires a pre-connected Redis client instance (options.client). ' +
|
|
22
|
+
'Please create and connect a Redis client in your application and inject it here.'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof options.client.eval !== 'function' && typeof options.client.sendCommand !== 'function') {
|
|
27
|
+
throw new TypeError('SmartRate: Injected Redis client does not appear to be a valid Redis client instance.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.client = options.client;
|
|
31
|
+
this.script = FIXED_WINDOW_LUA;
|
|
32
|
+
this.fixedScript = FIXED_WINDOW_LUA;
|
|
33
|
+
this.slidingScript = SLIDING_WINDOW_LUA;
|
|
34
|
+
this.tokenBucketScript = TOKEN_BUCKET_LUA;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async _evalFixedScript(key, windowMs) {
|
|
38
|
+
if (typeof this.client.eval === 'function') {
|
|
39
|
+
return this.client.eval(this.fixedScript, {
|
|
40
|
+
keys: [key],
|
|
41
|
+
arguments: [String(windowMs)]
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.client.sendCommand(['EVAL', this.fixedScript, '1', key, String(windowMs)]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async _evalSlidingScript(key, now, windowMs, limit, member) {
|
|
49
|
+
if (typeof this.client.eval === 'function') {
|
|
50
|
+
return this.client.eval(this.slidingScript, {
|
|
51
|
+
keys: [key],
|
|
52
|
+
arguments: [String(now), String(windowMs), String(limit), member]
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return this.client.sendCommand([
|
|
57
|
+
'EVAL',
|
|
58
|
+
this.slidingScript,
|
|
59
|
+
'1',
|
|
60
|
+
key,
|
|
61
|
+
String(now),
|
|
62
|
+
String(windowMs),
|
|
63
|
+
String(limit),
|
|
64
|
+
member
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async _evalTokenBucketScript(key, now, capacity, refillRate, refillIntervalMs = 1000, cost = 1) {
|
|
69
|
+
if (typeof this.client.eval === 'function') {
|
|
70
|
+
return this.client.eval(this.tokenBucketScript, {
|
|
71
|
+
keys: [key],
|
|
72
|
+
arguments: [String(now), String(capacity), String(refillRate), String(refillIntervalMs), String(cost)]
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return this.client.sendCommand([
|
|
77
|
+
'EVAL',
|
|
78
|
+
this.tokenBucketScript,
|
|
79
|
+
'1',
|
|
80
|
+
key,
|
|
81
|
+
String(now),
|
|
82
|
+
String(capacity),
|
|
83
|
+
String(refillRate),
|
|
84
|
+
String(refillIntervalMs),
|
|
85
|
+
String(cost)
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async consume({
|
|
90
|
+
key,
|
|
91
|
+
limit,
|
|
92
|
+
windowMs,
|
|
93
|
+
algorithm = 'fixed-window',
|
|
94
|
+
capacity,
|
|
95
|
+
refillRate,
|
|
96
|
+
refillIntervalMs = 1000,
|
|
97
|
+
cost = 1,
|
|
98
|
+
now = Date.now()
|
|
99
|
+
}) {
|
|
100
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
101
|
+
throw new TypeError("SmartRate: RedisStore 'key' must be a non-empty string.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (algorithm === 'token-bucket') {
|
|
105
|
+
const resolvedCapacity = capacity ?? limit;
|
|
106
|
+
if (typeof resolvedCapacity !== 'number' || !Number.isInteger(resolvedCapacity) || resolvedCapacity <= 0) {
|
|
107
|
+
throw new RangeError(`SmartRate: RedisStore 'capacity' must be a positive integer (received: ${resolvedCapacity}).`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const resolvedRefillRate = refillRate ?? (limit && windowMs ? limit / (windowMs / 1000) : undefined);
|
|
111
|
+
if (typeof resolvedRefillRate !== 'number' || !Number.isFinite(resolvedRefillRate) || resolvedRefillRate <= 0) {
|
|
112
|
+
throw new RangeError(`SmartRate: RedisStore 'refillRate' must be a positive number (received: ${resolvedRefillRate}).`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof refillIntervalMs !== 'number' || !Number.isFinite(refillIntervalMs) || refillIntervalMs <= 0) {
|
|
116
|
+
throw new RangeError(`SmartRate: RedisStore 'refillIntervalMs' must be a positive number (received: ${refillIntervalMs}).`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return this._consumeTokenBucket({
|
|
120
|
+
key,
|
|
121
|
+
capacity: resolvedCapacity,
|
|
122
|
+
refillRate: resolvedRefillRate,
|
|
123
|
+
refillIntervalMs,
|
|
124
|
+
cost,
|
|
125
|
+
now
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
130
|
+
throw new RangeError(`SmartRate: RedisStore 'limit' must be a positive integer (received: ${limit}).`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (typeof windowMs !== 'number' || !Number.isFinite(windowMs) || windowMs <= 0) {
|
|
134
|
+
throw new RangeError(`SmartRate: RedisStore 'windowMs' must be a positive number in milliseconds (received: ${windowMs}).`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (algorithm === 'sliding-window') {
|
|
138
|
+
return this._consumeSlidingWindow({ key, limit, windowMs, now });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return this._consumeFixedWindow({ key, limit, windowMs });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _consumeTokenBucket({ key, capacity, refillRate, refillIntervalMs = 1000, cost = 1, now }) {
|
|
145
|
+
const rawResult = await this._evalTokenBucketScript(key, now, capacity, refillRate, refillIntervalMs, cost);
|
|
146
|
+
const [rawAllowed, rawRemaining, rawReset, rawRetryAfter] = Array.isArray(rawResult)
|
|
147
|
+
? rawResult
|
|
148
|
+
: [1, capacity - cost, 1, 0];
|
|
149
|
+
|
|
150
|
+
const allowed = Number(rawAllowed) === 1;
|
|
151
|
+
const remaining = Number(rawRemaining);
|
|
152
|
+
const reset = Number(rawReset);
|
|
153
|
+
const retryAfter = Number(rawRetryAfter);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
allowed,
|
|
157
|
+
count: Math.max(0, capacity - remaining),
|
|
158
|
+
remaining,
|
|
159
|
+
reset,
|
|
160
|
+
...(allowed ? {} : { retryAfter })
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async _consumeFixedWindow({ key, limit, windowMs }) {
|
|
165
|
+
const rawResult = await this._evalFixedScript(key, windowMs);
|
|
166
|
+
const [rawCount, rawTtlMs] = Array.isArray(rawResult) ? rawResult : [1, windowMs];
|
|
167
|
+
|
|
168
|
+
const count = Number(rawCount);
|
|
169
|
+
const ttlMs = Number(rawTtlMs);
|
|
170
|
+
|
|
171
|
+
if (ttlMs === -1) {
|
|
172
|
+
throw new Error(`SmartRate: Key '${key}' exists in Redis without an expiration TTL.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const resetSeconds = Math.max(1, Math.ceil((ttlMs > 0 ? ttlMs : windowMs) / 1000));
|
|
176
|
+
const allowed = count <= limit;
|
|
177
|
+
const remaining = Math.max(0, limit - count);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
allowed,
|
|
181
|
+
count,
|
|
182
|
+
remaining,
|
|
183
|
+
reset: resetSeconds,
|
|
184
|
+
...(allowed ? {} : { retryAfter: resetSeconds })
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async _consumeSlidingWindow({ key, limit, windowMs, now }) {
|
|
189
|
+
const uniqueId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 10);
|
|
190
|
+
const member = `${now}:${uniqueId}`;
|
|
191
|
+
|
|
192
|
+
const rawResult = await this._evalSlidingScript(key, now, windowMs, limit, member);
|
|
193
|
+
const [rawAllowed, rawCount, rawOldestScore] = Array.isArray(rawResult) ? rawResult : [1, 1, now];
|
|
194
|
+
|
|
195
|
+
const allowed = Number(rawAllowed) === 1;
|
|
196
|
+
const count = Number(rawCount);
|
|
197
|
+
const oldestScore = Number(rawOldestScore);
|
|
198
|
+
const remaining = Math.max(0, limit - count);
|
|
199
|
+
|
|
200
|
+
const resetMs = (oldestScore + windowMs) - now;
|
|
201
|
+
const resetSeconds = Math.max(1, Math.ceil(resetMs / 1000));
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
allowed,
|
|
205
|
+
count,
|
|
206
|
+
remaining,
|
|
207
|
+
reset: resetSeconds,
|
|
208
|
+
...(allowed ? {} : { retryAfter: resetSeconds })
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default RedisStore;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { MemoryStore } from './memoryStore.js';
|
|
3
|
+
import { CircuitBreaker } from '../resilience/circuitBreaker.js';
|
|
4
|
+
import { withTimeout } from '../resilience/timeoutGuard.js';
|
|
5
|
+
|
|
6
|
+
export class ResilientStore extends EventEmitter {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
super();
|
|
9
|
+
|
|
10
|
+
if (!options || typeof options !== 'object') {
|
|
11
|
+
throw new TypeError('SmartRate: ResilientStore options must be an object.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
primaryStore,
|
|
16
|
+
fallbackStore,
|
|
17
|
+
circuitBreaker,
|
|
18
|
+
timeoutMs = 250,
|
|
19
|
+
failureThreshold = 5,
|
|
20
|
+
resetTimeoutMs = 10000
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
if (!primaryStore || typeof primaryStore.consume !== 'function') {
|
|
24
|
+
throw new TypeError("SmartRate: ResilientStore requires a 'primaryStore' implementing a consume() method.");
|
|
25
|
+
}
|
|
26
|
+
this.primaryStore = primaryStore;
|
|
27
|
+
|
|
28
|
+
if (fallbackStore !== undefined) {
|
|
29
|
+
if (!fallbackStore || typeof fallbackStore.consume !== 'function') {
|
|
30
|
+
throw new TypeError("SmartRate: ResilientStore 'fallbackStore' must implement a consume() method.");
|
|
31
|
+
}
|
|
32
|
+
this.fallbackStore = fallbackStore;
|
|
33
|
+
} else {
|
|
34
|
+
this.fallbackStore = new MemoryStore();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
38
|
+
throw new RangeError(
|
|
39
|
+
`SmartRate: ResilientStore 'timeoutMs' must be a positive number in milliseconds (received: ${timeoutMs}).`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
this.timeoutMs = timeoutMs;
|
|
43
|
+
|
|
44
|
+
if (circuitBreaker instanceof CircuitBreaker) {
|
|
45
|
+
this.circuitBreaker = circuitBreaker;
|
|
46
|
+
} else if (typeof circuitBreaker === 'object' && circuitBreaker !== null) {
|
|
47
|
+
this.circuitBreaker = new CircuitBreaker(circuitBreaker);
|
|
48
|
+
} else if (circuitBreaker === false) {
|
|
49
|
+
this.circuitBreaker = null;
|
|
50
|
+
} else {
|
|
51
|
+
this.circuitBreaker = new CircuitBreaker({ failureThreshold, resetTimeoutMs });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.circuitBreaker) {
|
|
55
|
+
this.circuitBreaker.on('stateChange', (evt) => this.emit('stateChange', evt));
|
|
56
|
+
this.circuitBreaker.on('trip', (evt) => this.emit('circuitOpen', evt));
|
|
57
|
+
this.circuitBreaker.on('close', (evt) => this.emit('circuitClose', evt));
|
|
58
|
+
this.circuitBreaker.on('probe', (evt) => this.emit('probe', evt));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async consume(params) {
|
|
63
|
+
const runPrimary = () =>
|
|
64
|
+
this.timeoutMs
|
|
65
|
+
? withTimeout(this.primaryStore.consume(params), this.timeoutMs)
|
|
66
|
+
: this.primaryStore.consume(params);
|
|
67
|
+
|
|
68
|
+
let primaryError = null;
|
|
69
|
+
try {
|
|
70
|
+
const result = this.circuitBreaker
|
|
71
|
+
? await this.circuitBreaker.execute(runPrimary)
|
|
72
|
+
: await runPrimary();
|
|
73
|
+
|
|
74
|
+
this.emit('primarySuccess', { key: params.key });
|
|
75
|
+
return {
|
|
76
|
+
...result,
|
|
77
|
+
degraded: false,
|
|
78
|
+
fallbackUsed: false,
|
|
79
|
+
store: 'primary'
|
|
80
|
+
};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
primaryError = err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.emit('fallback', {
|
|
86
|
+
key: params.key,
|
|
87
|
+
error: primaryError,
|
|
88
|
+
circuitState: this.circuitBreaker ? this.circuitBreaker.getState() : 'NONE'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const fallbackResult = await this.fallbackStore.consume(params);
|
|
92
|
+
return {
|
|
93
|
+
...fallbackResult,
|
|
94
|
+
degraded: true,
|
|
95
|
+
fallbackUsed: true,
|
|
96
|
+
store: 'fallback',
|
|
97
|
+
primaryError
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cleanupExpiredRecords(now) {
|
|
102
|
+
let cleaned = 0;
|
|
103
|
+
if (typeof this.primaryStore.cleanupExpiredRecords === 'function') {
|
|
104
|
+
cleaned += this.primaryStore.cleanupExpiredRecords(now) || 0;
|
|
105
|
+
}
|
|
106
|
+
if (typeof this.fallbackStore.cleanupExpiredRecords === 'function') {
|
|
107
|
+
cleaned += this.fallbackStore.cleanupExpiredRecords(now) || 0;
|
|
108
|
+
}
|
|
109
|
+
return cleaned;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getStats() {
|
|
113
|
+
return {
|
|
114
|
+
circuitBreaker: this.circuitBreaker ? this.circuitBreaker.getStats() : null,
|
|
115
|
+
timeoutMs: this.timeoutMs,
|
|
116
|
+
hasFallbackStore: Boolean(this.fallbackStore)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default ResilientStore;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
METRIC_TYPE,
|
|
3
|
+
METRIC_NAMES,
|
|
4
|
+
CIRCUIT_STATE_GAUGE_VALUES,
|
|
5
|
+
DEFAULT_STORE_DURATION_BUCKETS
|
|
6
|
+
} from './telemetryEvents.js';
|
|
7
|
+
|
|
8
|
+
function serializeLabelKey(labels = {}) {
|
|
9
|
+
if (!labels || typeof labels !== 'object') return '';
|
|
10
|
+
const entries = Object.entries(labels).filter(([k, v]) => v !== undefined && v !== null);
|
|
11
|
+
if (entries.length === 0) return '';
|
|
12
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
13
|
+
return entries.map(([k, v]) => `${k}="${String(v)}"`).join(',');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MetricsCollector {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.storeDurationBuckets = options.storeDurationBuckets || DEFAULT_STORE_DURATION_BUCKETS;
|
|
19
|
+
this.maxSeries = typeof options.maxSeries === 'number' && options.maxSeries > 0 ? options.maxSeries : 1000;
|
|
20
|
+
|
|
21
|
+
this.counters = new Map();
|
|
22
|
+
this.gauges = new Map();
|
|
23
|
+
this.histograms = new Map();
|
|
24
|
+
|
|
25
|
+
// Default gauge for circuit breaker state (0 = CLOSED)
|
|
26
|
+
this.setGauge(METRIC_NAMES.CIRCUIT_BREAKER_STATE, CIRCUIT_STATE_GAUGE_VALUES.CLOSED);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
incrementCounter(name, labels = {}, value = 1) {
|
|
30
|
+
try {
|
|
31
|
+
if (!name || typeof name !== 'string') return;
|
|
32
|
+
if (typeof value !== 'number' || value <= 0) return;
|
|
33
|
+
|
|
34
|
+
if (!this.counters.has(name)) {
|
|
35
|
+
this.counters.set(name, new Map());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const metricMap = this.counters.get(name);
|
|
39
|
+
let targetLabels = labels;
|
|
40
|
+
let labelKey = serializeLabelKey(targetLabels);
|
|
41
|
+
|
|
42
|
+
if (!metricMap.has(labelKey)) {
|
|
43
|
+
if (metricMap.size >= this.maxSeries) {
|
|
44
|
+
if (labels && labels.normalized_route) {
|
|
45
|
+
targetLabels = { ...labels, normalized_route: '_overflow' };
|
|
46
|
+
labelKey = serializeLabelKey(targetLabels);
|
|
47
|
+
if (!metricMap.has(labelKey) && metricMap.size >= this.maxSeries) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const current = metricMap.get(labelKey) || { value: 0, labels: { ...targetLabels } };
|
|
57
|
+
current.value += value;
|
|
58
|
+
metricMap.set(labelKey, current);
|
|
59
|
+
} catch {
|
|
60
|
+
// safe telemetry boundary
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setGauge(name, value, labels = {}) {
|
|
65
|
+
try {
|
|
66
|
+
if (!name || typeof name !== 'string') return;
|
|
67
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
|
68
|
+
|
|
69
|
+
if (!this.gauges.has(name)) {
|
|
70
|
+
this.gauges.set(name, new Map());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const metricMap = this.gauges.get(name);
|
|
74
|
+
let targetLabels = labels;
|
|
75
|
+
let labelKey = serializeLabelKey(targetLabels);
|
|
76
|
+
|
|
77
|
+
if (!metricMap.has(labelKey) && metricMap.size >= this.maxSeries) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
metricMap.set(labelKey, {
|
|
82
|
+
value,
|
|
83
|
+
labels: { ...targetLabels }
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// safe telemetry boundary
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
observeHistogram(name, durationSeconds, labels = {}, customBuckets = this.storeDurationBuckets) {
|
|
91
|
+
try {
|
|
92
|
+
if (!name || typeof name !== 'string') return;
|
|
93
|
+
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds < 0) return;
|
|
94
|
+
|
|
95
|
+
if (!this.histograms.has(name)) {
|
|
96
|
+
this.histograms.set(name, new Map());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const metricMap = this.histograms.get(name);
|
|
100
|
+
let targetLabels = labels;
|
|
101
|
+
let labelKey = serializeLabelKey(targetLabels);
|
|
102
|
+
|
|
103
|
+
if (!metricMap.has(labelKey) && metricMap.size >= this.maxSeries) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let record = metricMap.get(labelKey);
|
|
108
|
+
if (!record) {
|
|
109
|
+
const sortedBounds = [...customBuckets].sort((a, b) => a - b);
|
|
110
|
+
record = {
|
|
111
|
+
labels: { ...targetLabels },
|
|
112
|
+
buckets: sortedBounds.map((le) => ({ le, count: 0 })),
|
|
113
|
+
sum: 0,
|
|
114
|
+
count: 0
|
|
115
|
+
};
|
|
116
|
+
metricMap.set(labelKey, record);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
record.sum += durationSeconds;
|
|
120
|
+
record.count += 1;
|
|
121
|
+
|
|
122
|
+
for (const bucket of record.buckets) {
|
|
123
|
+
if (durationSeconds <= bucket.le) {
|
|
124
|
+
bucket.count += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// safe telemetry boundary
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
recordRequest({ outcome, algorithm, method, normalized_route, store }) {
|
|
133
|
+
this.incrementCounter(METRIC_NAMES.REQUESTS_TOTAL, {
|
|
134
|
+
outcome: outcome || 'unknown',
|
|
135
|
+
algorithm: algorithm || 'unknown',
|
|
136
|
+
method: (method || 'GET').toUpperCase(),
|
|
137
|
+
normalized_route: normalized_route || '/',
|
|
138
|
+
store: store || 'unknown'
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
recordStoreError({ store, error_type }) {
|
|
143
|
+
this.incrementCounter(METRIC_NAMES.STORE_ERRORS_TOTAL, {
|
|
144
|
+
store: store || 'unknown',
|
|
145
|
+
error_type: error_type || 'store_error'
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
recordDegradedRequest({ reason }) {
|
|
150
|
+
this.incrementCounter(METRIC_NAMES.DEGRADED_REQUESTS_TOTAL, {
|
|
151
|
+
reason: reason || 'fallback'
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
recordCircuitBreakerState(state, labels = {}) {
|
|
156
|
+
const numericValue =
|
|
157
|
+
CIRCUIT_STATE_GAUGE_VALUES[state] !== undefined
|
|
158
|
+
? CIRCUIT_STATE_GAUGE_VALUES[state]
|
|
159
|
+
: CIRCUIT_STATE_GAUGE_VALUES.CLOSED;
|
|
160
|
+
|
|
161
|
+
this.setGauge(METRIC_NAMES.CIRCUIT_BREAKER_STATE, numericValue, labels);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
recordStoreDuration(durationSeconds, labels = {}) {
|
|
165
|
+
this.observeHistogram(METRIC_NAMES.STORE_DURATION_SECONDS, durationSeconds, labels);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getSnapshot() {
|
|
169
|
+
const snapshot = {
|
|
170
|
+
counters: {},
|
|
171
|
+
gauges: {},
|
|
172
|
+
histograms: {}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const [name, metricMap] of this.counters.entries()) {
|
|
176
|
+
snapshot.counters[name] = [];
|
|
177
|
+
for (const record of metricMap.values()) {
|
|
178
|
+
snapshot.counters[name].push({ ...record, labels: { ...record.labels } });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [name, metricMap] of this.gauges.entries()) {
|
|
183
|
+
snapshot.gauges[name] = [];
|
|
184
|
+
for (const record of metricMap.values()) {
|
|
185
|
+
snapshot.gauges[name].push({ ...record, labels: { ...record.labels } });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const [name, metricMap] of this.histograms.entries()) {
|
|
190
|
+
snapshot.histograms[name] = [];
|
|
191
|
+
for (const record of metricMap.values()) {
|
|
192
|
+
snapshot.histograms[name].push({
|
|
193
|
+
labels: { ...record.labels },
|
|
194
|
+
buckets: record.buckets.map((b) => ({ ...b })),
|
|
195
|
+
sum: record.sum,
|
|
196
|
+
count: record.count
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return snapshot;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
reset() {
|
|
205
|
+
this.counters.clear();
|
|
206
|
+
this.gauges.clear();
|
|
207
|
+
this.histograms.clear();
|
|
208
|
+
this.setGauge(METRIC_NAMES.CIRCUIT_BREAKER_STATE, CIRCUIT_STATE_GAUGE_VALUES.CLOSED);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const defaultMetricsCollector = new MetricsCollector();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export class OpenTelemetryBridge {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.tracer = options.tracer || null;
|
|
4
|
+
this.getSpan = options.getSpan || null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
startSpan(name = 'smartrate.consume', options = {}) {
|
|
8
|
+
if (this.tracer && typeof this.tracer.startSpan === 'function') {
|
|
9
|
+
try {
|
|
10
|
+
return this.tracer.startSpan(name, options);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
resolveSpan(req) {
|
|
19
|
+
if (typeof this.getSpan === 'function') {
|
|
20
|
+
try {
|
|
21
|
+
const span = this.getSpan(req);
|
|
22
|
+
if (span && typeof span.setAttribute === 'function') {
|
|
23
|
+
return span;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (req && req.span && typeof req.span.setAttribute === 'function') {
|
|
31
|
+
return req.span;
|
|
32
|
+
}
|
|
33
|
+
if (req && req.telemetrySpan && typeof req.telemetrySpan.setAttribute === 'function') {
|
|
34
|
+
return req.telemetrySpan;
|
|
35
|
+
}
|
|
36
|
+
if (req && req._smartRateSpan && typeof req._smartRateSpan.setAttribute === 'function') {
|
|
37
|
+
return req._smartRateSpan;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const api = globalThis[Symbol.for('opentelemetry.js.api.1')];
|
|
42
|
+
if (api && api.trace && typeof api.trace.getActiveSpan === 'function') {
|
|
43
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
44
|
+
if (activeSpan && typeof activeSpan.setAttribute === 'function') {
|
|
45
|
+
return activeSpan;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// safe boundary
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
recordEvaluation({ req, result, algorithm, normalizedRoute, store }) {
|
|
56
|
+
try {
|
|
57
|
+
const span = this.resolveSpan(req);
|
|
58
|
+
if (!span || typeof span.setAttribute !== 'function') return;
|
|
59
|
+
|
|
60
|
+
const isAllowed = Boolean(result.allowed);
|
|
61
|
+
const remainingVal = typeof result.remaining === 'number' ? result.remaining : 0;
|
|
62
|
+
const resetVal = typeof result.reset === 'number' ? result.reset : 0;
|
|
63
|
+
const algoStr = algorithm || 'fixed-window';
|
|
64
|
+
const routeStr = normalizedRoute || '/';
|
|
65
|
+
const storeStr = store || 'unknown';
|
|
66
|
+
|
|
67
|
+
span.setAttribute('smartrate.outcome', isAllowed ? 'allowed' : 'blocked');
|
|
68
|
+
span.setAttribute('smartrate.algorithm', algoStr);
|
|
69
|
+
span.setAttribute('smartrate.route', routeStr);
|
|
70
|
+
span.setAttribute('smartrate.store', storeStr);
|
|
71
|
+
|
|
72
|
+
if (typeof result.remaining === 'number') {
|
|
73
|
+
span.setAttribute('smartrate.remaining', result.remaining);
|
|
74
|
+
}
|
|
75
|
+
if (typeof result.reset === 'number') {
|
|
76
|
+
span.setAttribute('smartrate.reset', result.reset);
|
|
77
|
+
}
|
|
78
|
+
if (result.degraded) {
|
|
79
|
+
span.setAttribute('smartrate.degraded', true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
span.setAttribute('ratelimit.allowed', isAllowed);
|
|
83
|
+
span.setAttribute('ratelimit.remaining', remainingVal);
|
|
84
|
+
span.setAttribute('ratelimit.reset', resetVal);
|
|
85
|
+
span.setAttribute('ratelimit.algorithm', algoStr);
|
|
86
|
+
span.setAttribute('ratelimit.store', storeStr);
|
|
87
|
+
span.setAttribute('ratelimit.degraded', Boolean(result.degraded));
|
|
88
|
+
|
|
89
|
+
if (result.circuitState) {
|
|
90
|
+
span.setAttribute('ratelimit.circuit_state', result.circuitState);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!isAllowed) {
|
|
94
|
+
if (typeof span.addEvent === 'function') {
|
|
95
|
+
span.addEvent('smartrate.rate_limit_exceeded', {
|
|
96
|
+
'smartrate.retry_after': result.retryAfter || 0,
|
|
97
|
+
'smartrate.route': routeStr,
|
|
98
|
+
'ratelimit.retry_after': result.retryAfter || 0
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// safe boundary
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recordStoreError({ req, storeError, store }) {
|
|
108
|
+
try {
|
|
109
|
+
const span = this.resolveSpan(req);
|
|
110
|
+
if (!span) return;
|
|
111
|
+
|
|
112
|
+
if (typeof span.recordException === 'function' && storeError instanceof Error) {
|
|
113
|
+
span.recordException(storeError);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof span.setAttribute === 'function') {
|
|
117
|
+
span.setAttribute('smartrate.store_error', true);
|
|
118
|
+
span.setAttribute('smartrate.store', store || 'unknown');
|
|
119
|
+
span.setAttribute('ratelimit.store_error', true);
|
|
120
|
+
span.setAttribute('ratelimit.store', store || 'unknown');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof span.addEvent === 'function') {
|
|
124
|
+
span.addEvent('smartrate.store_error', {
|
|
125
|
+
'error.name': storeError?.name || 'Error',
|
|
126
|
+
'error.message': storeError?.message || 'Store operation failed'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// safe boundary
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|