@agenttrace-io/sdk 0.1.9
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/dist/benchmark.d.ts +79 -0
- package/dist/benchmark.d.ts.map +1 -0
- package/dist/benchmark.js +324 -0
- package/dist/benchmark.js.map +1 -0
- package/dist/index.d.ts +358 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1169 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/001-initial.d.ts +5 -0
- package/dist/migrations/001-initial.d.ts.map +1 -0
- package/dist/migrations/001-initial.js +86 -0
- package/dist/migrations/001-initial.js.map +1 -0
- package/dist/migrations/002-scores.d.ts +5 -0
- package/dist/migrations/002-scores.d.ts.map +1 -0
- package/dist/migrations/002-scores.js +17 -0
- package/dist/migrations/002-scores.js.map +1 -0
- package/dist/migrations/003-alerts.d.ts +5 -0
- package/dist/migrations/003-alerts.d.ts.map +1 -0
- package/dist/migrations/003-alerts.js +27 -0
- package/dist/migrations/003-alerts.js.map +1 -0
- package/dist/migrations/004-trace-context.d.ts +5 -0
- package/dist/migrations/004-trace-context.d.ts.map +1 -0
- package/dist/migrations/004-trace-context.js +16 -0
- package/dist/migrations/004-trace-context.js.map +1 -0
- package/dist/migrations/005-agent-usage.d.ts +5 -0
- package/dist/migrations/005-agent-usage.d.ts.map +1 -0
- package/dist/migrations/005-agent-usage.js +27 -0
- package/dist/migrations/005-agent-usage.js.map +1 -0
- package/dist/migrations/005-webhooks.d.ts +5 -0
- package/dist/migrations/005-webhooks.d.ts.map +1 -0
- package/dist/migrations/005-webhooks.js +33 -0
- package/dist/migrations/005-webhooks.js.map +1 -0
- package/dist/migrations/006-api-keys.d.ts +5 -0
- package/dist/migrations/006-api-keys.d.ts.map +1 -0
- package/dist/migrations/006-api-keys.js +27 -0
- package/dist/migrations/006-api-keys.js.map +1 -0
- package/dist/migrations.d.ts +29 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +107 -0
- package/dist/migrations.js.map +1 -0
- package/dist/rate-limiter.d.ts +34 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +74 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/self-track.d.ts +42 -0
- package/dist/self-track.d.ts.map +1 -0
- package/dist/self-track.js +288 -0
- package/dist/self-track.js.map +1 -0
- package/dist/storage.d.ts +149 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +1479 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +323 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentTrace -- Core SDK
|
|
3
|
+
* Drop-in tracing for any AI agent
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID, createHash, createHmac } from 'node:crypto';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { TraceStorage } from './storage.js';
|
|
8
|
+
import { TokenBucketRateLimiter } from './rate-limiter.js';
|
|
9
|
+
export const VERSION = '0.1.0';
|
|
10
|
+
export const PACKAGE_NAME = '@agenttrace-io/sdk';
|
|
11
|
+
// Value import — TraceContext is a class (value), not just a type.
|
|
12
|
+
// Must be a separate import from the type-only block above so isolatedModules
|
|
13
|
+
// doesn't erase it at compile time.
|
|
14
|
+
import { TraceContext } from './types.js';
|
|
15
|
+
export { TraceContext } from './types.js';
|
|
16
|
+
export { TraceStorage } from './storage.js';
|
|
17
|
+
export { SelfTracker } from './self-track.js';
|
|
18
|
+
export { TokenBucketRateLimiter } from './rate-limiter.js';
|
|
19
|
+
// Default cost calculator (approximate 2026 pricing)
|
|
20
|
+
// Rates are in USD per 1000 tokens. Extended with additional models for v0.2.0.
|
|
21
|
+
const modelRates = {
|
|
22
|
+
'gpt-4o': { prompt: 0.0025, completion: 0.01 },
|
|
23
|
+
'gpt-4o-mini': { prompt: 0.00015, completion: 0.0006 },
|
|
24
|
+
'claude-sonnet-4': { prompt: 0.003, completion: 0.015 },
|
|
25
|
+
'claude-haiku-4': { prompt: 0.00025, completion: 0.00125 },
|
|
26
|
+
'gemini-2.0-flash': { prompt: 0.0001, completion: 0.0004 },
|
|
27
|
+
'llama-3.1-70b': { prompt: 0.0009, completion: 0.0009 },
|
|
28
|
+
// Added models (approximate current pricing researched 2026)
|
|
29
|
+
'claude-opus-4': { prompt: 0.005, completion: 0.025 },
|
|
30
|
+
'claude-sonnet-4.5': { prompt: 0.003, completion: 0.015 },
|
|
31
|
+
'claude-haiku-4.5': { prompt: 0.001, completion: 0.005 },
|
|
32
|
+
'gpt-4.1': { prompt: 0.002, completion: 0.008 },
|
|
33
|
+
'gpt-4.1-mini': { prompt: 0.0004, completion: 0.0016 },
|
|
34
|
+
'gpt-4.1-nano': { prompt: 0.0001, completion: 0.0004 },
|
|
35
|
+
'gemini-2.5-pro': { prompt: 0.00125, completion: 0.01 },
|
|
36
|
+
'gemini-2.5-flash': { prompt: 0.0003, completion: 0.0025 },
|
|
37
|
+
'llama-4-scout': { prompt: 0.00008, completion: 0.0003 },
|
|
38
|
+
'llama-4-maverick': { prompt: 0.00015, completion: 0.0006 },
|
|
39
|
+
};
|
|
40
|
+
function defaultCostCalculator(tokens, model) {
|
|
41
|
+
const rate = modelRates[model || ''] || { prompt: 0.001, completion: 0.002 };
|
|
42
|
+
return (tokens.promptTokens * rate.prompt + tokens.completionTokens * rate.completion) / 1000;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Register or override pricing rates for a model in the default cost calculator (used at runtime).
|
|
46
|
+
* Rates are USD per 1,000 tokens (matching the calculator convention).
|
|
47
|
+
* Example: registerModelRate('my-model', 0.001, 0.002) => $1/M prompt, $2/M completion
|
|
48
|
+
*/
|
|
49
|
+
export function registerModelRate(model, promptRatePerK, completionRatePerK) {
|
|
50
|
+
modelRates[model] = { prompt: promptRatePerK, completion: completionRatePerK };
|
|
51
|
+
}
|
|
52
|
+
// ---- OpenTelemetry (OTLP JSON) export helpers (no external deps) ----
|
|
53
|
+
function toOtelId(id, length) {
|
|
54
|
+
const cleaned = id.replace(/-/g, '');
|
|
55
|
+
if (/^[0-9a-fA-F]{32}$/.test(cleaned)) {
|
|
56
|
+
return cleaned.toLowerCase().slice(0, length);
|
|
57
|
+
}
|
|
58
|
+
// Fallback for non-UUID ids (e.g. test data like 'trace-1'); deterministic
|
|
59
|
+
return createHash('sha256').update(id).digest('hex').slice(0, length);
|
|
60
|
+
}
|
|
61
|
+
function toUnixNano(timestampMs) {
|
|
62
|
+
return String(BigInt(Math.floor(timestampMs)) * 1000000n);
|
|
63
|
+
}
|
|
64
|
+
function toOtelAttrValue(v) {
|
|
65
|
+
if (v === null || v === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
const t = typeof v;
|
|
68
|
+
if (t === 'string')
|
|
69
|
+
return { stringValue: v };
|
|
70
|
+
if (t === 'number') {
|
|
71
|
+
return Number.isInteger(v) ? { intValue: v } : { doubleValue: v };
|
|
72
|
+
}
|
|
73
|
+
if (t === 'boolean')
|
|
74
|
+
return { boolValue: v };
|
|
75
|
+
return { stringValue: JSON.stringify(v) };
|
|
76
|
+
}
|
|
77
|
+
function stringifyForAttr(v, maxLen = 2048) {
|
|
78
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
79
|
+
if (s.length <= maxLen)
|
|
80
|
+
return s;
|
|
81
|
+
return s.slice(0, maxLen - 3) + '...';
|
|
82
|
+
}
|
|
83
|
+
function buildOtelAttributes(trace) {
|
|
84
|
+
const attrs = [];
|
|
85
|
+
attrs.push({ key: 'agenttrace.status', value: { stringValue: trace.status } });
|
|
86
|
+
attrs.push({ key: 'agenttrace.latency_ms', value: { intValue: trace.latencyMs } });
|
|
87
|
+
attrs.push({ key: 'agenttrace.cost_usd', value: { doubleValue: trace.costUsd } });
|
|
88
|
+
attrs.push({ key: 'agenttrace.run_id', value: { stringValue: trace.runId } });
|
|
89
|
+
const tok = trace.tokens || { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
90
|
+
attrs.push({ key: 'agenttrace.tokens.prompt', value: { intValue: tok.promptTokens } });
|
|
91
|
+
attrs.push({ key: 'agenttrace.tokens.completion', value: { intValue: tok.completionTokens } });
|
|
92
|
+
attrs.push({ key: 'agenttrace.tokens.total', value: { intValue: tok.totalTokens } });
|
|
93
|
+
if (tok.model) {
|
|
94
|
+
attrs.push({ key: 'agenttrace.model', value: { stringValue: tok.model } });
|
|
95
|
+
}
|
|
96
|
+
if (tok.provider) {
|
|
97
|
+
attrs.push({ key: 'agenttrace.provider', value: { stringValue: tok.provider } });
|
|
98
|
+
}
|
|
99
|
+
if (trace.error) {
|
|
100
|
+
attrs.push({ key: 'agenttrace.error', value: { stringValue: trace.error } });
|
|
101
|
+
}
|
|
102
|
+
// metadata
|
|
103
|
+
const meta = trace.metadata || {};
|
|
104
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
105
|
+
const otelVal = toOtelAttrValue(v);
|
|
106
|
+
if (otelVal) {
|
|
107
|
+
attrs.push({ key: `agenttrace.metadata.${k}`, value: otelVal });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// input/output (stringified, truncated)
|
|
111
|
+
if (trace.input != null) {
|
|
112
|
+
attrs.push({ key: 'agenttrace.input', value: { stringValue: stringifyForAttr(trace.input) } });
|
|
113
|
+
}
|
|
114
|
+
if (trace.output != null) {
|
|
115
|
+
attrs.push({
|
|
116
|
+
key: 'agenttrace.output',
|
|
117
|
+
value: { stringValue: stringifyForAttr(trace.output) },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return attrs;
|
|
121
|
+
}
|
|
122
|
+
function buildResourceAttributes() {
|
|
123
|
+
return [
|
|
124
|
+
{ key: 'service.name', value: { stringValue: 'agenttrace' } },
|
|
125
|
+
{ key: 'telemetry.sdk.name', value: { stringValue: 'agenttrace' } },
|
|
126
|
+
{ key: 'telemetry.sdk.version', value: { stringValue: VERSION } },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
function traceToOtelSpan(trace) {
|
|
130
|
+
const traceId = toOtelId(trace.id, 32);
|
|
131
|
+
const spanId = toOtelId(trace.id, 16);
|
|
132
|
+
const endMs = trace.createdAt || Date.now();
|
|
133
|
+
const startMs = Math.max(0, endMs - (trace.latencyMs || 0));
|
|
134
|
+
const startTimeUnixNano = toUnixNano(startMs);
|
|
135
|
+
const endTimeUnixNano = toUnixNano(endMs);
|
|
136
|
+
const attributes = buildOtelAttributes(trace);
|
|
137
|
+
const isSuccess = trace.status === 'success';
|
|
138
|
+
const status = isSuccess
|
|
139
|
+
? { code: 1 } // STATUS_CODE_OK
|
|
140
|
+
: { code: 2, message: trace.error || trace.status }; // STATUS_CODE_ERROR
|
|
141
|
+
return {
|
|
142
|
+
traceId,
|
|
143
|
+
spanId,
|
|
144
|
+
name: trace.name,
|
|
145
|
+
kind: 1, // SPAN_KIND_INTERNAL
|
|
146
|
+
startTimeUnixNano,
|
|
147
|
+
endTimeUnixNano,
|
|
148
|
+
attributes,
|
|
149
|
+
status,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export class AgentTrace {
|
|
153
|
+
storage;
|
|
154
|
+
config;
|
|
155
|
+
currentRunId = null;
|
|
156
|
+
registeredAlerts = [];
|
|
157
|
+
usageEmitter = new EventEmitter();
|
|
158
|
+
webhookEmitter = new EventEmitter();
|
|
159
|
+
_cleanupInterval;
|
|
160
|
+
rateLimiter = null;
|
|
161
|
+
activeTraceContext = null;
|
|
162
|
+
constructor(config = {}) {
|
|
163
|
+
const dbPath = config.dbPath || './agenttrace.db';
|
|
164
|
+
const tenantId = config.tenantId ?? '';
|
|
165
|
+
this.storage = new TraceStorage(dbPath, tenantId);
|
|
166
|
+
const persisted = this.storage.getRetentionPolicy();
|
|
167
|
+
this.config = {
|
|
168
|
+
dbPath,
|
|
169
|
+
maxTraces: config.maxTraces ?? 10000,
|
|
170
|
+
autoCleanup: config.autoCleanup !== false,
|
|
171
|
+
costCalculator: config.costCalculator || defaultCostCalculator,
|
|
172
|
+
hallucinationDetector: config.hallucinationDetector || (() => false),
|
|
173
|
+
silent: !!config.silent,
|
|
174
|
+
retentionDays: config.retentionDays !== undefined ? config.retentionDays : persisted.retentionDays,
|
|
175
|
+
cleanupIntervalHours: config.cleanupIntervalHours !== undefined
|
|
176
|
+
? config.cleanupIntervalHours
|
|
177
|
+
: persisted.cleanupIntervalHours,
|
|
178
|
+
tenantId,
|
|
179
|
+
maxTracesPerSecond: config.maxTracesPerSecond ?? 0,
|
|
180
|
+
maxTracesPerMinute: config.maxTracesPerMinute ?? 0,
|
|
181
|
+
burstAllowance: config.burstAllowance ?? 10,
|
|
182
|
+
};
|
|
183
|
+
// Initialize rate limiter if any rate limit is configured
|
|
184
|
+
if (this.config.maxTracesPerSecond > 0 || this.config.maxTracesPerMinute > 0) {
|
|
185
|
+
this.rateLimiter = new TokenBucketRateLimiter({
|
|
186
|
+
maxTracesPerSecond: this.config.maxTracesPerSecond,
|
|
187
|
+
maxTracesPerMinute: this.config.maxTracesPerMinute,
|
|
188
|
+
burstAllowance: this.config.burstAllowance,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
this.setupRetentionCleanup();
|
|
192
|
+
}
|
|
193
|
+
setupRetentionCleanup() {
|
|
194
|
+
if (this._cleanupInterval) {
|
|
195
|
+
clearInterval(this._cleanupInterval);
|
|
196
|
+
this._cleanupInterval = undefined;
|
|
197
|
+
}
|
|
198
|
+
if (this.config.retentionDays > 0) {
|
|
199
|
+
const hours = this.config.cleanupIntervalHours > 0 ? this.config.cleanupIntervalHours : 24;
|
|
200
|
+
const intervalMs = hours * 60 * 60 * 1000;
|
|
201
|
+
this._cleanupInterval = setInterval(() => {
|
|
202
|
+
const before = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
|
203
|
+
try {
|
|
204
|
+
this.storage.cleanupOldTraces(before);
|
|
205
|
+
this.storage.cleanupOldRuns(before);
|
|
206
|
+
this.storage.cleanupOldAgentUsage(before);
|
|
207
|
+
}
|
|
208
|
+
catch (_) {
|
|
209
|
+
/* scheduled cleanup must never crash host process */
|
|
210
|
+
}
|
|
211
|
+
}, intervalMs);
|
|
212
|
+
if (this._cleanupInterval &&
|
|
213
|
+
typeof this._cleanupInterval.unref === 'function') {
|
|
214
|
+
this._cleanupInterval.unref();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Start a new agent run
|
|
220
|
+
*/
|
|
221
|
+
startRun(name, metadata = {}) {
|
|
222
|
+
const runId = randomUUID();
|
|
223
|
+
this.storage.createRun({
|
|
224
|
+
id: runId,
|
|
225
|
+
name,
|
|
226
|
+
startedAt: Date.now(),
|
|
227
|
+
metadata,
|
|
228
|
+
tenantId: this.config.tenantId || undefined,
|
|
229
|
+
});
|
|
230
|
+
this.currentRunId = runId;
|
|
231
|
+
return runId;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Complete the current run
|
|
235
|
+
*/
|
|
236
|
+
completeRun(status = 'success') {
|
|
237
|
+
if (this.currentRunId) {
|
|
238
|
+
this.storage.completeRun(this.currentRunId, status);
|
|
239
|
+
const runId = this.currentRunId;
|
|
240
|
+
this.currentRunId = null;
|
|
241
|
+
// Fire-and-forget webhook delivery for run complete/error
|
|
242
|
+
if (status === 'success') {
|
|
243
|
+
this.triggerWebhook('run.complete', { runId }).catch(() => { });
|
|
244
|
+
try {
|
|
245
|
+
this.webhookEmitter.emit('webhook', 'run.complete', { runId });
|
|
246
|
+
}
|
|
247
|
+
catch (_) {
|
|
248
|
+
/* handler errors must not break */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
this.triggerWebhook('run.error', { runId }).catch(() => { });
|
|
253
|
+
try {
|
|
254
|
+
this.webhookEmitter.emit('webhook', 'run.error', { runId });
|
|
255
|
+
}
|
|
256
|
+
catch (_) {
|
|
257
|
+
/* handler errors must not break */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Trace an async function call
|
|
264
|
+
*/
|
|
265
|
+
async trace(name, fn, options = {}) {
|
|
266
|
+
const traceId = options.context && typeof options.context.traceId === 'string'
|
|
267
|
+
? options.context.traceId
|
|
268
|
+
: randomUUID();
|
|
269
|
+
const parentId = options.context && typeof options.context.traceId === 'string'
|
|
270
|
+
? options.context.parentSpanId
|
|
271
|
+
: (options.parentId ?? undefined);
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
let result;
|
|
274
|
+
let error;
|
|
275
|
+
let status = 'success';
|
|
276
|
+
// Rate limit check
|
|
277
|
+
if (this.rateLimiter && !this.rateLimiter.tryConsume()) {
|
|
278
|
+
// Rate limited — execute the function but don't record the trace
|
|
279
|
+
return fn();
|
|
280
|
+
}
|
|
281
|
+
const prevContext = this.activeTraceContext;
|
|
282
|
+
this.activeTraceContext = { traceId, toolCalls: [] };
|
|
283
|
+
try {
|
|
284
|
+
result = await fn();
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
error = e instanceof Error ? e.message : String(e);
|
|
288
|
+
status = 'error';
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
const latencyMs = Date.now() - startTime;
|
|
293
|
+
const tokens = options.tokens || {
|
|
294
|
+
promptTokens: 0,
|
|
295
|
+
completionTokens: 0,
|
|
296
|
+
totalTokens: 0,
|
|
297
|
+
model: options.model,
|
|
298
|
+
provider: options.provider,
|
|
299
|
+
};
|
|
300
|
+
const costUsd = this.config.costCalculator(tokens, options.model);
|
|
301
|
+
const baseMeta = options.metadata || {};
|
|
302
|
+
const ctxMeta = options.context ? options.context.metadata || {} : {};
|
|
303
|
+
const mergedMeta = { ...ctxMeta, ...baseMeta };
|
|
304
|
+
// Drain collected tool calls from active context (populated by recordToolCall during fn)
|
|
305
|
+
const toolCalls = this.activeTraceContext ? this.activeTraceContext.toolCalls : [];
|
|
306
|
+
// Restore previous context (supports nesting; top-level restores to null)
|
|
307
|
+
this.activeTraceContext = prevContext;
|
|
308
|
+
const trace = {
|
|
309
|
+
id: traceId,
|
|
310
|
+
runId: this.currentRunId || randomUUID(),
|
|
311
|
+
name,
|
|
312
|
+
status,
|
|
313
|
+
input: options.input ?? null,
|
|
314
|
+
output: result ?? null,
|
|
315
|
+
tokens,
|
|
316
|
+
toolCalls,
|
|
317
|
+
latencyMs,
|
|
318
|
+
costUsd,
|
|
319
|
+
error,
|
|
320
|
+
metadata: mergedMeta,
|
|
321
|
+
parentId,
|
|
322
|
+
tenantId: this.config.tenantId || undefined,
|
|
323
|
+
};
|
|
324
|
+
this.storage.createTrace(trace);
|
|
325
|
+
if (this.config.autoCleanup) {
|
|
326
|
+
this.storage.cleanup(this.config.maxTraces);
|
|
327
|
+
}
|
|
328
|
+
// Auto-check alerts after each trace (awaited so history/delivery visible to caller immediately)
|
|
329
|
+
try {
|
|
330
|
+
await this.checkAlerts();
|
|
331
|
+
}
|
|
332
|
+
catch (_) {
|
|
333
|
+
/* alerts must never cause trace() to fail */
|
|
334
|
+
}
|
|
335
|
+
// Fire-and-forget webhook delivery for trace complete/error
|
|
336
|
+
if (status === 'success') {
|
|
337
|
+
this.triggerWebhook('trace.complete', {
|
|
338
|
+
traceId,
|
|
339
|
+
runId: trace.runId,
|
|
340
|
+
name,
|
|
341
|
+
latencyMs,
|
|
342
|
+
costUsd,
|
|
343
|
+
}).catch(() => { });
|
|
344
|
+
try {
|
|
345
|
+
this.webhookEmitter.emit('webhook', 'trace.complete', {
|
|
346
|
+
traceId,
|
|
347
|
+
runId: trace.runId,
|
|
348
|
+
name,
|
|
349
|
+
latencyMs,
|
|
350
|
+
costUsd,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (_) {
|
|
354
|
+
/* handler errors must not break */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
this.triggerWebhook('trace.error', { traceId, runId: trace.runId, name, error }).catch(() => { });
|
|
359
|
+
try {
|
|
360
|
+
this.webhookEmitter.emit('webhook', 'trace.error', {
|
|
361
|
+
traceId,
|
|
362
|
+
runId: trace.runId,
|
|
363
|
+
name,
|
|
364
|
+
error,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
catch (_) {
|
|
368
|
+
/* handler errors must not break */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Record a tool call within the current trace
|
|
376
|
+
*/
|
|
377
|
+
recordToolCall(call) {
|
|
378
|
+
const id = randomUUID();
|
|
379
|
+
const timestamp = Date.now();
|
|
380
|
+
const fullCall = { ...call, id, timestamp };
|
|
381
|
+
if (this.activeTraceContext) {
|
|
382
|
+
this.activeTraceContext.toolCalls.push(fullCall);
|
|
383
|
+
return id;
|
|
384
|
+
}
|
|
385
|
+
// Outside active trace: still return id (for potential manual correlation) but warn
|
|
386
|
+
if (!this.config.silent) {
|
|
387
|
+
console.warn(`[AgentTrace] recordToolCall("${call.name}") called outside an active trace() — tool call will not be stored. Call recordToolCall from within a trace() callback.`);
|
|
388
|
+
}
|
|
389
|
+
return id;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Get traces with filtering
|
|
393
|
+
*/
|
|
394
|
+
getTraces(filter = {}) {
|
|
395
|
+
return this.storage.getTraces(filter);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Get a specific trace
|
|
399
|
+
*/
|
|
400
|
+
getTrace(id) {
|
|
401
|
+
return this.storage.getTrace(id);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Get recent runs (most recent first)
|
|
405
|
+
*/
|
|
406
|
+
getRuns(limit = 100) {
|
|
407
|
+
return this.storage.getRuns(limit);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Get a specific run
|
|
411
|
+
*/
|
|
412
|
+
getRun(id) {
|
|
413
|
+
return this.storage.getRun(id);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get summary statistics
|
|
417
|
+
*/
|
|
418
|
+
getStats() {
|
|
419
|
+
return this.storage.getStats(this.config.tenantId || undefined);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Get the number of traces dropped due to rate limiting.
|
|
423
|
+
* Returns 0 if rate limiting is not configured.
|
|
424
|
+
*/
|
|
425
|
+
getDroppedTraces() {
|
|
426
|
+
return this.rateLimiter?.getDroppedTraces() ?? 0;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Get cost breakdown (by model, by day, total). Supports optional run filter.
|
|
430
|
+
*/
|
|
431
|
+
getCostBreakdown(filter = {}) {
|
|
432
|
+
return this.storage.getCostBreakdown(filter.runId, this.config.tenantId || undefined);
|
|
433
|
+
}
|
|
434
|
+
// ---- Agent usage tracking (for self-observability by agents) ----
|
|
435
|
+
/**
|
|
436
|
+
* Record a usage/action event from an agent (for agent self-tracking of its own operations/costs).
|
|
437
|
+
* Agents can call this to log high-level actions beyond LLM traces.
|
|
438
|
+
*/
|
|
439
|
+
recordAgentUsage(record) {
|
|
440
|
+
const full = {
|
|
441
|
+
id: record.id || randomUUID(),
|
|
442
|
+
createdAt: record.createdAt || Date.now(),
|
|
443
|
+
agentName: record.agentName,
|
|
444
|
+
agentType: record.agentType,
|
|
445
|
+
sessionId: record.sessionId,
|
|
446
|
+
action: record.action,
|
|
447
|
+
target: record.target,
|
|
448
|
+
tokensUsed: record.tokensUsed ?? 0,
|
|
449
|
+
costUsd: record.costUsd ?? 0,
|
|
450
|
+
durationMs: record.durationMs ?? 0,
|
|
451
|
+
status: record.status || 'success',
|
|
452
|
+
metadata: record.metadata || {},
|
|
453
|
+
tenantId: this.config.tenantId || undefined,
|
|
454
|
+
};
|
|
455
|
+
this.storage.recordAgentUsage(full);
|
|
456
|
+
this.usageEmitter.emit('usage', full);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Query recorded agent usage records.
|
|
460
|
+
*/
|
|
461
|
+
getAgentUsage(filter = {}) {
|
|
462
|
+
return this.storage.getAgentUsage(filter, this.config.tenantId || undefined);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Get aggregated usage statistics across agent actions.
|
|
466
|
+
*/
|
|
467
|
+
getUsageStats(agentName, fromDate, toDate) {
|
|
468
|
+
return this.storage.getUsageStats(agentName, fromDate, toDate, this.config.tenantId || undefined);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Get list of agents with their last active time (all time, sorted recent first).
|
|
472
|
+
*/
|
|
473
|
+
getActiveAgents() {
|
|
474
|
+
return this.storage.getActiveAgents();
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Get 'who' summary (active agents overview, supports activeOnly for last 30min).
|
|
478
|
+
*/
|
|
479
|
+
getAgentWho(filter = {}) {
|
|
480
|
+
return this.storage.getAgentWho(filter);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Get agent sessions summary.
|
|
484
|
+
*/
|
|
485
|
+
getAgentSessions(filter = {}) {
|
|
486
|
+
return this.storage.getAgentSessions(filter);
|
|
487
|
+
}
|
|
488
|
+
// ---- API key management ----
|
|
489
|
+
/**
|
|
490
|
+
* Create a new API key for dashboard API authentication.
|
|
491
|
+
* Returns the full secret key (display once) + metadata record.
|
|
492
|
+
* The secret is never stored; only its SHA-256 hash is persisted.
|
|
493
|
+
*/
|
|
494
|
+
createApiKey(name) {
|
|
495
|
+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
496
|
+
throw new Error('API key name is required');
|
|
497
|
+
}
|
|
498
|
+
const fullKey = `at_${randomUUID().replace(/-/g, '')}`;
|
|
499
|
+
const meta = this.storage.createApiKey(name.trim(), ['read', 'write'], fullKey);
|
|
500
|
+
return { ...meta, key: fullKey };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* List API keys (masked/previewed, no secrets).
|
|
504
|
+
*/
|
|
505
|
+
listApiKeys() {
|
|
506
|
+
return this.storage.getApiKeys();
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Revoke an API key by its id. Returns true if deleted.
|
|
510
|
+
*/
|
|
511
|
+
revokeApiKey(id) {
|
|
512
|
+
if (!id)
|
|
513
|
+
return;
|
|
514
|
+
this.storage.revokeApiKey(id);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Validate a raw API key string (e.g. from header). Returns matching metadata or null.
|
|
518
|
+
* Side effect: updates lastUsedAt on success.
|
|
519
|
+
*/
|
|
520
|
+
validateApiKey(key) {
|
|
521
|
+
return this.storage.validateApiKey(key);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Subscribe to new agent usage records (for live dashboards / SSE).
|
|
525
|
+
*/
|
|
526
|
+
onUsage(listener) {
|
|
527
|
+
this.usageEmitter.on('usage', listener);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Unsubscribe from agent usage events.
|
|
531
|
+
*/
|
|
532
|
+
offUsage(listener) {
|
|
533
|
+
this.usageEmitter.off('usage', listener);
|
|
534
|
+
}
|
|
535
|
+
// ---- Multi-tenant Project Management ----
|
|
536
|
+
/**
|
|
537
|
+
* Create a new project for multi-tenant isolation.
|
|
538
|
+
* Returns the project with its API key (shown only once).
|
|
539
|
+
*/
|
|
540
|
+
createProject(name) {
|
|
541
|
+
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
542
|
+
throw new Error('Project name is required');
|
|
543
|
+
}
|
|
544
|
+
return this.storage.createProject(name.trim());
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Look up a project by its API key.
|
|
548
|
+
*/
|
|
549
|
+
getProject(apiKey) {
|
|
550
|
+
const p = this.storage.getProject(apiKey);
|
|
551
|
+
if (!p)
|
|
552
|
+
return null;
|
|
553
|
+
// Don't expose the apiKey in the return — caller already has it
|
|
554
|
+
return { id: p.id, name: p.name, createdAt: p.createdAt };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Delete a project by ID. Returns true if deleted.
|
|
558
|
+
*/
|
|
559
|
+
deleteProject(id) {
|
|
560
|
+
return this.storage.deleteProject(id);
|
|
561
|
+
}
|
|
562
|
+
// ---- Webhook Management ----
|
|
563
|
+
/**
|
|
564
|
+
* Register a new webhook. Returns the webhook ID.
|
|
565
|
+
*/
|
|
566
|
+
addWebhook(url, events, secret) {
|
|
567
|
+
return this.storage.registerWebhook(url, events, secret);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* List all configured webhooks.
|
|
571
|
+
*/
|
|
572
|
+
getWebhooks() {
|
|
573
|
+
return this.storage.getWebhooks();
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Remove a webhook by ID.
|
|
577
|
+
*/
|
|
578
|
+
removeWebhook(id) {
|
|
579
|
+
this.storage.deleteWebhook(id);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Register a new webhook (alias for addWebhook). Returns the webhook ID.
|
|
583
|
+
*/
|
|
584
|
+
registerWebhook(url, events, secret) {
|
|
585
|
+
return this.addWebhook(url, events, secret);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Delete a webhook by ID (alias for removeWebhook).
|
|
589
|
+
*/
|
|
590
|
+
deleteWebhook(id) {
|
|
591
|
+
this.removeWebhook(id);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Trigger webhooks for a given event. Finds all enabled webhooks registered for
|
|
595
|
+
* the event, builds the payload, signs it if a secret is configured, and POSTs
|
|
596
|
+
* to each URL. Returns delivery results.
|
|
597
|
+
*/
|
|
598
|
+
async triggerWebhook(event, payload) {
|
|
599
|
+
const webhooks = this.storage.getEnabledWebhooksForEvent(event);
|
|
600
|
+
const results = [];
|
|
601
|
+
const timestamp = Date.now();
|
|
602
|
+
for (const wh of webhooks) {
|
|
603
|
+
const fullPayload = { event, timestamp, ...payload };
|
|
604
|
+
const bodyStr = JSON.stringify(fullPayload);
|
|
605
|
+
const headers = {
|
|
606
|
+
'Content-Type': 'application/json',
|
|
607
|
+
'User-Agent': `AgentTrace/${VERSION}`,
|
|
608
|
+
};
|
|
609
|
+
if (wh.secret) {
|
|
610
|
+
const sig = createHash('sha256')
|
|
611
|
+
.update(wh.secret + '.' + bodyStr)
|
|
612
|
+
.digest('hex');
|
|
613
|
+
headers['X-AgentTrace-Signature'] = `sha256=${sig}`;
|
|
614
|
+
}
|
|
615
|
+
let deliveryStatus = 'failure';
|
|
616
|
+
let httpStatus;
|
|
617
|
+
let errorMsg;
|
|
618
|
+
try {
|
|
619
|
+
const resp = await fetch(wh.url, {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
headers,
|
|
622
|
+
body: bodyStr,
|
|
623
|
+
});
|
|
624
|
+
httpStatus = resp.ok ? resp.status : resp.status;
|
|
625
|
+
deliveryStatus = resp.ok ? 'success' : 'failure';
|
|
626
|
+
if (resp.ok) {
|
|
627
|
+
this.storage.resetWebhookFailures(wh.id);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
this.storage.incrementWebhookFailures(wh.id);
|
|
631
|
+
errorMsg = `webhook responded ${resp.status}`;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
this.storage.incrementWebhookFailures(wh.id);
|
|
636
|
+
errorMsg = e instanceof Error ? e.message : String(e);
|
|
637
|
+
}
|
|
638
|
+
const delivery = {
|
|
639
|
+
id: randomUUID(),
|
|
640
|
+
webhookId: wh.id,
|
|
641
|
+
event,
|
|
642
|
+
payload: bodyStr,
|
|
643
|
+
status: deliveryStatus,
|
|
644
|
+
httpStatus,
|
|
645
|
+
error: errorMsg,
|
|
646
|
+
createdAt: timestamp,
|
|
647
|
+
};
|
|
648
|
+
results.push(delivery);
|
|
649
|
+
}
|
|
650
|
+
return results;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Test a webhook by ID: fires a test event payload to the webhook URL.
|
|
654
|
+
* Returns delivery result.
|
|
655
|
+
*/
|
|
656
|
+
async testWebhook(id) {
|
|
657
|
+
const webhooks = this.getWebhooks();
|
|
658
|
+
const wh = webhooks.find((w) => w.id === id);
|
|
659
|
+
if (!wh) {
|
|
660
|
+
throw new Error(`Webhook '${id}' not found. List webhooks with: agenttrace-io webhook list`);
|
|
661
|
+
}
|
|
662
|
+
if (!wh.enabled) {
|
|
663
|
+
throw new Error(`Webhook '${id}' is disabled.`);
|
|
664
|
+
}
|
|
665
|
+
const payload = {
|
|
666
|
+
event: 'webhook.test',
|
|
667
|
+
timestamp: Date.now(),
|
|
668
|
+
message: 'AgentTrace webhook test',
|
|
669
|
+
};
|
|
670
|
+
try {
|
|
671
|
+
const resp = await fetch(wh.url, {
|
|
672
|
+
method: 'POST',
|
|
673
|
+
headers: {
|
|
674
|
+
'Content-Type': 'application/json',
|
|
675
|
+
'User-Agent': `AgentTrace/${VERSION}`,
|
|
676
|
+
},
|
|
677
|
+
body: JSON.stringify(payload),
|
|
678
|
+
});
|
|
679
|
+
if (resp.ok) {
|
|
680
|
+
this.storage.resetWebhookFailures(id);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
this.storage.incrementWebhookFailures(id);
|
|
684
|
+
}
|
|
685
|
+
return { ok: resp.ok, status: resp.status };
|
|
686
|
+
}
|
|
687
|
+
catch (e) {
|
|
688
|
+
this.storage.incrementWebhookFailures(id);
|
|
689
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Register a webhook event handler callback.
|
|
694
|
+
* The handler is called for every emitted webhook event (both explicit emissions
|
|
695
|
+
* and automatic events from trace()/completeRun()).
|
|
696
|
+
* Returns a function that unsubscribes the handler when called.
|
|
697
|
+
*/
|
|
698
|
+
onWebhook(handler) {
|
|
699
|
+
this.webhookEmitter.on('webhook', handler);
|
|
700
|
+
return () => {
|
|
701
|
+
this.webhookEmitter.off('webhook', handler);
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Explicitly emit a webhook event.
|
|
706
|
+
* Fires all registered HTTP webhooks (via triggerWebhook) and invokes all
|
|
707
|
+
* onWebhook handler callbacks. Does not auto-emit from trace()/completeRun() --
|
|
708
|
+
* those methods already call triggerWebhook directly.
|
|
709
|
+
* Use this for custom events (e.g. 'cost.threshold', 'agent.inactive').
|
|
710
|
+
* Returns the HTTP delivery results (same as triggerWebhook).
|
|
711
|
+
*/
|
|
712
|
+
async emitWebhookEvent(event, payload) {
|
|
713
|
+
// Fire HTTP webhooks
|
|
714
|
+
const results = await this.triggerWebhook(event, payload);
|
|
715
|
+
// Notify in-process handlers (fire-and-forget)
|
|
716
|
+
try {
|
|
717
|
+
this.webhookEmitter.emit('webhook', event, payload);
|
|
718
|
+
}
|
|
719
|
+
catch (_) {
|
|
720
|
+
/* handler errors must not break emission */
|
|
721
|
+
}
|
|
722
|
+
return results;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Trigger all webhooks registered for the given event.
|
|
726
|
+
* Alias for triggerWebhook — provided for API clarity when the intent is
|
|
727
|
+
* to fan-out to all matching webhooks rather than targeting a specific one.
|
|
728
|
+
* Also invokes any onWebhook handler callbacks after HTTP delivery.
|
|
729
|
+
*/
|
|
730
|
+
async triggerAllWebhooks(event, payload) {
|
|
731
|
+
const results = await this.triggerWebhook(event, payload);
|
|
732
|
+
// Notify in-process handlers (fire-and-forget)
|
|
733
|
+
try {
|
|
734
|
+
this.webhookEmitter.emit('webhook', event, payload);
|
|
735
|
+
}
|
|
736
|
+
catch (_) {
|
|
737
|
+
/* handler errors must not break emission */
|
|
738
|
+
}
|
|
739
|
+
return results;
|
|
740
|
+
}
|
|
741
|
+
// ---- Multi-agent tracing (v0.2) ----
|
|
742
|
+
/**
|
|
743
|
+
* Create a TraceContext for a child operation linked to the provided parent context.
|
|
744
|
+
* The child context has a freshly generated traceId (use as the child's trace id)
|
|
745
|
+
* and parentSpanId pointing to the parent's traceId (span).
|
|
746
|
+
* Pass the returned context via options.context when calling trace() (on any AgentTrace instance).
|
|
747
|
+
*/
|
|
748
|
+
createChild(context) {
|
|
749
|
+
if (!context || typeof context.traceId !== 'string' || context.traceId.length === 0) {
|
|
750
|
+
throw new Error('createChild requires a valid TraceContext with traceId');
|
|
751
|
+
}
|
|
752
|
+
const childTraceId = randomUUID();
|
|
753
|
+
return new TraceContext(childTraceId, context.traceId, { ...context.metadata });
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Manually link a set of trace IDs as related (for cross-agent collaboration without strict parent/child).
|
|
757
|
+
* Uses an internal links table; getTraceTree will surface linked traces as children in the tree.
|
|
758
|
+
*/
|
|
759
|
+
linkTraces(traceIds) {
|
|
760
|
+
if (!Array.isArray(traceIds) || traceIds.length < 2) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
this.storage.linkTraces(traceIds);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Get the full tree (parent -> children, including manually linked) for the given traceId.
|
|
767
|
+
* The tree is rooted at the ultimate ancestor (following parentId links).
|
|
768
|
+
*/
|
|
769
|
+
getTraceTree(traceId) {
|
|
770
|
+
return this.storage.getTraceTree(traceId);
|
|
771
|
+
}
|
|
772
|
+
// ---- Alerting (v0.2) ----
|
|
773
|
+
/**
|
|
774
|
+
* Register an alert condition. Persists config (without function) and enables auto-checks.
|
|
775
|
+
*/
|
|
776
|
+
registerAlert(alert) {
|
|
777
|
+
if (!alert || !alert.name || typeof alert.condition !== 'function') {
|
|
778
|
+
throw new Error('Invalid alert: must have name and condition function');
|
|
779
|
+
}
|
|
780
|
+
// dedupe by name (last wins)
|
|
781
|
+
this.registeredAlerts = this.registeredAlerts.filter((a) => a.name !== alert.name);
|
|
782
|
+
const copy = {
|
|
783
|
+
name: alert.name,
|
|
784
|
+
condition: alert.condition,
|
|
785
|
+
webhook: alert.webhook,
|
|
786
|
+
email: alert.email,
|
|
787
|
+
cooldown: alert.cooldown ?? 0,
|
|
788
|
+
lastTriggered: alert.lastTriggered,
|
|
789
|
+
};
|
|
790
|
+
this.registeredAlerts.push(copy);
|
|
791
|
+
// persist without the function
|
|
792
|
+
const { condition: _cond, ...serializable } = copy;
|
|
793
|
+
this.storage.saveAlert(copy.name, serializable);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Check all registered alerts against current stats.
|
|
797
|
+
* Fires (and records history) for those whose condition is true and cooldown elapsed.
|
|
798
|
+
* Returns the AlertHistory entries that were triggered this check.
|
|
799
|
+
*/
|
|
800
|
+
async checkAlerts() {
|
|
801
|
+
const results = [];
|
|
802
|
+
if (this.registeredAlerts.length === 0)
|
|
803
|
+
return results;
|
|
804
|
+
const stats = this.getStats();
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
for (const alert of this.registeredAlerts) {
|
|
807
|
+
const cooldownMs = Math.max(0, alert.cooldown || 0) * 1000;
|
|
808
|
+
const last = alert.lastTriggered || 0;
|
|
809
|
+
if (cooldownMs > 0 && now - last < cooldownMs) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const met = (() => {
|
|
813
|
+
try {
|
|
814
|
+
return !!alert.condition(stats);
|
|
815
|
+
}
|
|
816
|
+
catch (condErr) {
|
|
817
|
+
if (!this.config.silent) {
|
|
818
|
+
console.error(`[AgentTrace] Alert condition error for ${alert.name}:`, condErr);
|
|
819
|
+
}
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
})();
|
|
823
|
+
if (met) {
|
|
824
|
+
const hist = await this.deliverAlert(alert, stats, now);
|
|
825
|
+
results.push(hist);
|
|
826
|
+
alert.lastTriggered = now;
|
|
827
|
+
const { condition: _c, ...toStore } = alert;
|
|
828
|
+
this.storage.saveAlert(alert.name, toStore);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return results;
|
|
832
|
+
}
|
|
833
|
+
async deliverAlert(alert, stats, now) {
|
|
834
|
+
const numericStats = {
|
|
835
|
+
totalRuns: stats.totalRuns || 0,
|
|
836
|
+
totalTraces: stats.totalTraces || 0,
|
|
837
|
+
successRate: stats.successRate || 0,
|
|
838
|
+
avgLatencyMs: stats.avgLatencyMs || 0,
|
|
839
|
+
totalCostUsd: stats.totalCostUsd || 0,
|
|
840
|
+
totalTokens: stats.totalTokens || 0,
|
|
841
|
+
avgTokensPerTrace: stats.avgTokensPerTrace || 0,
|
|
842
|
+
};
|
|
843
|
+
let delivered;
|
|
844
|
+
let errMsg;
|
|
845
|
+
const payload = { alertName: alert.name, stats, timestamp: now };
|
|
846
|
+
if (alert.webhook) {
|
|
847
|
+
try {
|
|
848
|
+
const url = new URL(alert.webhook);
|
|
849
|
+
const host = url.hostname;
|
|
850
|
+
const bareHost = host.replace(/^\[|\]$/g, '');
|
|
851
|
+
const isLoopback = host === 'localhost' ||
|
|
852
|
+
bareHost === 'localhost' ||
|
|
853
|
+
bareHost === '127.0.0.1' ||
|
|
854
|
+
host.startsWith('127.') ||
|
|
855
|
+
bareHost === '::1';
|
|
856
|
+
if (url.protocol !== 'https:' && !isLoopback) {
|
|
857
|
+
throw new Error('webhook must use HTTPS');
|
|
858
|
+
}
|
|
859
|
+
let is172Private = false;
|
|
860
|
+
if (bareHost.startsWith('172.')) {
|
|
861
|
+
const second = parseInt(bareHost.split('.')[1] || '0', 10);
|
|
862
|
+
is172Private = second >= 16 && second <= 31;
|
|
863
|
+
}
|
|
864
|
+
const isPrivate = bareHost.startsWith('10.') ||
|
|
865
|
+
bareHost.startsWith('192.168.') ||
|
|
866
|
+
bareHost.startsWith('169.254.') ||
|
|
867
|
+
is172Private ||
|
|
868
|
+
bareHost.startsWith('fc00:') ||
|
|
869
|
+
bareHost.startsWith('fe80:');
|
|
870
|
+
if (isPrivate) {
|
|
871
|
+
throw new Error('webhook URL resolves to private IP');
|
|
872
|
+
}
|
|
873
|
+
const webhooks = this.storage.getWebhooks ? this.storage.getWebhooks() : [];
|
|
874
|
+
const wh = webhooks.find((w) => w.url === alert.webhook);
|
|
875
|
+
const secret = wh?.secret;
|
|
876
|
+
const headers = {
|
|
877
|
+
'Content-Type': 'application/json',
|
|
878
|
+
'User-Agent': `AgentTrace/${VERSION}`,
|
|
879
|
+
};
|
|
880
|
+
if (secret) {
|
|
881
|
+
const sig = createHmac('sha256', secret).update(JSON.stringify(payload)).digest('hex');
|
|
882
|
+
headers['X-AgentTrace-Signature'] = `sha256=${sig}`;
|
|
883
|
+
}
|
|
884
|
+
const resp = await fetch(alert.webhook, {
|
|
885
|
+
method: 'POST',
|
|
886
|
+
headers,
|
|
887
|
+
body: JSON.stringify(payload),
|
|
888
|
+
signal: AbortSignal.timeout(10000),
|
|
889
|
+
});
|
|
890
|
+
delivered = resp.ok;
|
|
891
|
+
if (!resp.ok) {
|
|
892
|
+
errMsg = `webhook responded ${resp.status}`;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
delivered = false;
|
|
897
|
+
errMsg = e?.message || String(e);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
else if (alert.email) {
|
|
901
|
+
delivered = false;
|
|
902
|
+
errMsg = 'email delivery not supported in this version';
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
delivered = false;
|
|
906
|
+
errMsg = 'no delivery channel configured';
|
|
907
|
+
}
|
|
908
|
+
const history = {
|
|
909
|
+
id: randomUUID(),
|
|
910
|
+
alertName: alert.name,
|
|
911
|
+
triggeredAt: now,
|
|
912
|
+
stats: numericStats,
|
|
913
|
+
delivered,
|
|
914
|
+
...(errMsg ? { error: errMsg } : {}),
|
|
915
|
+
};
|
|
916
|
+
this.storage.insertAlertHistory(history);
|
|
917
|
+
if (!this.config.silent) {
|
|
918
|
+
const status = delivered ? 'delivered' : `delivery failed${errMsg ? ': ' + errMsg : ''}`;
|
|
919
|
+
console.log(`[AgentTrace] Alert '${alert.name}' triggered. ${status}`);
|
|
920
|
+
}
|
|
921
|
+
return history;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Get currently registered (in-memory) alerts. Falls back to persisted configs (with no-op condition) for CLI.
|
|
925
|
+
*/
|
|
926
|
+
getAlerts() {
|
|
927
|
+
const map = new Map();
|
|
928
|
+
// load persisted (dummy condition)
|
|
929
|
+
for (const s of this.storage.getStoredAlerts()) {
|
|
930
|
+
const cfg = s.config || {};
|
|
931
|
+
map.set(s.name, {
|
|
932
|
+
name: s.name,
|
|
933
|
+
condition: () => false,
|
|
934
|
+
webhook: cfg.webhook,
|
|
935
|
+
email: cfg.email,
|
|
936
|
+
cooldown: typeof cfg.cooldown === 'number' ? cfg.cooldown : 0,
|
|
937
|
+
lastTriggered: cfg.lastTriggered,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
// overlay runtime registered (have real conditions)
|
|
941
|
+
for (const r of this.registeredAlerts) {
|
|
942
|
+
map.set(r.name, { ...r });
|
|
943
|
+
}
|
|
944
|
+
return Array.from(map.values());
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Get alert firing history from storage.
|
|
948
|
+
*/
|
|
949
|
+
getAlertHistory() {
|
|
950
|
+
return this.storage.getAlertHistory();
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Return health report: status, version (sdk), uptime, dbPath, traceCount, dbSize + integrity check.
|
|
954
|
+
* Integrity verifies required tables exist and detects orphaned child records.
|
|
955
|
+
*/
|
|
956
|
+
getHealth() {
|
|
957
|
+
const h = this.storage.getHealthInfo();
|
|
958
|
+
return {
|
|
959
|
+
status: 'ok',
|
|
960
|
+
version: VERSION,
|
|
961
|
+
uptime: process.uptime(),
|
|
962
|
+
dbPath: h.dbPath,
|
|
963
|
+
traceCount: h.traceCount,
|
|
964
|
+
dbSize: h.dbSize,
|
|
965
|
+
integrity: h.integrity,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
// ---- Retention / data lifecycle ----
|
|
969
|
+
/**
|
|
970
|
+
* Delete traces with created_at < before (timestamp ms). Also cleans dependent scores/links.
|
|
971
|
+
* Returns number of traces deleted.
|
|
972
|
+
*/
|
|
973
|
+
cleanupOldTraces(before) {
|
|
974
|
+
return this.storage.cleanupOldTraces(before);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Delete runs with started_at < before (timestamp ms). Cascades to their traces.
|
|
978
|
+
* Returns number of runs deleted.
|
|
979
|
+
*/
|
|
980
|
+
cleanupOldRuns(before) {
|
|
981
|
+
return this.storage.cleanupOldRuns(before);
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Delete agent_usage records with created_at < before (timestamp ms).
|
|
985
|
+
* Returns number deleted.
|
|
986
|
+
*/
|
|
987
|
+
cleanupOldAgentUsage(before) {
|
|
988
|
+
return this.storage.cleanupOldAgentUsage(before);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Return basic storage stats for the backing DB.
|
|
992
|
+
*/
|
|
993
|
+
getStorageStats() {
|
|
994
|
+
return this.storage.getStorageStats();
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Get the active retention policy (from this instance config, which may come from persisted defaults).
|
|
998
|
+
*/
|
|
999
|
+
getRetentionPolicy() {
|
|
1000
|
+
return {
|
|
1001
|
+
retentionDays: this.config.retentionDays,
|
|
1002
|
+
cleanupIntervalHours: this.config.cleanupIntervalHours,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Set retention policy for this DB (persists it) and update live config + reschedule timer if needed.
|
|
1007
|
+
*/
|
|
1008
|
+
setRetentionPolicy(retentionDays, cleanupIntervalHours) {
|
|
1009
|
+
this.storage.setRetentionPolicy(retentionDays, cleanupIntervalHours);
|
|
1010
|
+
this.config.retentionDays = Math.max(0, Math.floor(Number(retentionDays) || 0));
|
|
1011
|
+
if (cleanupIntervalHours !== undefined) {
|
|
1012
|
+
this.config.cleanupIntervalHours = Math.max(1, Math.floor(Number(cleanupIntervalHours) || 24));
|
|
1013
|
+
}
|
|
1014
|
+
this.setupRetentionCleanup();
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Export traces to JSON, CSV, or OpenTelemetry (OTLP JSON)
|
|
1018
|
+
*/
|
|
1019
|
+
export(format = 'json', filter = {}) {
|
|
1020
|
+
const traces = this.storage.getTraces(filter);
|
|
1021
|
+
if (format === 'json') {
|
|
1022
|
+
return JSON.stringify(traces, null, 2);
|
|
1023
|
+
}
|
|
1024
|
+
if (format === 'otel') {
|
|
1025
|
+
const resourceAttributes = buildResourceAttributes();
|
|
1026
|
+
const spans = traces.map((t) => traceToOtelSpan(t));
|
|
1027
|
+
const otlp = {
|
|
1028
|
+
resourceSpans: [
|
|
1029
|
+
{
|
|
1030
|
+
resource: { attributes: resourceAttributes },
|
|
1031
|
+
scopeSpans: [
|
|
1032
|
+
{
|
|
1033
|
+
scope: { name: 'agenttrace' },
|
|
1034
|
+
spans,
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
1037
|
+
},
|
|
1038
|
+
],
|
|
1039
|
+
};
|
|
1040
|
+
return JSON.stringify(otlp, null, 2);
|
|
1041
|
+
}
|
|
1042
|
+
// CSV
|
|
1043
|
+
const headers = [
|
|
1044
|
+
'id',
|
|
1045
|
+
'runId',
|
|
1046
|
+
'name',
|
|
1047
|
+
'status',
|
|
1048
|
+
'latencyMs',
|
|
1049
|
+
'costUsd',
|
|
1050
|
+
'totalTokens',
|
|
1051
|
+
'createdAt',
|
|
1052
|
+
];
|
|
1053
|
+
const rows = traces.map((t) => [
|
|
1054
|
+
t.id,
|
|
1055
|
+
t.runId,
|
|
1056
|
+
t.name,
|
|
1057
|
+
t.status,
|
|
1058
|
+
t.latencyMs,
|
|
1059
|
+
t.costUsd,
|
|
1060
|
+
t.tokens.totalTokens,
|
|
1061
|
+
t.createdAt,
|
|
1062
|
+
]);
|
|
1063
|
+
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Close the database connection
|
|
1067
|
+
*/
|
|
1068
|
+
close() {
|
|
1069
|
+
if (this._cleanupInterval) {
|
|
1070
|
+
clearInterval(this._cleanupInterval);
|
|
1071
|
+
this._cleanupInterval = undefined;
|
|
1072
|
+
}
|
|
1073
|
+
this.storage.close();
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Evaluate traces using the provided scorers.
|
|
1077
|
+
* If traceIds provided, scores only those; if runId, scores traces in that run; otherwise all traces.
|
|
1078
|
+
*/
|
|
1079
|
+
async evaluate(options) {
|
|
1080
|
+
const { scorers, runId, traceIds, concurrency } = options;
|
|
1081
|
+
if (!scorers || scorers.length === 0) {
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1084
|
+
const traces = traceIds && traceIds.length > 0
|
|
1085
|
+
? traceIds.map((id) => this.getTrace(id)).filter((t) => t != null)
|
|
1086
|
+
: runId
|
|
1087
|
+
? this.getTraces({ runId })
|
|
1088
|
+
: this.getTraces();
|
|
1089
|
+
return this.scoreLoop(traces, scorers, concurrency);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Score a single trace by id.
|
|
1093
|
+
*/
|
|
1094
|
+
async evaluateTrace(traceId, scorers) {
|
|
1095
|
+
const trace = this.getTrace(traceId);
|
|
1096
|
+
if (!trace) {
|
|
1097
|
+
return { traceId, scores: {}, errors: {} };
|
|
1098
|
+
}
|
|
1099
|
+
return this.scoreTrace(trace, scorers);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Internal helper to iterate traces with limited concurrency, run scorers, store scores.
|
|
1103
|
+
*/
|
|
1104
|
+
async scoreLoop(traces, scorers, concurrency) {
|
|
1105
|
+
if (traces.length === 0)
|
|
1106
|
+
return [];
|
|
1107
|
+
const limit = Math.max(1, concurrency ?? 5);
|
|
1108
|
+
const results = [];
|
|
1109
|
+
for (let i = 0; i < traces.length; i += limit) {
|
|
1110
|
+
const chunk = traces.slice(i, i + limit);
|
|
1111
|
+
const chunkResults = await Promise.all(chunk.map((t) => this.scoreTrace(t, scorers)));
|
|
1112
|
+
results.push(...chunkResults);
|
|
1113
|
+
}
|
|
1114
|
+
return results;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Internal: run all scorers on one trace (in parallel), catch errors, store successful scores.
|
|
1118
|
+
*/
|
|
1119
|
+
async scoreTrace(trace, scorers) {
|
|
1120
|
+
const traceId = trace.id;
|
|
1121
|
+
const scores = {};
|
|
1122
|
+
const errors = {};
|
|
1123
|
+
await Promise.all(scorers.map(async (scorer) => {
|
|
1124
|
+
try {
|
|
1125
|
+
const val = await Promise.resolve(scorer.fn(trace));
|
|
1126
|
+
if (typeof val === 'number' && Number.isFinite(val)) {
|
|
1127
|
+
scores[scorer.name] = val;
|
|
1128
|
+
const id = randomUUID();
|
|
1129
|
+
this.storage.createScore(id, traceId, scorer.name, val);
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
errors[scorer.name] = `Invalid score returned: ${val}`;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
catch (e) {
|
|
1136
|
+
errors[scorer.name] = e instanceof Error ? e.message : String(e);
|
|
1137
|
+
}
|
|
1138
|
+
}));
|
|
1139
|
+
return { traceId, scores, errors };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Singleton for convenience
|
|
1143
|
+
let instance = null;
|
|
1144
|
+
export function init(config) {
|
|
1145
|
+
instance = new AgentTrace(config);
|
|
1146
|
+
return instance;
|
|
1147
|
+
}
|
|
1148
|
+
export function getAgentTrace() {
|
|
1149
|
+
if (!instance) {
|
|
1150
|
+
instance = new AgentTrace();
|
|
1151
|
+
}
|
|
1152
|
+
return instance;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Helper to create a Scorer from name + function.
|
|
1156
|
+
*/
|
|
1157
|
+
export function score(name, fn) {
|
|
1158
|
+
return { name, fn };
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Helper to create an AlertCondition (omits lastTriggered which is internal).
|
|
1162
|
+
*/
|
|
1163
|
+
export function alert(config) {
|
|
1164
|
+
return {
|
|
1165
|
+
...config,
|
|
1166
|
+
lastTriggered: undefined,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
//# sourceMappingURL=index.js.map
|