te.js 2.2.1 → 2.3.0

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://tejas-documentation.vercel.app/tejas-logo.svg" alt="Tejas Logo" width="200">
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://tejas-documentation.vercel.app">Documentation</a> •
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://tejas-documentation.vercel.app).
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.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "AI Native Node.js Framework",
5
5
  "type": "module",
6
6
  "main": "te.js",
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
- process.env.RADAR_COLLECTOR_URL ?? 'http://localhost:3100';
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
- ? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
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
- const incoming = status >= 400 ? 2 : 1;
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.enabled is true and no explicit code/message is given (no args,
343
- * Error, or string/other), an LLM infers statusCode and message from context.
344
- * In that case throw() returns a Promise; otherwise it returns undefined.
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, only when no explicit status code): pass an object
347
- * with `useLlm` (boolean) and/or `messageType` ('endUser' | 'developer'). Use
348
- * `useLlm: false` to skip the LLM for this call; use `messageType` to override
349
- * errors.llm.messageType for this call (end-user-friendly vs developer-friendly message).
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 Not Found error with custom message
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
- * // Throw an error from an Error object
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; use default 500
369
- * ammo.throw(err, { useLlm: false });
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? } when call is LLM-eligible (no explicit code).
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
- const useLlm = llmEnabled && llmEligible && throwOpts?.useLlm !== false;
405
-
406
- if (useLlm) {
407
- // Capture the stack string SYNCHRONOUSLY before any async work or fire() call,
408
- // because the call stack unwinds as soon as we await or respond.
409
- const stack =
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
- // Run LLM in the background; expose the promise so the Radar middleware
440
- // can await it before flushing events (ensures LLM data is captured).
441
- const method = this.method;
442
- const path = this.path;
443
- const self = this;
444
- this._llmPromise = captureCodeContext(stack)
445
- .then((codeContext) => {
446
- // Update _errorInfo with captured code context
447
- if (self._errorInfo) self._errorInfo.codeContext = codeContext;
448
- const context = {
449
- codeContext,
450
- method,
451
- path,
452
- // Always request devInsight in async mode — it goes to the channel, not the HTTP response.
453
- includeDevInsight: true,
454
- forceDevInsight: true,
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
- return;
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
- // Sync mode (default): block until LLM responds, then fire.
497
- return captureCodeContext(stack)
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: this.method,
502
- path: this.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
- const { statusCode, message, devInsight } = result;
516
- const errorType =
517
- originalError != null &&
518
- typeof originalError.constructor?.name === 'string'
519
- ? originalError.constructor.name
520
- : originalError !== undefined
521
- ? typeof originalError
522
- : null;
523
- this._errorInfo = {
524
- message,
525
- type: errorType,
526
- devInsight: devInsight ?? null,
527
- stack: stack ?? null,
528
- codeContext: codeContext ?? null,
529
- };
530
- const isProduction = process.env.NODE_ENV === 'production';
531
- const data =
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
- // LLM call failed (network error, timeout, etc.) — fall back to generic 500
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
- if (isStatusCode(args[0])) {
559
- const statusCode = args[0];
560
- const message = args[1] || toStatusMessage(statusCode);
561
- this._errorInfo = {
562
- message,
563
- type: null,
564
- devInsight: null,
565
- stack: null,
566
- codeContext: null,
567
- };
568
- this.fire(statusCode, message);
569
- return;
570
- }
571
-
572
- if (
573
- typeof args[0]?.statusCode === 'number' &&
574
- typeof args[0]?.code === 'string'
575
- ) {
576
- const error = args[0];
577
- this._errorInfo = {
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: error.constructor.name,
600
- devInsight: null,
601
- stack: error.stack ?? null,
602
- codeContext: null,
606
+ message: finalMessage,
607
+ type: errorType,
608
+ devInsight,
609
+ stack: stack ?? null,
610
+ codeContext: codeContext ?? null,
603
611
  };
604
- this.fire(statusCode, message);
605
- return;
606
- }
607
- const statusCode = toStatusCode(error.message);
608
- if (statusCode) {
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: error.message,
611
- type: error.constructor.name,
622
+ message,
623
+ type: errorType,
612
624
  devInsight: null,
613
- stack: error.stack ?? null,
625
+ stack: resolved.stack,
614
626
  codeContext: null,
615
627
  };
616
- this.fire(statusCode, error.message);
617
- return;
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
@@ -275,7 +275,7 @@ class Tejas {
275
275
  * @param {string} [config.model] - Model name (e.g. gpt-4o-mini)
276
276
  * @param {'endUser'|'developer'} [config.messageType] - Default message tone
277
277
  * @param {'sync'|'async'} [config.mode] - 'sync' blocks the response until LLM returns (default); 'async' responds immediately with 500 and dispatches LLM result to a channel
278
- * @param {number} [config.timeout] - LLM fetch timeout in milliseconds (default 10000)
278
+ * @param {number} [config.timeout] - LLM fetch timeout in milliseconds (default 20000)
279
279
  * @param {'console'|'log'|'both'} [config.channel] - Output channel for async mode results (default 'console')
280
280
  * @param {string} [config.logFile] - Path to JSONL log file used by 'log' and 'both' channels (default './errors.llm.log')
281
281
  * @param {number} [config.rateLimit] - Max LLM calls per minute across all requests (default 10)
@@ -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 http://localhost:3100)
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.
@@ -107,7 +107,7 @@ export function getErrorsLlmConfig() {
107
107
  const timeoutRaw = env('ERRORS_LLM_TIMEOUT') ?? env('LLM_TIMEOUT') ?? '';
108
108
  const timeoutNum = Number(timeoutRaw);
109
109
  const timeout =
110
- !timeoutRaw || isNaN(timeoutNum) || timeoutNum <= 0 ? 10000 : timeoutNum;
110
+ !timeoutRaw || isNaN(timeoutNum) || timeoutNum <= 0 ? 20000 : timeoutNum;
111
111
 
112
112
  const channelRaw = env('ERRORS_LLM_CHANNEL') ?? env('LLM_CHANNEL') ?? '';
113
113