te.js 2.2.2 → 2.3.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 +3 -3
- package/package.json +2 -2
- package/radar/index.js +87 -9
- package/radar/spans.js +104 -0
- package/radar/spans.test.js +89 -0
- package/server/ammo.js +219 -240
- package/server/errors/llm-error-service.js +58 -1
- package/server/handler.js +33 -0
- package/te.js +9 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://
|
|
2
|
+
<img src="https://usetejas.com/tejas-logo.svg" alt="Tejas Logo" width="200">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Tejas</h1>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
|
-
<a href="https://
|
|
18
|
+
<a href="https://usetejas.com">Documentation</a> •
|
|
19
19
|
<a href="#ai-assisted-setup-mcp">AI Setup (MCP)</a> •
|
|
20
20
|
<a href="#quick-start">Quick Start</a> •
|
|
21
21
|
<a href="#features">Features</a> •
|
|
@@ -163,7 +163,7 @@ app.takeoff();
|
|
|
163
163
|
|
|
164
164
|
## Documentation
|
|
165
165
|
|
|
166
|
-
For comprehensive documentation, see the [docs folder](./docs) or visit [tejas-documentation.vercel.app](https://
|
|
166
|
+
For comprehensive documentation, see the [docs folder](./docs) or visit [tejas-documentation.vercel.app](https://usetejas.com).
|
|
167
167
|
|
|
168
168
|
- [Getting Started](./docs/getting-started.md) — Installation and quick start
|
|
169
169
|
- [Configuration](./docs/configuration.md) — All configuration options
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "te.js",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "AI Native Node.js Framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "te.js",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"mime": "^4.0.1",
|
|
50
50
|
"statuses": "^2.0.1",
|
|
51
51
|
"tej-env": "^1.1.3",
|
|
52
|
-
"tej-logger": "^1.2.
|
|
52
|
+
"tej-logger": "^1.2.6"
|
|
53
53
|
},
|
|
54
54
|
"husky": {
|
|
55
55
|
"hooks": {
|
package/radar/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { promisify } from 'node:util';
|
|
|
7
7
|
const gzipAsync = promisify(gzip);
|
|
8
8
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
9
|
import TejLogger from 'tej-logger';
|
|
10
|
+
import { getErrorsLlmConfig } from '../utils/errors-llm-config.js';
|
|
11
|
+
import { createSpanContext, buildSpanEvent } from './spans.js';
|
|
10
12
|
|
|
11
13
|
const logger = new TejLogger('Tejas.Radar');
|
|
12
14
|
|
|
@@ -95,6 +97,22 @@ function parseJsonSafe(raw) {
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
const MAX_JSON_BLOB = 8 * 1024;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return `value` if its JSON-serialised size fits within the collector's
|
|
104
|
+
* per-field blob limit, otherwise `null`. Prevents oversized request/response
|
|
105
|
+
* bodies from causing 422 rejections that drop the entire batch.
|
|
106
|
+
*/
|
|
107
|
+
function capJsonBlob(value) {
|
|
108
|
+
if (value == null) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.stringify(value).length <= MAX_JSON_BLOB ? value : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
/**
|
|
99
117
|
* Factory that returns a te.js-compatible `(ammo, next)` middleware which
|
|
100
118
|
* captures HTTP request metrics and forwards them to the Tejas Radar collector.
|
|
@@ -108,12 +126,18 @@ function parseJsonSafe(raw) {
|
|
|
108
126
|
* @param {Function} [config.transport] Custom transport `(events) => Promise<{ok, status}>`.
|
|
109
127
|
* Defaults to gzip-compressed HTTP POST to the collector.
|
|
110
128
|
* @param {string[]} [config.ignore] Request paths to skip (default ['/health']).
|
|
129
|
+
* @param {string} [config.collectorUrl] Radar collector URL. Falls back to RADAR_COLLECTOR_URL env, then "https://collector.usetejas.com".
|
|
130
|
+
* A future release will support self-hosted Radar collectors.
|
|
111
131
|
* @param {Object} [config.capture] Controls what additional data is captured beyond metrics.
|
|
112
132
|
* @param {boolean} [config.capture.request] Capture and send request body (default false).
|
|
113
133
|
* @param {boolean} [config.capture.response] Capture and send response body (default false).
|
|
114
134
|
* @param {boolean|string[]} [config.capture.headers] Capture request headers. `true` sends all headers;
|
|
115
135
|
* a `string[]` sends only the named headers (allowlist);
|
|
116
136
|
* `false` (default) sends nothing.
|
|
137
|
+
* @param {boolean} [config.capture.logs=false] Forward TejLogger calls to the collector as app-level
|
|
138
|
+
* log events. Off by default.
|
|
139
|
+
* @param {string[]} [config.capture.logLevels] When `capture.logs` is true, only forward these levels
|
|
140
|
+
* (e.g. `['warn', 'error']`). Defaults to all levels.
|
|
117
141
|
* @param {Object} [config.mask] Client-side masking applied before data is sent.
|
|
118
142
|
* @param {string[]} [config.mask.fields] Extra field names to mask in request/response bodies.
|
|
119
143
|
* These are merged with the collector's server-side GDPR blocklist.
|
|
@@ -122,11 +146,10 @@ function parseJsonSafe(raw) {
|
|
|
122
146
|
* @returns {Promise<Function>} Middleware function `(ammo, next)`
|
|
123
147
|
*/
|
|
124
148
|
async function radarMiddleware(config = {}) {
|
|
125
|
-
// RADAR_COLLECTOR_URL is an undocumented internal escape hatch used only
|
|
126
|
-
// during local development. In production, telemetry always goes to the
|
|
127
|
-
// hosted collector and this env var should not be set.
|
|
128
149
|
const collectorUrl =
|
|
129
|
-
|
|
150
|
+
config.collectorUrl ??
|
|
151
|
+
process.env.RADAR_COLLECTOR_URL ??
|
|
152
|
+
'https://collector.usetejas.com';
|
|
130
153
|
|
|
131
154
|
const apiKey = config.apiKey ?? process.env.RADAR_API_KEY ?? null;
|
|
132
155
|
|
|
@@ -145,6 +168,8 @@ async function radarMiddleware(config = {}) {
|
|
|
145
168
|
request: config.capture?.request === true,
|
|
146
169
|
response: config.capture?.response === true,
|
|
147
170
|
headers: config.capture?.headers ?? false,
|
|
171
|
+
logs: config.capture?.logs === true,
|
|
172
|
+
logLevels: config.capture?.logLevels ?? null,
|
|
148
173
|
});
|
|
149
174
|
|
|
150
175
|
// Build the client-side field blocklist from developer-supplied extra fields.
|
|
@@ -275,9 +300,40 @@ async function radarMiddleware(config = {}) {
|
|
|
275
300
|
const timer = setInterval(flush, flushInterval);
|
|
276
301
|
if (timer.unref) timer.unref();
|
|
277
302
|
|
|
303
|
+
const captureLevels = capture.logLevels ? new Set(capture.logLevels) : null;
|
|
304
|
+
|
|
305
|
+
if (capture.logs) {
|
|
306
|
+
TejLogger.addHook(({ level, identifier, message, metadata }) => {
|
|
307
|
+
if (captureLevels && !captureLevels.has(level)) return;
|
|
308
|
+
|
|
309
|
+
const store = traceStore.getStore();
|
|
310
|
+
const traceId = store?.traceId ?? null;
|
|
311
|
+
|
|
312
|
+
const metaJson =
|
|
313
|
+
metadata != null
|
|
314
|
+
? JSON.stringify(metadata).slice(0, MAX_JSON_BLOB)
|
|
315
|
+
: null;
|
|
316
|
+
|
|
317
|
+
const event = {
|
|
318
|
+
type: 'app_log',
|
|
319
|
+
projectName,
|
|
320
|
+
level,
|
|
321
|
+
message: `[${identifier}] ${String(message).slice(0, 4096)}`,
|
|
322
|
+
traceId,
|
|
323
|
+
timestamp: Date.now(),
|
|
324
|
+
metadata: metaJson,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (batch.length >= maxQueueSize) batch.splice(0, 1);
|
|
328
|
+
batch.push(event);
|
|
329
|
+
if (batch.length >= batchSize) flush();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
278
333
|
function radarCapture(ammo, next) {
|
|
279
334
|
const startTime = Date.now();
|
|
280
335
|
const traceId = randomUUID().replace(/-/g, '');
|
|
336
|
+
const spanCtx = createSpanContext(traceId);
|
|
281
337
|
|
|
282
338
|
ammo.res.on('finish', () => {
|
|
283
339
|
const path = ammo.endpoint ?? ammo.path ?? '/';
|
|
@@ -294,12 +350,14 @@ async function radarMiddleware(config = {}) {
|
|
|
294
350
|
const responseSize = Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8');
|
|
295
351
|
const ip = ammo.ip ?? null;
|
|
296
352
|
const userAgent = ammo.headers?.['user-agent'] ?? null;
|
|
297
|
-
const headers = buildHeaders(ammo.headers, capture.headers);
|
|
353
|
+
const headers = capJsonBlob(buildHeaders(ammo.headers, capture.headers));
|
|
298
354
|
const requestBody = capture.request
|
|
299
|
-
? deepMask(ammo.payload ?? null, clientMaskBlocklist)
|
|
355
|
+
? capJsonBlob(deepMask(ammo.payload ?? null, clientMaskBlocklist))
|
|
300
356
|
: null;
|
|
301
357
|
const responseBody = capture.response
|
|
302
|
-
?
|
|
358
|
+
? capJsonBlob(
|
|
359
|
+
deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist),
|
|
360
|
+
)
|
|
303
361
|
: null;
|
|
304
362
|
|
|
305
363
|
function pushEvents() {
|
|
@@ -311,10 +369,26 @@ async function radarMiddleware(config = {}) {
|
|
|
311
369
|
message: errorInfo.message ?? null,
|
|
312
370
|
type: errorInfo.type ?? null,
|
|
313
371
|
devInsight: errorInfo.devInsight ?? null,
|
|
372
|
+
llmEnabled: getErrorsLlmConfig().enabled,
|
|
314
373
|
});
|
|
315
374
|
}
|
|
316
375
|
|
|
317
|
-
|
|
376
|
+
// Finalize root span — added last so middleware spans already
|
|
377
|
+
// reference rootSpanId as their parentId.
|
|
378
|
+
spanCtx.addSpan(
|
|
379
|
+
`${ammo.method} ${path}`,
|
|
380
|
+
'handler',
|
|
381
|
+
null,
|
|
382
|
+
startTime,
|
|
383
|
+
duration,
|
|
384
|
+
status,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const spanEvents = spanCtx.spans.map((s) =>
|
|
388
|
+
buildSpanEvent(projectName, spanCtx, s),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const incoming = (status >= 400 ? 2 : 1) + spanEvents.length;
|
|
318
392
|
if (batch.length + incoming > maxQueueSize) {
|
|
319
393
|
const overflow = batch.length + incoming - maxQueueSize;
|
|
320
394
|
batch.splice(0, overflow);
|
|
@@ -356,6 +430,10 @@ async function radarMiddleware(config = {}) {
|
|
|
356
430
|
});
|
|
357
431
|
}
|
|
358
432
|
|
|
433
|
+
for (const spanEvent of spanEvents) {
|
|
434
|
+
batch.push(spanEvent);
|
|
435
|
+
}
|
|
436
|
+
|
|
359
437
|
if (batch.length >= batchSize) flush();
|
|
360
438
|
}
|
|
361
439
|
|
|
@@ -372,7 +450,7 @@ async function radarMiddleware(config = {}) {
|
|
|
372
450
|
}
|
|
373
451
|
});
|
|
374
452
|
|
|
375
|
-
traceStore.run({ traceId }, () => next());
|
|
453
|
+
traceStore.run({ traceId, spanCtx }, () => next());
|
|
376
454
|
}
|
|
377
455
|
|
|
378
456
|
radarCapture._radarStatus = radarStatus;
|
package/radar/spans.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a 16-character hex span ID (matches OpenTelemetry span ID length).
|
|
5
|
+
*
|
|
6
|
+
* @returns {string} 16-char lowercase hex string.
|
|
7
|
+
*/
|
|
8
|
+
export function createSpanId() {
|
|
9
|
+
return randomBytes(8).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} CollectedSpan
|
|
14
|
+
* @property {string} spanId Unique span identifier.
|
|
15
|
+
* @property {string} name Human-readable span name (e.g. "middleware:auth").
|
|
16
|
+
* @property {string} type Span type: "middleware", "handler", or "other".
|
|
17
|
+
* @property {string|null} parentId Parent span ID, or null for root spans.
|
|
18
|
+
* @property {number} startMs Unix epoch milliseconds when the span started.
|
|
19
|
+
* @property {number} durationMs Span duration in milliseconds.
|
|
20
|
+
* @property {number} status HTTP status code (or 0 if unavailable).
|
|
21
|
+
* @property {Object|null} metadata Optional key-value metadata.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} SpanContext
|
|
26
|
+
* @property {string} traceId Trace identifier (shared across all spans in a request).
|
|
27
|
+
* @property {string} rootSpanId Span ID of the root span (the request itself).
|
|
28
|
+
* @property {CollectedSpan[]} spans Accumulated spans for this request.
|
|
29
|
+
* @property {function(string, string, string|null, number, number, number, Object=): void} addSpan
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a span collection context to be stored in AsyncLocalStorage alongside
|
|
34
|
+
* the traceId. Middleware and handler instrumentation pushes spans here; the
|
|
35
|
+
* radar middleware reads them at response finish time.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} traceId The trace identifier for this request.
|
|
38
|
+
* @returns {SpanContext}
|
|
39
|
+
*/
|
|
40
|
+
export function createSpanContext(traceId) {
|
|
41
|
+
const rootSpanId = createSpanId();
|
|
42
|
+
|
|
43
|
+
/** @type {CollectedSpan[]} */
|
|
44
|
+
const spans = [];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a completed span.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} name Human-readable span name.
|
|
50
|
+
* @param {string} type Span type ("middleware", "handler", "other").
|
|
51
|
+
* @param {string|null} parentId Parent span ID, or null for root.
|
|
52
|
+
* @param {number} startMs Start time (Unix epoch ms).
|
|
53
|
+
* @param {number} durationMs Duration in milliseconds.
|
|
54
|
+
* @param {number} status HTTP status code.
|
|
55
|
+
* @param {Object} [metadata] Optional metadata object.
|
|
56
|
+
*/
|
|
57
|
+
function addSpan(
|
|
58
|
+
name,
|
|
59
|
+
type,
|
|
60
|
+
parentId,
|
|
61
|
+
startMs,
|
|
62
|
+
durationMs,
|
|
63
|
+
status,
|
|
64
|
+
metadata,
|
|
65
|
+
) {
|
|
66
|
+
spans.push({
|
|
67
|
+
spanId: createSpanId(),
|
|
68
|
+
name,
|
|
69
|
+
type,
|
|
70
|
+
parentId: parentId ?? null,
|
|
71
|
+
startMs,
|
|
72
|
+
durationMs,
|
|
73
|
+
status,
|
|
74
|
+
metadata: metadata ?? null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { traceId, rootSpanId, spans, addSpan };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert a collected span into the event shape expected by the Radar
|
|
83
|
+
* collector's `SpanEvent` (Rust serde struct).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} projectName Project identifier sent with every event.
|
|
86
|
+
* @param {SpanContext} ctx The span context holding the traceId.
|
|
87
|
+
* @param {CollectedSpan} span The span to convert.
|
|
88
|
+
* @returns {Object} Collector-compatible span event object.
|
|
89
|
+
*/
|
|
90
|
+
export function buildSpanEvent(projectName, ctx, span) {
|
|
91
|
+
return {
|
|
92
|
+
type: 'span',
|
|
93
|
+
projectName,
|
|
94
|
+
traceId: ctx.traceId,
|
|
95
|
+
spanId: span.spanId,
|
|
96
|
+
parentId: span.parentId,
|
|
97
|
+
name: span.name,
|
|
98
|
+
spanType: span.type,
|
|
99
|
+
startMs: span.startMs,
|
|
100
|
+
duration_ms: span.durationMs,
|
|
101
|
+
status: span.status,
|
|
102
|
+
metadata: span.metadata,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createSpanId, createSpanContext, buildSpanEvent } from './spans.js';
|
|
3
|
+
|
|
4
|
+
describe('createSpanId', () => {
|
|
5
|
+
it('should return a 16-character hex string', () => {
|
|
6
|
+
const id = createSpanId();
|
|
7
|
+
expect(typeof id).toBe('string');
|
|
8
|
+
expect(id).toHaveLength(16);
|
|
9
|
+
expect(id).toMatch(/^[0-9a-f]{16}$/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should produce unique IDs', () => {
|
|
13
|
+
const ids = new Set(Array.from({ length: 100 }, () => createSpanId()));
|
|
14
|
+
expect(ids.size).toBe(100);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('createSpanContext', () => {
|
|
19
|
+
it('should create a context with traceId and rootSpanId', () => {
|
|
20
|
+
const ctx = createSpanContext('abc123');
|
|
21
|
+
expect(ctx.traceId).toBe('abc123');
|
|
22
|
+
expect(typeof ctx.rootSpanId).toBe('string');
|
|
23
|
+
expect(ctx.rootSpanId).toHaveLength(16);
|
|
24
|
+
expect(ctx.spans).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should accumulate spans via addSpan', () => {
|
|
28
|
+
const ctx = createSpanContext('trace1');
|
|
29
|
+
ctx.addSpan('middleware:auth', 'middleware', ctx.rootSpanId, 1000, 5, 200);
|
|
30
|
+
ctx.addSpan('handler:GET /users', 'handler', ctx.rootSpanId, 1005, 20, 200);
|
|
31
|
+
|
|
32
|
+
expect(ctx.spans).toHaveLength(2);
|
|
33
|
+
expect(ctx.spans[0].name).toBe('middleware:auth');
|
|
34
|
+
expect(ctx.spans[0].type).toBe('middleware');
|
|
35
|
+
expect(ctx.spans[0].parentId).toBe(ctx.rootSpanId);
|
|
36
|
+
expect(ctx.spans[0].startMs).toBe(1000);
|
|
37
|
+
expect(ctx.spans[0].durationMs).toBe(5);
|
|
38
|
+
expect(ctx.spans[0].status).toBe(200);
|
|
39
|
+
expect(ctx.spans[0].metadata).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should assign unique spanIds to each span', () => {
|
|
43
|
+
const ctx = createSpanContext('trace2');
|
|
44
|
+
ctx.addSpan('a', 'middleware', null, 0, 1, 200);
|
|
45
|
+
ctx.addSpan('b', 'middleware', null, 1, 1, 200);
|
|
46
|
+
expect(ctx.spans[0].spanId).not.toBe(ctx.spans[1].spanId);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should accept optional metadata', () => {
|
|
50
|
+
const ctx = createSpanContext('trace3');
|
|
51
|
+
ctx.addSpan('db:query', 'other', null, 0, 10, 200, { query: 'SELECT 1' });
|
|
52
|
+
expect(ctx.spans[0].metadata).toEqual({ query: 'SELECT 1' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should default parentId to null when not provided', () => {
|
|
56
|
+
const ctx = createSpanContext('trace4');
|
|
57
|
+
ctx.addSpan('root', 'handler', null, 0, 100, 200);
|
|
58
|
+
expect(ctx.spans[0].parentId).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('buildSpanEvent', () => {
|
|
63
|
+
it('should produce a collector-compatible event object', () => {
|
|
64
|
+
const ctx = createSpanContext('trace-build');
|
|
65
|
+
ctx.addSpan('middleware:cors', 'middleware', ctx.rootSpanId, 5000, 2, 200);
|
|
66
|
+
const span = ctx.spans[0];
|
|
67
|
+
|
|
68
|
+
const event = buildSpanEvent('my-api', ctx, span);
|
|
69
|
+
|
|
70
|
+
expect(event.type).toBe('span');
|
|
71
|
+
expect(event.projectName).toBe('my-api');
|
|
72
|
+
expect(event.traceId).toBe('trace-build');
|
|
73
|
+
expect(event.spanId).toBe(span.spanId);
|
|
74
|
+
expect(event.parentId).toBe(ctx.rootSpanId);
|
|
75
|
+
expect(event.name).toBe('middleware:cors');
|
|
76
|
+
expect(event.spanType).toBe('middleware');
|
|
77
|
+
expect(event.startMs).toBe(5000);
|
|
78
|
+
expect(event.duration_ms).toBe(2);
|
|
79
|
+
expect(event.status).toBe(200);
|
|
80
|
+
expect(event.metadata).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should include metadata when present', () => {
|
|
84
|
+
const ctx = createSpanContext('trace-meta');
|
|
85
|
+
ctx.addSpan('db', 'other', null, 0, 10, 200, { table: 'users' });
|
|
86
|
+
const event = buildSpanEvent('app', ctx, ctx.spans[0]);
|
|
87
|
+
expect(event.metadata).toEqual({ table: 'users' });
|
|
88
|
+
});
|
|
89
|
+
});
|
package/server/ammo.js
CHANGED
|
@@ -34,6 +34,112 @@ function isThrowOptions(v) {
|
|
|
34
34
|
return hasUseLlm || hasMessageType === true;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Synchronously resolve throw() arguments into a status code, message, error
|
|
39
|
+
* metadata, and an `explicit` flag that tells the LLM whether it may override
|
|
40
|
+
* the resolved code/message.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown[]} args The throw() arguments (after throwOpts have been popped)
|
|
43
|
+
* @returns {{ statusCode: number, message: string, errorType: string|null, stack: string|null, originalError: unknown, explicit: boolean }}
|
|
44
|
+
*/
|
|
45
|
+
function resolveThrowArgs(args) {
|
|
46
|
+
if (args.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
statusCode: 500,
|
|
49
|
+
message: 'Internal Server Error',
|
|
50
|
+
errorType: null,
|
|
51
|
+
stack: null,
|
|
52
|
+
originalError: undefined,
|
|
53
|
+
explicit: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isStatusCode(args[0])) {
|
|
58
|
+
return {
|
|
59
|
+
statusCode: args[0],
|
|
60
|
+
message: args[1] || toStatusMessage(args[0]),
|
|
61
|
+
errorType: null,
|
|
62
|
+
stack: null,
|
|
63
|
+
originalError: undefined,
|
|
64
|
+
explicit: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
typeof args[0]?.statusCode === 'number' &&
|
|
70
|
+
typeof args[0]?.code === 'string'
|
|
71
|
+
) {
|
|
72
|
+
const err = args[0];
|
|
73
|
+
return {
|
|
74
|
+
statusCode: err.statusCode,
|
|
75
|
+
message: err.message,
|
|
76
|
+
errorType: err.constructor?.name ?? 'TejError',
|
|
77
|
+
stack: err.stack ?? null,
|
|
78
|
+
originalError: err,
|
|
79
|
+
explicit: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
args[0] != null &&
|
|
85
|
+
typeof args[0].message === 'string' &&
|
|
86
|
+
typeof args[0].stack === 'string'
|
|
87
|
+
) {
|
|
88
|
+
const err = args[0];
|
|
89
|
+
if (!isNaN(parseInt(err.message))) {
|
|
90
|
+
const code = parseInt(err.message);
|
|
91
|
+
return {
|
|
92
|
+
statusCode: code,
|
|
93
|
+
message: toStatusMessage(code) || toStatusMessage(500),
|
|
94
|
+
errorType: err.constructor.name,
|
|
95
|
+
stack: err.stack,
|
|
96
|
+
originalError: err,
|
|
97
|
+
explicit: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const code = toStatusCode(err.message);
|
|
101
|
+
if (code) {
|
|
102
|
+
return {
|
|
103
|
+
statusCode: code,
|
|
104
|
+
message: err.message,
|
|
105
|
+
errorType: err.constructor.name,
|
|
106
|
+
stack: err.stack,
|
|
107
|
+
originalError: err,
|
|
108
|
+
explicit: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
statusCode: 500,
|
|
113
|
+
message: err.message,
|
|
114
|
+
errorType: err.constructor.name,
|
|
115
|
+
stack: err.stack,
|
|
116
|
+
originalError: err,
|
|
117
|
+
explicit: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const val = args[0];
|
|
122
|
+
const code = toStatusCode(val);
|
|
123
|
+
if (code) {
|
|
124
|
+
return {
|
|
125
|
+
statusCode: code,
|
|
126
|
+
message: toStatusMessage(code),
|
|
127
|
+
errorType: null,
|
|
128
|
+
stack: null,
|
|
129
|
+
originalError: undefined,
|
|
130
|
+
explicit: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
statusCode: 500,
|
|
135
|
+
message: val.toString(),
|
|
136
|
+
errorType: null,
|
|
137
|
+
stack: null,
|
|
138
|
+
originalError: undefined,
|
|
139
|
+
explicit: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
37
143
|
/**
|
|
38
144
|
* Ammo class for handling HTTP requests and responses.
|
|
39
145
|
*
|
|
@@ -339,25 +445,28 @@ class Ammo {
|
|
|
339
445
|
* 4. Error object: Extracts status code and message from the error
|
|
340
446
|
* 5. String: Treats as error message with 500 status code
|
|
341
447
|
*
|
|
342
|
-
* When errors.llm
|
|
343
|
-
*
|
|
344
|
-
*
|
|
448
|
+
* When errors.llm is enabled (via `withLLMErrors()`), every throw() call is
|
|
449
|
+
* enriched by the LLM with a `devInsight` field for Radar. Explicit status
|
|
450
|
+
* codes and messages are always preserved — the LLM only adds diagnostic
|
|
451
|
+
* context, never overrides the developer's chosen code/message. For bare
|
|
452
|
+
* Error objects the LLM may also infer a more appropriate status code and
|
|
453
|
+
* message. When the LLM path is active, throw() returns a Promise.
|
|
345
454
|
*
|
|
346
|
-
* Per-call options (last argument
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* errors.llm.messageType for this call
|
|
455
|
+
* Per-call options (last argument): pass an object with `useLlm` (boolean)
|
|
456
|
+
* and/or `messageType` ('endUser' | 'developer'). Use `useLlm: false` to
|
|
457
|
+
* skip the LLM for this specific call; use `messageType` to override
|
|
458
|
+
* errors.llm.messageType for this call.
|
|
350
459
|
*
|
|
351
460
|
* @example
|
|
352
461
|
* // Throw a 404 Not Found error
|
|
353
462
|
* ammo.throw(404);
|
|
354
463
|
*
|
|
355
464
|
* @example
|
|
356
|
-
* // Throw a 404
|
|
465
|
+
* // Throw a 404 with custom message — LLM adds devInsight only
|
|
357
466
|
* ammo.throw(404, 'Resource not found');
|
|
358
467
|
*
|
|
359
468
|
* @example
|
|
360
|
-
* //
|
|
469
|
+
* // Error object — LLM infers code + message + devInsight
|
|
361
470
|
* ammo.throw(new Error('Something went wrong'));
|
|
362
471
|
*
|
|
363
472
|
* @example
|
|
@@ -365,8 +474,8 @@ class Ammo {
|
|
|
365
474
|
* ammo.throw('Something went wrong');
|
|
366
475
|
*
|
|
367
476
|
* @example
|
|
368
|
-
* // Skip LLM for this call
|
|
369
|
-
* ammo.throw(
|
|
477
|
+
* // Skip LLM for this specific call
|
|
478
|
+
* ammo.throw(502, 'Known upstream issue', { useLlm: false });
|
|
370
479
|
*
|
|
371
480
|
* @example
|
|
372
481
|
* // Force developer-friendly message for this call
|
|
@@ -378,129 +487,65 @@ class Ammo {
|
|
|
378
487
|
let args = Array.from(arguments);
|
|
379
488
|
const { enabled: llmEnabled } = getErrorsLlmConfig();
|
|
380
489
|
|
|
381
|
-
// Per-call options: last arg can be { useLlm?, messageType? }
|
|
382
|
-
const llmEligible =
|
|
383
|
-
args.length === 0 ||
|
|
384
|
-
(!isStatusCode(args[0]) &&
|
|
385
|
-
!(
|
|
386
|
-
typeof args[0]?.statusCode === 'number' &&
|
|
387
|
-
typeof args[0]?.code === 'string'
|
|
388
|
-
));
|
|
490
|
+
// Per-call options: last arg can be { useLlm?, messageType? }.
|
|
389
491
|
let throwOpts =
|
|
390
492
|
/** @type {{ useLlm?: boolean, messageType?: 'endUser'|'developer' } | null} */ (
|
|
391
493
|
null
|
|
392
494
|
);
|
|
393
|
-
if (
|
|
394
|
-
llmEligible &&
|
|
395
|
-
args.length > 0 &&
|
|
396
|
-
isThrowOptions(args[args.length - 1])
|
|
397
|
-
) {
|
|
495
|
+
if (args.length > 0 && isThrowOptions(args[args.length - 1])) {
|
|
398
496
|
throwOpts =
|
|
399
497
|
/** @type {{ useLlm?: boolean, messageType?: 'endUser'|'developer' } } */ (
|
|
400
498
|
args.pop()
|
|
401
499
|
);
|
|
402
500
|
}
|
|
403
501
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
args[0] != null && typeof args[0].stack === 'string'
|
|
411
|
-
? args[0].stack
|
|
412
|
-
: new Error().stack;
|
|
413
|
-
const originalError =
|
|
414
|
-
args[0] !== undefined && args[0] !== null ? args[0] : undefined;
|
|
415
|
-
|
|
416
|
-
const { mode, channel, logFile } = getErrorsLlmConfig();
|
|
417
|
-
|
|
418
|
-
if (mode === 'async') {
|
|
419
|
-
// Respond immediately with a generic 500, then run LLM in the background.
|
|
420
|
-
this.fire(500, 'Internal Server Error');
|
|
421
|
-
|
|
422
|
-
// Stash basic error info synchronously so radar can read it on res.finish
|
|
423
|
-
// even before LLM completes. LLM result will update _errorInfo when ready.
|
|
424
|
-
const errorType =
|
|
425
|
-
originalError != null &&
|
|
426
|
-
typeof originalError.constructor?.name === 'string'
|
|
427
|
-
? originalError.constructor.name
|
|
428
|
-
: originalError !== undefined
|
|
429
|
-
? typeof originalError
|
|
430
|
-
: null;
|
|
431
|
-
this._errorInfo = {
|
|
432
|
-
message: 'Internal Server Error',
|
|
433
|
-
type: errorType,
|
|
434
|
-
devInsight: null,
|
|
435
|
-
stack: stack ?? null,
|
|
436
|
-
codeContext: null,
|
|
437
|
-
};
|
|
502
|
+
// ── Phase 1: resolve statusCode, message, metadata from args ──────
|
|
503
|
+
const resolved = resolveThrowArgs(args);
|
|
504
|
+
const { statusCode, message, errorType, originalError } = resolved;
|
|
505
|
+
// For LLM code-context capture we always need a stack trace, even when
|
|
506
|
+
// the developer passed a bare status code like throw(502).
|
|
507
|
+
const stack = resolved.stack ?? new Error().stack;
|
|
438
508
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
...(throwOpts?.messageType && {
|
|
456
|
-
messageType: throwOpts.messageType,
|
|
457
|
-
}),
|
|
458
|
-
};
|
|
459
|
-
if (originalError !== undefined) context.error = originalError;
|
|
460
|
-
return inferErrorFromContext(context).then((result) => ({
|
|
461
|
-
result,
|
|
462
|
-
codeContext,
|
|
463
|
-
}));
|
|
464
|
-
})
|
|
465
|
-
.then(({ result, codeContext }) => {
|
|
466
|
-
// Update _errorInfo with full LLM result
|
|
467
|
-
if (self._errorInfo) {
|
|
468
|
-
self._errorInfo.message = result.message;
|
|
469
|
-
self._errorInfo.devInsight = result.devInsight ?? null;
|
|
470
|
-
}
|
|
471
|
-
const channels = getChannels(channel, logFile);
|
|
472
|
-
const payload = buildPayload({
|
|
473
|
-
method,
|
|
474
|
-
path,
|
|
475
|
-
originalError,
|
|
476
|
-
codeContext,
|
|
477
|
-
statusCode: result.statusCode,
|
|
478
|
-
message: result.message,
|
|
479
|
-
devInsight: result.devInsight,
|
|
480
|
-
cached: result.cached,
|
|
481
|
-
rateLimited: result.rateLimited,
|
|
482
|
-
});
|
|
483
|
-
return dispatchToChannels(channels, payload);
|
|
484
|
-
})
|
|
485
|
-
.catch((err) => {
|
|
486
|
-
// Background LLM failed after HTTP response already sent — log the failure
|
|
487
|
-
// but do not attempt to respond again.
|
|
488
|
-
logger.warn(
|
|
489
|
-
`Background LLM dispatch failed: ${err?.message ?? err}`,
|
|
490
|
-
);
|
|
491
|
-
});
|
|
509
|
+
// ── Phase 2: decide fire strategy ─────────────────────────────────
|
|
510
|
+
const useLlm = llmEnabled && throwOpts?.useLlm !== false;
|
|
511
|
+
|
|
512
|
+
if (!useLlm) {
|
|
513
|
+
this._errorInfo = {
|
|
514
|
+
message,
|
|
515
|
+
type: errorType,
|
|
516
|
+
devInsight: null,
|
|
517
|
+
stack: resolved.stack,
|
|
518
|
+
codeContext: null,
|
|
519
|
+
};
|
|
520
|
+
this.fire(statusCode, message);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const { mode, channel, logFile } = getErrorsLlmConfig();
|
|
492
525
|
|
|
493
|
-
|
|
494
|
-
|
|
526
|
+
if (mode === 'async') {
|
|
527
|
+
// Fire immediately with the resolved code/message.
|
|
528
|
+
this.fire(statusCode, message);
|
|
529
|
+
this._errorInfo = {
|
|
530
|
+
message,
|
|
531
|
+
type: errorType,
|
|
532
|
+
devInsight: null,
|
|
533
|
+
stack: stack ?? null,
|
|
534
|
+
codeContext: null,
|
|
535
|
+
};
|
|
495
536
|
|
|
496
|
-
|
|
497
|
-
|
|
537
|
+
const method = this.method;
|
|
538
|
+
const path = this.path;
|
|
539
|
+
const self = this;
|
|
540
|
+
this._llmPromise = captureCodeContext(stack)
|
|
498
541
|
.then((codeContext) => {
|
|
542
|
+
if (self._errorInfo) self._errorInfo.codeContext = codeContext;
|
|
499
543
|
const context = {
|
|
500
544
|
codeContext,
|
|
501
|
-
method
|
|
502
|
-
path
|
|
545
|
+
method,
|
|
546
|
+
path,
|
|
503
547
|
includeDevInsight: true,
|
|
548
|
+
forceDevInsight: true,
|
|
504
549
|
...(throwOpts?.messageType && {
|
|
505
550
|
messageType: throwOpts.messageType,
|
|
506
551
|
}),
|
|
@@ -512,142 +557,76 @@ class Ammo {
|
|
|
512
557
|
}));
|
|
513
558
|
})
|
|
514
559
|
.then(({ result, codeContext }) => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
!isProduction && devInsight
|
|
533
|
-
? { message, _dev: devInsight }
|
|
534
|
-
: message;
|
|
535
|
-
this.fire(statusCode, data);
|
|
560
|
+
if (self._errorInfo) {
|
|
561
|
+
if (!resolved.explicit) self._errorInfo.message = result.message;
|
|
562
|
+
self._errorInfo.devInsight = result.devInsight ?? null;
|
|
563
|
+
}
|
|
564
|
+
const channels = getChannels(channel, logFile);
|
|
565
|
+
const payload = buildPayload({
|
|
566
|
+
method,
|
|
567
|
+
path,
|
|
568
|
+
originalError,
|
|
569
|
+
codeContext,
|
|
570
|
+
statusCode: resolved.explicit ? statusCode : result.statusCode,
|
|
571
|
+
message: resolved.explicit ? message : result.message,
|
|
572
|
+
devInsight: result.devInsight,
|
|
573
|
+
cached: result.cached,
|
|
574
|
+
rateLimited: result.rateLimited,
|
|
575
|
+
});
|
|
576
|
+
return dispatchToChannels(channels, payload);
|
|
536
577
|
})
|
|
537
578
|
.catch((err) => {
|
|
538
|
-
|
|
539
|
-
// so the client always gets a response and we don't trigger an infinite retry loop.
|
|
540
|
-
logger.warn(`LLM error inference failed: ${err?.message ?? err}`);
|
|
541
|
-
this.fire(500, 'Internal Server Error');
|
|
579
|
+
logger.warn(`Background LLM dispatch failed: ${err?.message ?? err}`);
|
|
542
580
|
});
|
|
543
|
-
}
|
|
544
581
|
|
|
545
|
-
// Sync path: explicit code/message or useLlm: false
|
|
546
|
-
if (args.length === 0) {
|
|
547
|
-
this._errorInfo = {
|
|
548
|
-
message: 'Internal Server Error',
|
|
549
|
-
type: null,
|
|
550
|
-
devInsight: null,
|
|
551
|
-
stack: null,
|
|
552
|
-
codeContext: null,
|
|
553
|
-
};
|
|
554
|
-
this.fire(500, 'Internal Server Error');
|
|
555
582
|
return;
|
|
556
583
|
}
|
|
557
584
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
message: error.message,
|
|
579
|
-
type: error.constructor?.name ?? 'TejError',
|
|
580
|
-
devInsight: null,
|
|
581
|
-
stack: error.stack ?? null,
|
|
582
|
-
codeContext: null,
|
|
583
|
-
};
|
|
584
|
-
this.fire(error.statusCode, error.message);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (
|
|
589
|
-
args[0] != null &&
|
|
590
|
-
typeof args[0].message === 'string' &&
|
|
591
|
-
typeof args[0].stack === 'string'
|
|
592
|
-
) {
|
|
593
|
-
const error = args[0];
|
|
594
|
-
if (!isNaN(parseInt(error.message))) {
|
|
595
|
-
const statusCode = parseInt(error.message);
|
|
596
|
-
const message = toStatusMessage(statusCode) || toStatusMessage(500);
|
|
585
|
+
// Sync mode (default): run LLM, then fire.
|
|
586
|
+
return captureCodeContext(stack)
|
|
587
|
+
.then((codeContext) => {
|
|
588
|
+
const context = {
|
|
589
|
+
codeContext,
|
|
590
|
+
method: this.method,
|
|
591
|
+
path: this.path,
|
|
592
|
+
includeDevInsight: true,
|
|
593
|
+
...(throwOpts?.messageType && { messageType: throwOpts.messageType }),
|
|
594
|
+
};
|
|
595
|
+
if (originalError !== undefined) context.error = originalError;
|
|
596
|
+
return inferErrorFromContext(context).then((result) => ({
|
|
597
|
+
result,
|
|
598
|
+
codeContext,
|
|
599
|
+
}));
|
|
600
|
+
})
|
|
601
|
+
.then(({ result, codeContext }) => {
|
|
602
|
+
const devInsight = result.devInsight ?? null;
|
|
603
|
+
const finalStatus = resolved.explicit ? statusCode : result.statusCode;
|
|
604
|
+
const finalMessage = resolved.explicit ? message : result.message;
|
|
597
605
|
this._errorInfo = {
|
|
598
|
-
message,
|
|
599
|
-
type:
|
|
600
|
-
devInsight
|
|
601
|
-
stack:
|
|
602
|
-
codeContext: null,
|
|
606
|
+
message: finalMessage,
|
|
607
|
+
type: errorType,
|
|
608
|
+
devInsight,
|
|
609
|
+
stack: stack ?? null,
|
|
610
|
+
codeContext: codeContext ?? null,
|
|
603
611
|
};
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
612
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
613
|
+
const data =
|
|
614
|
+
!isProduction && devInsight
|
|
615
|
+
? { message: finalMessage, _dev: devInsight }
|
|
616
|
+
: finalMessage;
|
|
617
|
+
this.fire(finalStatus, data);
|
|
618
|
+
})
|
|
619
|
+
.catch((err) => {
|
|
620
|
+
logger.warn(`LLM error inference failed: ${err?.message ?? err}`);
|
|
609
621
|
this._errorInfo = {
|
|
610
|
-
message
|
|
611
|
-
type:
|
|
622
|
+
message,
|
|
623
|
+
type: errorType,
|
|
612
624
|
devInsight: null,
|
|
613
|
-
stack:
|
|
625
|
+
stack: resolved.stack,
|
|
614
626
|
codeContext: null,
|
|
615
627
|
};
|
|
616
|
-
this.fire(statusCode,
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
this._errorInfo = {
|
|
620
|
-
message: error.message,
|
|
621
|
-
type: error.constructor.name,
|
|
622
|
-
devInsight: null,
|
|
623
|
-
stack: error.stack ?? null,
|
|
624
|
-
codeContext: null,
|
|
625
|
-
};
|
|
626
|
-
this.fire(500, error.message);
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const errorValue = args[0];
|
|
631
|
-
const statusCode = toStatusCode(errorValue);
|
|
632
|
-
if (statusCode) {
|
|
633
|
-
this._errorInfo = {
|
|
634
|
-
message: toStatusMessage(statusCode),
|
|
635
|
-
type: null,
|
|
636
|
-
devInsight: null,
|
|
637
|
-
stack: null,
|
|
638
|
-
codeContext: null,
|
|
639
|
-
};
|
|
640
|
-
this.fire(statusCode, toStatusMessage(statusCode));
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
this._errorInfo = {
|
|
644
|
-
message: errorValue.toString(),
|
|
645
|
-
type: null,
|
|
646
|
-
devInsight: null,
|
|
647
|
-
stack: null,
|
|
648
|
-
codeContext: null,
|
|
649
|
-
};
|
|
650
|
-
this.fire(500, errorValue.toString());
|
|
628
|
+
this.fire(statusCode, message);
|
|
629
|
+
});
|
|
651
630
|
}
|
|
652
631
|
}
|
|
653
632
|
|
|
@@ -16,6 +16,63 @@ import { getCache } from './llm-cache.js';
|
|
|
16
16
|
const DEFAULT_STATUS = 500;
|
|
17
17
|
const DEFAULT_MESSAGE = 'Internal Server Error';
|
|
18
18
|
|
|
19
|
+
const MASKED_FIELDS = new Set([
|
|
20
|
+
'password',
|
|
21
|
+
'passwd',
|
|
22
|
+
'secret',
|
|
23
|
+
'token',
|
|
24
|
+
'api_key',
|
|
25
|
+
'apikey',
|
|
26
|
+
'authorization',
|
|
27
|
+
'auth',
|
|
28
|
+
'credit_card',
|
|
29
|
+
'card_number',
|
|
30
|
+
'cvv',
|
|
31
|
+
'ssn',
|
|
32
|
+
'email',
|
|
33
|
+
'phone',
|
|
34
|
+
'mobile',
|
|
35
|
+
'otp',
|
|
36
|
+
'pin',
|
|
37
|
+
'dob',
|
|
38
|
+
'date_of_birth',
|
|
39
|
+
'address',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recursively mask sensitive fields in an object before it reaches the LLM.
|
|
44
|
+
* Returns a new object; the original is never mutated.
|
|
45
|
+
*/
|
|
46
|
+
function maskForLlm(value) {
|
|
47
|
+
if (value === null || value === undefined) return value;
|
|
48
|
+
if (typeof value !== 'object') return value;
|
|
49
|
+
if (Array.isArray(value)) return value.map(maskForLlm);
|
|
50
|
+
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const [k, v] of Object.entries(value)) {
|
|
53
|
+
result[k] = MASKED_FIELDS.has(k.toLowerCase()) ? '[MASKED]' : maskForLlm(v);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sanitise an error before including it in the LLM prompt.
|
|
60
|
+
* If the error is an object with properties that match the GDPR blocklist,
|
|
61
|
+
* those values are replaced with [MASKED]. If it's a raw string or an Error
|
|
62
|
+
* with only a message, the message is passed through (it's developer-authored
|
|
63
|
+
* code-level text, not user-submitted data).
|
|
64
|
+
*/
|
|
65
|
+
function sanitiseErrorForPrompt(error) {
|
|
66
|
+
if (error === null || error === undefined) return error;
|
|
67
|
+
if (typeof error === 'string') return error;
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
const sanitised = new Error(error.message);
|
|
70
|
+
sanitised.name = error.name;
|
|
71
|
+
return sanitised;
|
|
72
|
+
}
|
|
73
|
+
return maskForLlm(error);
|
|
74
|
+
}
|
|
75
|
+
|
|
19
76
|
/**
|
|
20
77
|
* Build prompt text from code context (and optional error) for the LLM.
|
|
21
78
|
* @param {object} context
|
|
@@ -153,7 +210,7 @@ export async function inferErrorFromContext(context) {
|
|
|
153
210
|
path: context.path,
|
|
154
211
|
includeDevInsight,
|
|
155
212
|
messageType,
|
|
156
|
-
error: context.error,
|
|
213
|
+
error: sanitiseErrorForPrompt(context.error),
|
|
157
214
|
});
|
|
158
215
|
|
|
159
216
|
const { content } = await provider.analyze(prompt);
|
package/server/handler.js
CHANGED
|
@@ -5,6 +5,7 @@ import logHttpRequest from '../utils/request-logger.js';
|
|
|
5
5
|
import Ammo from './ammo.js';
|
|
6
6
|
import TejError from './error.js';
|
|
7
7
|
import targetRegistry from './targets/registry.js';
|
|
8
|
+
import { traceStore } from '../radar/index.js';
|
|
8
9
|
|
|
9
10
|
const errorLogger = new TejLogger('Tejas.Exception');
|
|
10
11
|
const logger = new TejLogger('Tejas');
|
|
@@ -61,14 +62,35 @@ const executeChain = async (target, ammo) => {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const middleware = chain[i];
|
|
65
|
+
const currentIndex = i;
|
|
64
66
|
i++;
|
|
65
67
|
|
|
68
|
+
// Span instrumentation — only active when radar tracing has set up a spanCtx.
|
|
69
|
+
const spanCtx = traceStore.getStore()?.spanCtx;
|
|
70
|
+
const spanStartMs = spanCtx ? Date.now() : 0;
|
|
71
|
+
const isHandler = currentIndex === chain.length - 1;
|
|
72
|
+
const spanName = isHandler
|
|
73
|
+
? `handler:${ammo.endpoint ?? ammo.path ?? '/'}`
|
|
74
|
+
: `middleware:${middleware.name || 'anonymous'}`;
|
|
75
|
+
const spanType = isHandler ? 'handler' : 'middleware';
|
|
76
|
+
|
|
66
77
|
const args =
|
|
67
78
|
middleware.length === 3 ? [ammo.req, ammo.res, next] : [ammo, next];
|
|
68
79
|
|
|
69
80
|
try {
|
|
70
81
|
const result = await middleware(...args);
|
|
71
82
|
|
|
83
|
+
if (spanCtx) {
|
|
84
|
+
spanCtx.addSpan(
|
|
85
|
+
spanName,
|
|
86
|
+
spanType,
|
|
87
|
+
spanCtx.rootSpanId,
|
|
88
|
+
spanStartMs,
|
|
89
|
+
Date.now() - spanStartMs,
|
|
90
|
+
ammo.res.statusCode || 200,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
// Check again after middleware execution (passport might have redirected)
|
|
73
95
|
if (ammo.res.headersSent || ammo.res.writableEnded || ammo.res.finished) {
|
|
74
96
|
return;
|
|
@@ -87,6 +109,17 @@ const executeChain = async (target, ammo) => {
|
|
|
87
109
|
}
|
|
88
110
|
}
|
|
89
111
|
} catch (err) {
|
|
112
|
+
if (spanCtx) {
|
|
113
|
+
spanCtx.addSpan(
|
|
114
|
+
spanName,
|
|
115
|
+
spanType,
|
|
116
|
+
spanCtx.rootSpanId,
|
|
117
|
+
spanStartMs,
|
|
118
|
+
Date.now() - spanStartMs,
|
|
119
|
+
500,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
// Only handle error if response hasn't been sent
|
|
91
124
|
if (
|
|
92
125
|
!ammo.res.headersSent &&
|
package/te.js
CHANGED
|
@@ -381,7 +381,8 @@ class Tejas {
|
|
|
381
381
|
* The project name is auto-detected from `package.json` if not supplied.
|
|
382
382
|
*
|
|
383
383
|
* @param {Object} [config] - Radar configuration
|
|
384
|
-
* @param {string} [config.collectorUrl] Collector base URL (default: RADAR_COLLECTOR_URL env or
|
|
384
|
+
* @param {string} [config.collectorUrl] Collector base URL (default: RADAR_COLLECTOR_URL env or https://collector.usetejas.com).
|
|
385
|
+
* A future release will support self-hosted Radar collectors.
|
|
385
386
|
* @param {string} [config.apiKey] Bearer token `rdr_xxx` (default: RADAR_API_KEY env)
|
|
386
387
|
* @param {string} [config.projectName] Project identifier (default: RADAR_PROJECT_NAME env → package.json name → "tejas-app")
|
|
387
388
|
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default: 2000)
|
|
@@ -405,6 +406,13 @@ class Tejas {
|
|
|
405
406
|
* allowlist of specific header names to send (e.g. `['content-type', 'x-request-id']`).
|
|
406
407
|
* The collector always strips sensitive headers (`authorization`, `cookie`,
|
|
407
408
|
* `set-cookie`, `x-api-key`, etc.) server-side regardless of what is sent.
|
|
409
|
+
* @param {boolean} [config.capture.logs=false]
|
|
410
|
+
* Forward TejLogger calls to the Radar collector as application-level log
|
|
411
|
+
* events. Logs are automatically correlated with the current trace when
|
|
412
|
+
* emitted inside a request context.
|
|
413
|
+
* @param {string[]} [config.capture.logLevels]
|
|
414
|
+
* When `capture.logs` is true, only forward these levels to the collector
|
|
415
|
+
* (e.g. `['warn', 'error']`). Defaults to all levels when omitted.
|
|
408
416
|
*
|
|
409
417
|
* @param {Object} [config.mask] Client-side masking applied to request/response bodies
|
|
410
418
|
* before data is sent to the collector.
|