te.js 2.2.0 → 2.2.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/README.md +1 -12
- package/docs/README.md +1 -2
- package/docs/api-reference.md +124 -186
- package/docs/configuration.md +0 -13
- package/docs/getting-started.md +19 -21
- package/docs/rate-limiting.md +59 -58
- package/lib/llm/client.js +1 -1
- package/package.json +1 -1
- package/radar/index.js +191 -90
- package/rate-limit/base.js +12 -15
- package/rate-limit/index.js +12 -12
- package/rate-limit/index.test.js +45 -16
- package/rate-limit/storage/memory.js +13 -13
- package/rate-limit/storage/redis-install.js +70 -0
- package/rate-limit/storage/redis.js +94 -52
- package/server/ammo.js +3 -2
- package/te.js +64 -143
- package/utils/errors-llm-config.js +63 -0
- package/utils/startup.js +80 -0
- package/database/index.js +0 -167
- package/database/mongodb.js +0 -152
- package/database/redis.js +0 -210
- package/docs/database.md +0 -390
package/docs/rate-limiting.md
CHANGED
|
@@ -10,10 +10,9 @@ import Tejas from 'te.js';
|
|
|
10
10
|
const app = new Tejas();
|
|
11
11
|
|
|
12
12
|
app
|
|
13
|
-
.withRedis({ url: 'redis://localhost:6379' })
|
|
14
13
|
.withRateLimit({
|
|
15
14
|
maxRequests: 100,
|
|
16
|
-
timeWindowSeconds: 60
|
|
15
|
+
timeWindowSeconds: 60,
|
|
17
16
|
})
|
|
18
17
|
.takeoff();
|
|
19
18
|
```
|
|
@@ -25,35 +24,35 @@ This limits all endpoints to 100 requests per minute per IP address.
|
|
|
25
24
|
```javascript
|
|
26
25
|
app.withRateLimit({
|
|
27
26
|
// Core settings
|
|
28
|
-
maxRequests: 100,
|
|
29
|
-
timeWindowSeconds: 60,
|
|
30
|
-
|
|
27
|
+
maxRequests: 100, // Maximum requests in time window
|
|
28
|
+
timeWindowSeconds: 60, // Time window in seconds
|
|
29
|
+
|
|
31
30
|
// Algorithm selection
|
|
32
31
|
algorithm: 'sliding-window', // 'sliding-window' | 'token-bucket' | 'fixed-window'
|
|
33
|
-
|
|
32
|
+
|
|
34
33
|
// Storage backend
|
|
35
|
-
store: '
|
|
36
|
-
|
|
34
|
+
store: 'memory', // 'memory' or { type: 'redis', url: '...' }
|
|
35
|
+
|
|
37
36
|
// Custom key generator (defaults to IP-based)
|
|
38
37
|
keyGenerator: (ammo) => ammo.ip,
|
|
39
|
-
|
|
38
|
+
|
|
40
39
|
// Algorithm-specific options
|
|
41
40
|
algorithmOptions: {},
|
|
42
|
-
|
|
41
|
+
|
|
43
42
|
// Key prefix for storage keys (useful for namespacing)
|
|
44
43
|
keyPrefix: 'rl:',
|
|
45
|
-
|
|
44
|
+
|
|
46
45
|
// Header format
|
|
47
46
|
headerFormat: {
|
|
48
|
-
type: 'standard',
|
|
49
|
-
draft7: false,
|
|
50
|
-
draft8: false
|
|
47
|
+
type: 'standard', // 'standard' | 'legacy' | 'both'
|
|
48
|
+
draft7: false, // Include RateLimit-Policy header (e.g. "100;w=60")
|
|
49
|
+
draft8: false, // Use delta-seconds for RateLimit-Reset instead of Unix timestamp
|
|
51
50
|
},
|
|
52
|
-
|
|
51
|
+
|
|
53
52
|
// Custom handler when rate limited
|
|
54
53
|
onRateLimited: (ammo) => {
|
|
55
54
|
ammo.fire(429, { error: 'Slow down!' });
|
|
56
|
-
}
|
|
55
|
+
},
|
|
57
56
|
});
|
|
58
57
|
```
|
|
59
58
|
|
|
@@ -67,7 +66,7 @@ Best for smooth, accurate rate limiting. Prevents the "burst at window boundary"
|
|
|
67
66
|
app.withRateLimit({
|
|
68
67
|
maxRequests: 100,
|
|
69
68
|
timeWindowSeconds: 60,
|
|
70
|
-
algorithm: 'sliding-window'
|
|
69
|
+
algorithm: 'sliding-window',
|
|
71
70
|
});
|
|
72
71
|
```
|
|
73
72
|
|
|
@@ -83,9 +82,9 @@ app.withRateLimit({
|
|
|
83
82
|
timeWindowSeconds: 60,
|
|
84
83
|
algorithm: 'token-bucket',
|
|
85
84
|
algorithmOptions: {
|
|
86
|
-
refillRate: 1.67,
|
|
87
|
-
burstSize: 150
|
|
88
|
-
}
|
|
85
|
+
refillRate: 1.67, // Tokens per second (100/60)
|
|
86
|
+
burstSize: 150, // Maximum tokens (allows 50% burst)
|
|
87
|
+
},
|
|
89
88
|
});
|
|
90
89
|
```
|
|
91
90
|
|
|
@@ -101,8 +100,8 @@ app.withRateLimit({
|
|
|
101
100
|
timeWindowSeconds: 60,
|
|
102
101
|
algorithm: 'fixed-window',
|
|
103
102
|
algorithmOptions: {
|
|
104
|
-
strictWindow: true
|
|
105
|
-
}
|
|
103
|
+
strictWindow: true, // Align to clock boundaries
|
|
104
|
+
},
|
|
106
105
|
});
|
|
107
106
|
```
|
|
108
107
|
|
|
@@ -118,31 +117,34 @@ Good for single-server deployments:
|
|
|
118
117
|
app.withRateLimit({
|
|
119
118
|
maxRequests: 100,
|
|
120
119
|
timeWindowSeconds: 60,
|
|
121
|
-
store: 'memory'
|
|
120
|
+
store: 'memory',
|
|
122
121
|
});
|
|
123
122
|
```
|
|
124
123
|
|
|
125
124
|
**Pros:** No external dependencies, fast
|
|
126
|
-
**Cons:** Not shared between server instances
|
|
125
|
+
**Cons:** Not shared between server instances — may be inaccurate in distributed deployments
|
|
126
|
+
|
|
127
|
+
> **Warning:** If you run multiple server instances (e.g. behind a load balancer), each instance tracks its own counters independently. Use Redis storage below for accurate distributed rate limiting.
|
|
127
128
|
|
|
128
129
|
### Redis
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
For distributed / multi-instance deployments where counters must be shared:
|
|
131
132
|
|
|
132
133
|
```javascript
|
|
133
|
-
app
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
134
|
+
app.withRateLimit({
|
|
135
|
+
maxRequests: 100,
|
|
136
|
+
timeWindowSeconds: 60,
|
|
137
|
+
store: {
|
|
138
|
+
type: 'redis',
|
|
139
|
+
url: 'redis://localhost:6379',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
140
142
|
```
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
**Cons:** Requires Redis server, slightly higher latency
|
|
144
|
+
The `redis` npm package is auto-installed on first use if not already present. Any additional properties in the `store` object are forwarded to the node-redis `createClient` call.
|
|
144
145
|
|
|
145
|
-
|
|
146
|
+
**Pros:** Shared across all server instances, persistent
|
|
147
|
+
**Cons:** Requires a Redis server, slightly higher latency
|
|
146
148
|
|
|
147
149
|
## Custom Key Generation
|
|
148
150
|
|
|
@@ -154,7 +156,7 @@ By default, rate limiting is based on client IP. Customize this:
|
|
|
154
156
|
app.withRateLimit({
|
|
155
157
|
maxRequests: 100,
|
|
156
158
|
timeWindowSeconds: 60,
|
|
157
|
-
keyGenerator: (ammo) => ammo.user?.id || ammo.ip
|
|
159
|
+
keyGenerator: (ammo) => ammo.user?.id || ammo.ip,
|
|
158
160
|
});
|
|
159
161
|
```
|
|
160
162
|
|
|
@@ -164,7 +166,7 @@ app.withRateLimit({
|
|
|
164
166
|
app.withRateLimit({
|
|
165
167
|
maxRequests: 1000,
|
|
166
168
|
timeWindowSeconds: 60,
|
|
167
|
-
keyGenerator: (ammo) => ammo.headers['x-api-key'] || ammo.ip
|
|
169
|
+
keyGenerator: (ammo) => ammo.headers['x-api-key'] || ammo.ip,
|
|
168
170
|
});
|
|
169
171
|
```
|
|
170
172
|
|
|
@@ -174,7 +176,7 @@ app.withRateLimit({
|
|
|
174
176
|
app.withRateLimit({
|
|
175
177
|
maxRequests: 100,
|
|
176
178
|
timeWindowSeconds: 60,
|
|
177
|
-
keyGenerator: (ammo) => `${ammo.ip}:${ammo.endpoint}
|
|
179
|
+
keyGenerator: (ammo) => `${ammo.ip}:${ammo.endpoint}`,
|
|
178
180
|
});
|
|
179
181
|
```
|
|
180
182
|
|
|
@@ -194,7 +196,7 @@ RateLimit-Reset: 1706540400
|
|
|
194
196
|
|
|
195
197
|
```javascript
|
|
196
198
|
app.withRateLimit({
|
|
197
|
-
headerFormat: { type: 'legacy' }
|
|
199
|
+
headerFormat: { type: 'legacy' },
|
|
198
200
|
});
|
|
199
201
|
```
|
|
200
202
|
|
|
@@ -208,7 +210,7 @@ X-RateLimit-Reset: 1706540400
|
|
|
208
210
|
|
|
209
211
|
```javascript
|
|
210
212
|
app.withRateLimit({
|
|
211
|
-
headerFormat: { type: 'both' }
|
|
213
|
+
headerFormat: { type: 'both' },
|
|
212
214
|
});
|
|
213
215
|
```
|
|
214
216
|
|
|
@@ -216,7 +218,7 @@ app.withRateLimit({
|
|
|
216
218
|
|
|
217
219
|
```javascript
|
|
218
220
|
app.withRateLimit({
|
|
219
|
-
headerFormat: { type: 'standard', draft7: true }
|
|
221
|
+
headerFormat: { type: 'standard', draft7: true },
|
|
220
222
|
});
|
|
221
223
|
```
|
|
222
224
|
|
|
@@ -238,9 +240,9 @@ app.withRateLimit({
|
|
|
238
240
|
ammo.fire(429, {
|
|
239
241
|
error: 'Rate limit exceeded',
|
|
240
242
|
message: 'Please slow down and try again later',
|
|
241
|
-
retryAfter: ammo.res.getHeader('Retry-After')
|
|
243
|
+
retryAfter: ammo.res.getHeader('Retry-After'),
|
|
242
244
|
});
|
|
243
|
-
}
|
|
245
|
+
},
|
|
244
246
|
});
|
|
245
247
|
```
|
|
246
248
|
|
|
@@ -259,14 +261,14 @@ const api = new Target('/api');
|
|
|
259
261
|
const authLimiter = rateLimiter({
|
|
260
262
|
maxRequests: 5,
|
|
261
263
|
timeWindowSeconds: 60,
|
|
262
|
-
algorithm: 'fixed-window'
|
|
264
|
+
algorithm: 'fixed-window',
|
|
263
265
|
});
|
|
264
266
|
|
|
265
267
|
// Relaxed limit for read operations
|
|
266
268
|
const readLimiter = rateLimiter({
|
|
267
269
|
maxRequests: 1000,
|
|
268
270
|
timeWindowSeconds: 60,
|
|
269
|
-
algorithm: 'sliding-window'
|
|
271
|
+
algorithm: 'sliding-window',
|
|
270
272
|
});
|
|
271
273
|
|
|
272
274
|
// Apply to specific routes
|
|
@@ -281,11 +283,11 @@ api.register('/data', readLimiter, (ammo) => {
|
|
|
281
283
|
|
|
282
284
|
## Algorithm Comparison
|
|
283
285
|
|
|
284
|
-
| Algorithm
|
|
285
|
-
|
|
286
|
-
| **Sliding Window** | Most APIs
|
|
287
|
-
| **Token Bucket**
|
|
288
|
-
| **Fixed Window**
|
|
286
|
+
| Algorithm | Best For | Burst Handling | Accuracy | Memory |
|
|
287
|
+
| ------------------ | ------------------- | ------------------ | -------- | ------ |
|
|
288
|
+
| **Sliding Window** | Most APIs | Smooth | High | Medium |
|
|
289
|
+
| **Token Bucket** | Burst-tolerant APIs | Allows bursts | Medium | Low |
|
|
290
|
+
| **Fixed Window** | Simple cases | Poor at boundaries | Low | Low |
|
|
289
291
|
|
|
290
292
|
## Examples
|
|
291
293
|
|
|
@@ -295,7 +297,7 @@ api.register('/data', readLimiter, (ammo) => {
|
|
|
295
297
|
const tierLimits = {
|
|
296
298
|
free: { maxRequests: 100, timeWindowSeconds: 3600 },
|
|
297
299
|
pro: { maxRequests: 1000, timeWindowSeconds: 3600 },
|
|
298
|
-
enterprise: { maxRequests: 10000, timeWindowSeconds: 3600 }
|
|
300
|
+
enterprise: { maxRequests: 10000, timeWindowSeconds: 3600 },
|
|
299
301
|
};
|
|
300
302
|
|
|
301
303
|
app.withRateLimit({
|
|
@@ -309,8 +311,8 @@ app.withRateLimit({
|
|
|
309
311
|
getLimits: (key) => {
|
|
310
312
|
const tier = key.split(':')[0];
|
|
311
313
|
return tierLimits[tier] || tierLimits.free;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
+
},
|
|
315
|
+
},
|
|
314
316
|
});
|
|
315
317
|
```
|
|
316
318
|
|
|
@@ -320,14 +322,13 @@ app.withRateLimit({
|
|
|
320
322
|
// Global rate limit
|
|
321
323
|
app.withRateLimit({
|
|
322
324
|
maxRequests: 1000,
|
|
323
|
-
timeWindowSeconds: 60
|
|
325
|
+
timeWindowSeconds: 60,
|
|
324
326
|
});
|
|
325
327
|
|
|
326
328
|
// Stricter limit for expensive endpoints
|
|
327
329
|
const expensiveLimiter = rateLimiter({
|
|
328
330
|
maxRequests: 10,
|
|
329
331
|
timeWindowSeconds: 60,
|
|
330
|
-
store: 'redis'
|
|
331
332
|
});
|
|
332
333
|
|
|
333
334
|
api.register('/search', expensiveLimiter, (ammo) => {
|
|
@@ -348,7 +349,7 @@ target.register('/status', (ammo) => {
|
|
|
348
349
|
ammo.fire({
|
|
349
350
|
limit: ammo.res.getHeader('RateLimit-Limit'),
|
|
350
351
|
remaining: ammo.res.getHeader('RateLimit-Remaining'),
|
|
351
|
-
reset: ammo.res.getHeader('RateLimit-Reset')
|
|
352
|
+
reset: ammo.res.getHeader('RateLimit-Reset'),
|
|
352
353
|
});
|
|
353
354
|
});
|
|
354
355
|
```
|
|
@@ -380,11 +381,11 @@ class PostgresStorage extends RateLimitStorage {
|
|
|
380
381
|
}
|
|
381
382
|
```
|
|
382
383
|
|
|
383
|
-
The built-in
|
|
384
|
+
The built-in `MemoryStorage` and `RedisStorage` backends both extend this base class.
|
|
384
385
|
|
|
385
386
|
## Best Practices
|
|
386
387
|
|
|
387
|
-
1. **Use Redis in production** — Memory store doesn't
|
|
388
|
+
1. **Use Redis in production** — Memory store doesn't share counters across instances; use `store: { type: 'redis', url: '...' }` for distributed deployments
|
|
388
389
|
2. **Set appropriate limits** — Too strict frustrates users, too lenient invites abuse
|
|
389
390
|
3. **Different limits for different endpoints** — Auth endpoints need stricter limits
|
|
390
391
|
4. **Include headers** — Help clients self-regulate
|
package/lib/llm/client.js
CHANGED
package/package.json
CHANGED
package/radar/index.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { gzip } from 'node:zlib';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const gzipAsync = promisify(gzip);
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
9
|
import TejLogger from 'tej-logger';
|
|
5
10
|
|
|
6
11
|
const logger = new TejLogger('Tejas.Radar');
|
|
7
12
|
|
|
13
|
+
/**
|
|
14
|
+
* AsyncLocalStorage instance for propagating trace context across async
|
|
15
|
+
* boundaries within a single request. Middleware sets `{ traceId }` on
|
|
16
|
+
* entry; downstream code can read it via `traceStore.getStore()?.traceId`.
|
|
17
|
+
*/
|
|
18
|
+
export const traceStore = new AsyncLocalStorage();
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Attempt to read the `name` field from the nearest package.json at startup.
|
|
10
22
|
* Returns null if the file cannot be read or parsed.
|
|
@@ -92,6 +104,9 @@ function parseJsonSafe(raw) {
|
|
|
92
104
|
* @param {string} [config.projectName] Project identifier. Falls back to RADAR_PROJECT_NAME env, then package.json `name`, then "tejas-app".
|
|
93
105
|
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default 2000).
|
|
94
106
|
* @param {number} [config.batchSize] Flush immediately when batch reaches this size (default 100).
|
|
107
|
+
* @param {number} [config.maxQueueSize] Maximum events held in memory before oldest are dropped (default 10000).
|
|
108
|
+
* @param {Function} [config.transport] Custom transport `(events) => Promise<{ok, status}>`.
|
|
109
|
+
* Defaults to gzip-compressed HTTP POST to the collector.
|
|
95
110
|
* @param {string[]} [config.ignore] Request paths to skip (default ['/health']).
|
|
96
111
|
* @param {Object} [config.capture] Controls what additional data is captured beyond metrics.
|
|
97
112
|
* @param {boolean} [config.capture.request] Capture and send request body (default false).
|
|
@@ -123,6 +138,7 @@ async function radarMiddleware(config = {}) {
|
|
|
123
138
|
|
|
124
139
|
const flushInterval = config.flushInterval ?? 2000;
|
|
125
140
|
const batchSize = config.batchSize ?? 100;
|
|
141
|
+
const maxQueueSize = config.maxQueueSize ?? 10_000;
|
|
126
142
|
const ignorePaths = new Set(config.ignore ?? ['/health']);
|
|
127
143
|
|
|
128
144
|
const capture = Object.freeze({
|
|
@@ -139,10 +155,13 @@ async function radarMiddleware(config = {}) {
|
|
|
139
155
|
);
|
|
140
156
|
|
|
141
157
|
if (!apiKey) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
158
|
+
const mw = (_ammo, next) => next();
|
|
159
|
+
mw._radarStatus = {
|
|
160
|
+
feature: 'Radar',
|
|
161
|
+
ok: null,
|
|
162
|
+
detail: 'disabled (no API key)',
|
|
163
|
+
};
|
|
164
|
+
return mw;
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
const ingestUrl = `${collectorUrl}/ingest`;
|
|
@@ -152,61 +171,113 @@ async function radarMiddleware(config = {}) {
|
|
|
152
171
|
/** @type {Array<Object>} */
|
|
153
172
|
let batch = [];
|
|
154
173
|
let connected = false;
|
|
174
|
+
let retryQueue = null;
|
|
175
|
+
let retryCount = 0;
|
|
176
|
+
const MAX_RETRIES = 3;
|
|
177
|
+
|
|
178
|
+
async function defaultHttpTransport(events) {
|
|
179
|
+
const json = JSON.stringify(events);
|
|
180
|
+
const compressed = await gzipAsync(Buffer.from(json));
|
|
181
|
+
return fetch(ingestUrl, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: {
|
|
184
|
+
'Content-Type': 'application/json',
|
|
185
|
+
'Content-Encoding': 'gzip',
|
|
186
|
+
Authorization: authHeader,
|
|
187
|
+
},
|
|
188
|
+
body: compressed,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
155
191
|
|
|
156
|
-
|
|
192
|
+
const send = config.transport ?? defaultHttpTransport;
|
|
157
193
|
|
|
194
|
+
/** @type {{ feature: string, ok: boolean, detail: string }} */
|
|
195
|
+
let radarStatus;
|
|
158
196
|
try {
|
|
159
197
|
const healthRes = await fetch(healthUrl);
|
|
160
198
|
if (healthRes.ok) {
|
|
161
|
-
|
|
199
|
+
radarStatus = {
|
|
200
|
+
feature: 'Radar',
|
|
201
|
+
ok: true,
|
|
202
|
+
detail: `connected (${collectorUrl})`,
|
|
203
|
+
};
|
|
162
204
|
} else {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
205
|
+
radarStatus = {
|
|
206
|
+
feature: 'Radar',
|
|
207
|
+
ok: false,
|
|
208
|
+
detail: `collector returned ${healthRes.status} (${collectorUrl})`,
|
|
209
|
+
};
|
|
166
210
|
}
|
|
167
211
|
} catch (err) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
212
|
+
radarStatus = {
|
|
213
|
+
feature: 'Radar',
|
|
214
|
+
ok: false,
|
|
215
|
+
detail: `unreachable (${collectorUrl})`,
|
|
216
|
+
};
|
|
171
217
|
}
|
|
172
218
|
|
|
173
|
-
async function
|
|
174
|
-
if (batch.length === 0) return;
|
|
175
|
-
const payload = batch;
|
|
176
|
-
batch = [];
|
|
177
|
-
|
|
219
|
+
async function sendPayload(payload) {
|
|
178
220
|
try {
|
|
179
|
-
const res = await
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
);
|
|
192
|
-
} else if (res.status === 401 && connected) {
|
|
193
|
-
connected = false;
|
|
194
|
-
logger.warn('Radar API key rejected by collector.');
|
|
195
|
-
} else if (res.status === 401) {
|
|
221
|
+
const res = await send(payload);
|
|
222
|
+
if (res.ok) {
|
|
223
|
+
if (!connected) {
|
|
224
|
+
connected = true;
|
|
225
|
+
logger.info(
|
|
226
|
+
`Connected — project: "${projectName}", collector: ${collectorUrl}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (res.status === 401) {
|
|
232
|
+
if (connected) connected = false;
|
|
196
233
|
logger.warn(
|
|
197
234
|
'Radar API key rejected by collector. Telemetry will not be recorded.',
|
|
198
235
|
);
|
|
236
|
+
return true;
|
|
199
237
|
}
|
|
238
|
+
return false;
|
|
200
239
|
} catch (err) {
|
|
201
240
|
logger.warn(`Radar flush failed: ${err.message}`);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function flush() {
|
|
246
|
+
if (retryQueue) {
|
|
247
|
+
const ok = await sendPayload(retryQueue);
|
|
248
|
+
if (ok) {
|
|
249
|
+
retryQueue = null;
|
|
250
|
+
retryCount = 0;
|
|
251
|
+
} else {
|
|
252
|
+
retryCount++;
|
|
253
|
+
if (retryCount >= MAX_RETRIES) {
|
|
254
|
+
logger.warn(
|
|
255
|
+
`Radar dropping ${retryQueue.length} events after ${MAX_RETRIES} failed retries`,
|
|
256
|
+
);
|
|
257
|
+
retryQueue = null;
|
|
258
|
+
retryCount = 0;
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (batch.length === 0) return;
|
|
265
|
+
const payload = batch;
|
|
266
|
+
batch = [];
|
|
267
|
+
|
|
268
|
+
const ok = await sendPayload(payload);
|
|
269
|
+
if (!ok) {
|
|
270
|
+
retryQueue = payload;
|
|
271
|
+
retryCount = 1;
|
|
202
272
|
}
|
|
203
273
|
}
|
|
204
274
|
|
|
205
275
|
const timer = setInterval(flush, flushInterval);
|
|
206
276
|
if (timer.unref) timer.unref();
|
|
207
277
|
|
|
208
|
-
|
|
278
|
+
function radarCapture(ammo, next) {
|
|
209
279
|
const startTime = Date.now();
|
|
280
|
+
const traceId = randomUUID().replace(/-/g, '');
|
|
210
281
|
|
|
211
282
|
ammo.res.on('finish', () => {
|
|
212
283
|
const path = ammo.endpoint ?? ammo.path ?? '/';
|
|
@@ -214,68 +285,98 @@ async function radarMiddleware(config = {}) {
|
|
|
214
285
|
if (ammo.method === 'OPTIONS' || ignorePaths.has(path)) return;
|
|
215
286
|
|
|
216
287
|
const status = ammo.res.statusCode;
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
288
|
+
const endTimestamp = Date.now();
|
|
289
|
+
const duration = endTimestamp - startTime;
|
|
290
|
+
const payloadSize = Buffer.byteLength(
|
|
291
|
+
JSON.stringify(ammo.payload ?? {}),
|
|
292
|
+
'utf8',
|
|
293
|
+
);
|
|
294
|
+
const responseSize = Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8');
|
|
295
|
+
const ip = ammo.ip ?? null;
|
|
296
|
+
const userAgent = ammo.headers?.['user-agent'] ?? null;
|
|
297
|
+
const headers = buildHeaders(ammo.headers, capture.headers);
|
|
298
|
+
const requestBody = capture.request
|
|
299
|
+
? deepMask(ammo.payload ?? null, clientMaskBlocklist)
|
|
300
|
+
: null;
|
|
301
|
+
const responseBody = capture.response
|
|
302
|
+
? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
|
|
303
|
+
: null;
|
|
304
|
+
|
|
305
|
+
function pushEvents() {
|
|
306
|
+
const errorInfo = ammo._errorInfo ?? null;
|
|
307
|
+
|
|
308
|
+
let errorField = null;
|
|
309
|
+
if (status >= 400 && errorInfo) {
|
|
310
|
+
errorField = JSON.stringify({
|
|
311
|
+
message: errorInfo.message ?? null,
|
|
312
|
+
type: errorInfo.type ?? null,
|
|
313
|
+
devInsight: errorInfo.devInsight ?? null,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const incoming = status >= 400 ? 2 : 1;
|
|
318
|
+
if (batch.length + incoming > maxQueueSize) {
|
|
319
|
+
const overflow = batch.length + incoming - maxQueueSize;
|
|
320
|
+
batch.splice(0, overflow);
|
|
321
|
+
}
|
|
229
322
|
|
|
230
|
-
batch.push({
|
|
231
|
-
type: 'log',
|
|
232
|
-
projectName,
|
|
233
|
-
method: ammo.method,
|
|
234
|
-
path,
|
|
235
|
-
status,
|
|
236
|
-
duration_ms: Date.now() - startTime,
|
|
237
|
-
payload_size: Buffer.byteLength(
|
|
238
|
-
JSON.stringify(ammo.payload ?? {}),
|
|
239
|
-
'utf8',
|
|
240
|
-
),
|
|
241
|
-
response_size: Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8'),
|
|
242
|
-
timestamp: Date.now(),
|
|
243
|
-
ip: ammo.ip ?? null,
|
|
244
|
-
traceId: null,
|
|
245
|
-
user_agent: ammo.headers?.['user-agent'] ?? null,
|
|
246
|
-
headers: buildHeaders(ammo.headers, capture.headers),
|
|
247
|
-
request_body: capture.request
|
|
248
|
-
? deepMask(ammo.payload ?? null, clientMaskBlocklist)
|
|
249
|
-
: null,
|
|
250
|
-
response_body: capture.response
|
|
251
|
-
? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
|
|
252
|
-
: null,
|
|
253
|
-
error: errorField,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// Emit a separate ErrorEvent for error grouping and tracking when status >= 400.
|
|
257
|
-
if (status >= 400) {
|
|
258
|
-
const message = errorInfo?.message ?? `HTTP ${status}`;
|
|
259
|
-
const fingerprint = createHash('sha256')
|
|
260
|
-
.update(`${message}:${path}`)
|
|
261
|
-
.digest('hex');
|
|
262
323
|
batch.push({
|
|
263
|
-
type: '
|
|
324
|
+
type: 'log',
|
|
264
325
|
projectName,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
326
|
+
method: ammo.method,
|
|
327
|
+
path,
|
|
328
|
+
status,
|
|
329
|
+
duration_ms: duration,
|
|
330
|
+
payload_size: payloadSize,
|
|
331
|
+
response_size: responseSize,
|
|
332
|
+
timestamp: endTimestamp,
|
|
333
|
+
ip,
|
|
334
|
+
traceId,
|
|
335
|
+
user_agent: userAgent,
|
|
336
|
+
headers,
|
|
337
|
+
request_body: requestBody,
|
|
338
|
+
response_body: responseBody,
|
|
339
|
+
error: errorField,
|
|
271
340
|
});
|
|
341
|
+
|
|
342
|
+
if (status >= 400) {
|
|
343
|
+
const message = errorInfo?.message ?? `HTTP ${status}`;
|
|
344
|
+
const fingerprint = createHash('sha256')
|
|
345
|
+
.update(`${message}:${path}`)
|
|
346
|
+
.digest('hex');
|
|
347
|
+
batch.push({
|
|
348
|
+
type: 'error',
|
|
349
|
+
projectName,
|
|
350
|
+
fingerprint,
|
|
351
|
+
message,
|
|
352
|
+
stack: errorInfo?.stack ?? null,
|
|
353
|
+
endpoint: `${ammo.method} ${path}`,
|
|
354
|
+
traceId,
|
|
355
|
+
timestamp: endTimestamp,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (batch.length >= batchSize) flush();
|
|
272
360
|
}
|
|
273
361
|
|
|
274
|
-
if (
|
|
362
|
+
if (ammo._llmPromise) {
|
|
363
|
+
const timeout = new Promise((resolve) => {
|
|
364
|
+
const t = setTimeout(resolve, 30000);
|
|
365
|
+
if (t.unref) t.unref();
|
|
366
|
+
});
|
|
367
|
+
Promise.race([ammo._llmPromise, timeout])
|
|
368
|
+
.catch(() => {})
|
|
369
|
+
.then(pushEvents);
|
|
370
|
+
} else {
|
|
371
|
+
pushEvents();
|
|
372
|
+
}
|
|
275
373
|
});
|
|
276
374
|
|
|
277
|
-
next();
|
|
278
|
-
}
|
|
375
|
+
traceStore.run({ traceId }, () => next());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
radarCapture._radarStatus = radarStatus;
|
|
379
|
+
return radarCapture;
|
|
279
380
|
}
|
|
280
381
|
|
|
281
382
|
export default radarMiddleware;
|