@lynxops/sdk 1.0.2

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/index.js ADDED
@@ -0,0 +1,1339 @@
1
+ // src/core/tracer.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
3
+
4
+ // src/utils/payload.ts
5
+ import { createHash } from "crypto";
6
+
7
+ // src/config/patterns.json
8
+ var patterns_default = {
9
+ riskPatterns: [
10
+ { pattern: "ignore", flags: "i" },
11
+ { pattern: "transfer", flags: "i" },
12
+ { pattern: "payment", flags: "i" },
13
+ { pattern: "delete", flags: "i" },
14
+ { pattern: "system prompt", flags: "i" },
15
+ { pattern: "bypass", flags: "i" },
16
+ { pattern: "shutdown", flags: "i" },
17
+ { pattern: "sudo", flags: "i" },
18
+ { pattern: "admin", flags: "i" },
19
+ { pattern: "leak", flags: "i" }
20
+ ],
21
+ piiRules: [
22
+ {
23
+ name: "EMAIL",
24
+ pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
25
+ flags: "g",
26
+ replacement: "[MASKED_EMAIL]"
27
+ },
28
+ {
29
+ name: "SSN",
30
+ pattern: "\\d{6}-[1-9]\\d{6}",
31
+ flags: "g",
32
+ replacement: "[MASKED_SSN]"
33
+ },
34
+ {
35
+ name: "CREDIT_CARD",
36
+ pattern: "\\b(?:\\d[ -]*?){13,16}\\b",
37
+ flags: "g",
38
+ replacement: "[MASKED_CARD]"
39
+ },
40
+ {
41
+ name: "PHONE",
42
+ pattern: "(?:01[016789]-\\d{3,4}-\\d{4})|(?:\\+82-\\d{1,2}-\\d{3,4}-\\d{4})",
43
+ flags: "g",
44
+ replacement: "[MASKED_PHONE]"
45
+ },
46
+ {
47
+ name: "JWT",
48
+ pattern: "ey[hH]bGciOi[A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*",
49
+ flags: "g",
50
+ replacement: "[MASKED_JWT]"
51
+ },
52
+ {
53
+ name: "API_KEY",
54
+ pattern: "(?:sk-[a-zA-Z0-9]{32,})|(?:Bearer\\s+[a-zA-Z0-9-_.]+)",
55
+ flags: "gi",
56
+ replacement: "[MASKED_KEY]"
57
+ }
58
+ ]
59
+ };
60
+
61
+ // src/utils/payload.ts
62
+ var RISK_PATTERNS = patterns_default.riskPatterns.map(
63
+ (item) => new RegExp(item.pattern, item.flags)
64
+ );
65
+ function getHash(data) {
66
+ const str = typeof data === "string" ? data : JSON.stringify(data);
67
+ return createHash("sha256").update(str || "").digest("hex").slice(0, 16);
68
+ }
69
+ function extractRiskFlags(text) {
70
+ const flags = [];
71
+ for (const pattern of RISK_PATTERNS) {
72
+ if (pattern.test(text)) {
73
+ const cleanPattern = pattern.source.replace(/\\/g, "");
74
+ flags.push(cleanPattern);
75
+ }
76
+ }
77
+ return flags;
78
+ }
79
+ function headTailTruncate(text, maxLen = 1e3) {
80
+ if (text.length <= maxLen) return text;
81
+ const reserve = 80;
82
+ const half = Math.floor((maxLen - reserve) / 2);
83
+ if (half <= 0) return "... [TRUNCATED] ...";
84
+ const head = text.substring(0, half);
85
+ const tail = text.substring(text.length - half);
86
+ const truncatedCount = text.length - (head.length + tail.length);
87
+ return `${head}... [TRUNCATED ${truncatedCount} CHARS] ...${tail}`;
88
+ }
89
+ var PII_RULES = patterns_default.piiRules.map((rule) => ({
90
+ name: rule.name,
91
+ regex: new RegExp(rule.pattern, rule.flags),
92
+ replacement: rule.replacement
93
+ }));
94
+ var SENSITIVE_KEY_PATTERN = /(?:api[-_]?key|authorization|auth[-_]?token|bearer|client[-_]?secret|cookie|credential|jwt|password|private[-_]?key|refresh[-_]?token|secret|session[-_]?token|token)/i;
95
+ function maskPIIString(text) {
96
+ let masked = text;
97
+ for (const rule of PII_RULES) {
98
+ masked = masked.replace(rule.regex, rule.replacement);
99
+ }
100
+ return masked;
101
+ }
102
+ function recursiveMaskPII(obj) {
103
+ if (obj === null || obj === void 0) return obj;
104
+ if (typeof obj === "string") {
105
+ return maskPIIString(obj);
106
+ }
107
+ if (Array.isArray(obj)) {
108
+ return obj.map((item) => recursiveMaskPII(item));
109
+ }
110
+ if (typeof obj === "object") {
111
+ const res = {};
112
+ for (const key of Object.keys(obj)) {
113
+ if (SENSITIVE_KEY_PATTERN.test(key)) {
114
+ res[key] = "[MASKED_SECRET]";
115
+ } else {
116
+ res[key] = recursiveMaskPII(obj[key]);
117
+ }
118
+ }
119
+ return res;
120
+ }
121
+ return obj;
122
+ }
123
+ function processPayload(payload, config) {
124
+ if (!payload || typeof payload !== "object") return payload;
125
+ let copy;
126
+ try {
127
+ copy = JSON.parse(JSON.stringify(payload));
128
+ } catch {
129
+ return { error: "[Unserializable Payload]" };
130
+ }
131
+ copy = recursiveMaskPII(copy);
132
+ if (config.captureInput === false && "input" in copy) {
133
+ delete copy.input;
134
+ }
135
+ if (config.captureOutput === false && "output" in copy) {
136
+ delete copy.output;
137
+ }
138
+ const mode = config.captureMode || "smart";
139
+ let textToAnalyze = "";
140
+ if (copy.input) {
141
+ textToAnalyze += typeof copy.input === "string" ? copy.input : JSON.stringify(copy.input);
142
+ }
143
+ if (copy.output) {
144
+ textToAnalyze += typeof copy.output === "string" ? copy.output : JSON.stringify(copy.output);
145
+ }
146
+ const riskFlags = textToAnalyze ? extractRiskFlags(textToAnalyze) : [];
147
+ if (riskFlags.length > 0) {
148
+ copy.riskFlags = riskFlags;
149
+ }
150
+ if (mode === "metadata-only") {
151
+ if ("input" in copy) {
152
+ const inputStr = typeof copy.input === "string" ? copy.input : JSON.stringify(copy.input);
153
+ copy.promptHash = getHash(copy.input);
154
+ copy.promptLength = inputStr.length;
155
+ delete copy.input;
156
+ }
157
+ if ("output" in copy) {
158
+ const outputStr = typeof copy.output === "string" ? copy.output : JSON.stringify(copy.output);
159
+ copy.responseHash = getHash(copy.output);
160
+ copy.responseLength = outputStr.length;
161
+ delete copy.output;
162
+ }
163
+ return copy;
164
+ }
165
+ if (mode === "smart") {
166
+ const maxLen = config.maxPayloadLength ?? 1e3;
167
+ const walkAndSmartTruncate = (obj) => {
168
+ if (!obj || typeof obj !== "object") return obj;
169
+ if (Array.isArray(obj)) {
170
+ return obj.map((item) => walkAndSmartTruncate(item));
171
+ }
172
+ const res = {};
173
+ for (const key of Object.keys(obj)) {
174
+ const priorityKeys = [
175
+ "spanid",
176
+ "parentspanid",
177
+ "latency",
178
+ "usage",
179
+ "cost",
180
+ "error",
181
+ "path",
182
+ "riskflags"
183
+ ];
184
+ if (priorityKeys.includes(key.toLowerCase())) {
185
+ res[key] = obj[key];
186
+ } else if (typeof obj[key] === "object") {
187
+ res[key] = walkAndSmartTruncate(obj[key]);
188
+ } else if (typeof obj[key] === "string") {
189
+ res[key] = headTailTruncate(obj[key], maxLen);
190
+ } else {
191
+ res[key] = obj[key];
192
+ }
193
+ }
194
+ return res;
195
+ };
196
+ return walkAndSmartTruncate(copy);
197
+ }
198
+ return copy;
199
+ }
200
+
201
+ // src/utils/id.ts
202
+ import { randomBytes, randomUUID } from "crypto";
203
+ function generateTraceId() {
204
+ try {
205
+ return randomBytes(16).toString("hex");
206
+ } catch {
207
+ return randomUUID().replace(/-/g, "").slice(0, 32);
208
+ }
209
+ }
210
+ function generateSpanId() {
211
+ try {
212
+ return randomBytes(8).toString("hex");
213
+ } catch {
214
+ return randomUUID().replace(/-/g, "").slice(0, 16);
215
+ }
216
+ }
217
+ function generateEventId() {
218
+ try {
219
+ return randomBytes(16).toString("hex");
220
+ } catch {
221
+ return randomUUID().replace(/-/g, "").slice(0, 32);
222
+ }
223
+ }
224
+
225
+ // src/utils/usage.ts
226
+ function extractTokenUsage(result) {
227
+ if (!result || typeof result !== "object") return void 0;
228
+ const usage = result.usage ?? result.llmOutput?.tokenUsage ?? result.generationInfo?.tokenUsage ?? result.response_metadata?.tokenUsage ?? result.response_metadata?.usage;
229
+ if (usage) {
230
+ const promptTokens = usage.prompt_tokens ?? usage.input_tokens ?? usage.promptTokens ?? usage.inputTokens ?? 0;
231
+ const completionTokens = usage.completion_tokens ?? usage.output_tokens ?? usage.completionTokens ?? usage.outputTokens ?? 0;
232
+ return {
233
+ promptTokens,
234
+ completionTokens,
235
+ totalTokens: promptTokens + completionTokens
236
+ };
237
+ }
238
+ const usageMetadata = result.usageMetadata;
239
+ if (usageMetadata) {
240
+ const promptTokens = usageMetadata.promptTokenCount ?? 0;
241
+ const completionTokens = usageMetadata.candidatesTokenCount ?? usageMetadata.completionTokenCount ?? 0;
242
+ return {
243
+ promptTokens,
244
+ completionTokens,
245
+ totalTokens: promptTokens + completionTokens
246
+ };
247
+ }
248
+ return void 0;
249
+ }
250
+
251
+ // src/instrumentation/index.ts
252
+ function stableHash(data) {
253
+ const str = typeof data === "string" ? data : JSON.stringify(data ?? "");
254
+ let hash = 0;
255
+ for (let i = 0; i < str.length; i++) {
256
+ hash = hash * 31 + str.charCodeAt(i) >>> 0;
257
+ }
258
+ return hash.toString(16).padStart(8, "0");
259
+ }
260
+ function getProvider(path, model) {
261
+ const pathStr = path.join(".");
262
+ if (pathStr.includes("messages.create")) return "anthropic";
263
+ if (pathStr.includes("generateContent")) return "google";
264
+ if (pathStr.includes("generateText") || pathStr.includes("streamText")) return "vercel-ai";
265
+ if (pathStr.includes("ollama")) return "ollama";
266
+ if (pathStr.includes("cohere")) return "cohere";
267
+ if (model?.toLowerCase().includes("claude")) return "anthropic";
268
+ if (model?.toLowerCase().includes("gemini")) return "google";
269
+ return "openai";
270
+ }
271
+ function getMessages(input) {
272
+ if (Array.isArray(input?.messages)) return input.messages;
273
+ if (Array.isArray(input?.contents)) return input.contents;
274
+ if (Array.isArray(input?.prompt)) return input.prompt;
275
+ return void 0;
276
+ }
277
+ function getTextLength(data) {
278
+ if (data === void 0 || data === null) return 0;
279
+ if (typeof data === "string") return data.length;
280
+ try {
281
+ return JSON.stringify(data).length;
282
+ } catch {
283
+ return 0;
284
+ }
285
+ }
286
+ function extractLlmMetadata(input, path) {
287
+ const messages = getMessages(input);
288
+ const systemMessage = messages?.find((item) => item?.role === "system");
289
+ const userMessage = [...messages ?? []].reverse().find((item) => item?.role === "user");
290
+ const model = input?.model ?? input?.modelName;
291
+ const maxTokens = input?.max_tokens ?? input?.maxTokens ?? input?.maxOutputTokens;
292
+ return {
293
+ provider: getProvider(path, model),
294
+ model,
295
+ temperature: input?.temperature,
296
+ topP: input?.top_p ?? input?.topP,
297
+ maxTokens,
298
+ seed: input?.seed,
299
+ promptVersion: input?.promptVersion,
300
+ systemPromptHash: systemMessage ? stableHash(systemMessage) : void 0,
301
+ userPromptHash: userMessage ? stableHash(userMessage) : void 0,
302
+ messageCount: messages?.length,
303
+ contextLength: getTextLength(input)
304
+ };
305
+ }
306
+ function summarizeResult(result) {
307
+ if (result === void 0 || result === null) return void 0;
308
+ if (typeof result === "string") return result.slice(0, 240);
309
+ if (typeof result === "number" || typeof result === "boolean") return String(result);
310
+ try {
311
+ return JSON.stringify(result).slice(0, 240);
312
+ } catch {
313
+ return "[Unserializable Result]";
314
+ }
315
+ }
316
+ function instrumentLLM(tracer, instance, optionsOrLabel = "llm.inference") {
317
+ const options = typeof optionsOrLabel === "string" ? { modelLabel: optionsOrLabel } : optionsOrLabel;
318
+ const modelLabel = options.modelLabel ?? "llm.inference";
319
+ const customRule = options.customRule;
320
+ const isTargetLLMMethod = (path) => {
321
+ if (customRule?.isTargetMethod) {
322
+ return customRule.isTargetMethod(path);
323
+ }
324
+ const pathStr = path.join(".");
325
+ return pathStr === "chat.completions.create" || // OpenAI
326
+ pathStr === "messages.create" || // Anthropic (Claude)
327
+ pathStr === "models.generateContent" || // Google Gen AI 신규
328
+ pathStr === "generateContent" || // Google Generative AI 기존
329
+ pathStr === "generateText" || // Vercel AI SDK
330
+ pathStr === "streamText" || // Vercel AI SDK
331
+ pathStr === "generateObject" || // Vercel AI SDK
332
+ pathStr === "streamObject" || // Vercel AI SDK
333
+ pathStr === "ollama.chat" || // Ollama Chat
334
+ pathStr === "ollama.generate" || // Ollama Generate
335
+ pathStr === "cohere.chat" || // Cohere Chat
336
+ pathStr === "cohere.generate" || // Cohere Generate
337
+ pathStr === "chat" || // Ollama/Cohere direct
338
+ pathStr === "invoke" || // LangChain
339
+ pathStr === "predict";
340
+ };
341
+ const createRecursiveProxy = (target, path = []) => {
342
+ if (target == null || typeof target !== "object" && typeof target !== "function") {
343
+ return target;
344
+ }
345
+ return new Proxy(target, {
346
+ get(obj, prop) {
347
+ if (typeof prop === "symbol") {
348
+ return Reflect.get(obj, prop);
349
+ }
350
+ const val = obj[prop];
351
+ const newPath = [...path, prop];
352
+ if (typeof val === "function" && isTargetLLMMethod(newPath)) {
353
+ return async function(...args) {
354
+ const context = LynxTracer.getStore();
355
+ const spanId = generateSpanId();
356
+ const parentSpanId = context?.spanId;
357
+ const startTime = Date.now();
358
+ const rawInput = customRule?.extractInput ? customRule.extractInput(args) : args[0];
359
+ const llmMetadata = extractLlmMetadata(rawInput, newPath);
360
+ if (context) {
361
+ tracer.captureInternal("LLM_CALL", modelLabel, {
362
+ input: rawInput,
363
+ path: newPath.join("."),
364
+ phase: "start",
365
+ spanId,
366
+ parentSpanId,
367
+ ...llmMetadata
368
+ }, context);
369
+ }
370
+ try {
371
+ const result = Reflect.apply(val, obj, args);
372
+ const resolvedResult = result instanceof Promise ? await result : result;
373
+ const latency = Date.now() - startTime;
374
+ const rawOutput = customRule?.extractOutput ? customRule.extractOutput(resolvedResult) : resolvedResult;
375
+ const usage = customRule?.extractUsage ? customRule.extractUsage(resolvedResult) : extractTokenUsage(resolvedResult);
376
+ if (context) {
377
+ tracer.captureInternal("LLM_CALL", modelLabel, {
378
+ output: rawOutput,
379
+ phase: "end",
380
+ spanId,
381
+ parentSpanId,
382
+ latency,
383
+ usage,
384
+ ...llmMetadata
385
+ }, context);
386
+ }
387
+ return resolvedResult;
388
+ } catch (err) {
389
+ const error = err;
390
+ const latency = Date.now() - startTime;
391
+ if (context) {
392
+ tracer.captureInternal("ERROR", modelLabel, {
393
+ error: error.message,
394
+ phase: "error",
395
+ spanId,
396
+ parentSpanId,
397
+ latency
398
+ }, context);
399
+ }
400
+ throw err;
401
+ }
402
+ };
403
+ }
404
+ if (val && (typeof val === "object" || typeof val === "function")) {
405
+ return createRecursiveProxy(val, newPath);
406
+ }
407
+ return val;
408
+ }
409
+ });
410
+ };
411
+ return createRecursiveProxy(instance);
412
+ }
413
+ function instrumentTool(tracer, toolName, fn, metadata = {}) {
414
+ return (async (...args) => {
415
+ const context = LynxTracer.getStore();
416
+ const spanId = generateSpanId();
417
+ const parentSpanId = context?.spanId;
418
+ const startTime = Date.now();
419
+ if (context) {
420
+ tracer.captureInternal("TOOL_CALL", toolName, {
421
+ toolName,
422
+ toolVersion: metadata.toolVersion,
423
+ sideEffect: metadata.sideEffect,
424
+ riskLevel: metadata.riskLevel,
425
+ externalTarget: metadata.externalTarget,
426
+ idempotencyKey: metadata.idempotencyKey,
427
+ args: args[0],
428
+ argsHash: stableHash(args[0]),
429
+ input: args[0],
430
+ phase: "start",
431
+ spanId,
432
+ parentSpanId
433
+ }, context);
434
+ }
435
+ try {
436
+ const result = fn(...args);
437
+ const resolvedResult = result instanceof Promise ? await result : result;
438
+ const latency = Date.now() - startTime;
439
+ if (context) {
440
+ tracer.captureInternal("TOOL_RESULT", toolName, {
441
+ toolName,
442
+ toolVersion: metadata.toolVersion,
443
+ result: resolvedResult,
444
+ resultSummary: summarizeResult(resolvedResult),
445
+ output: resolvedResult,
446
+ phase: "end",
447
+ spanId,
448
+ parentSpanId,
449
+ latency
450
+ }, context);
451
+ }
452
+ return resolvedResult;
453
+ } catch (err) {
454
+ const error = err;
455
+ const latency = Date.now() - startTime;
456
+ if (context) {
457
+ tracer.captureInternal("ERROR", toolName, {
458
+ error: error.message,
459
+ phase: "error",
460
+ spanId,
461
+ parentSpanId,
462
+ latency
463
+ }, context);
464
+ }
465
+ throw err;
466
+ }
467
+ });
468
+ }
469
+
470
+ // src/core/tracer.ts
471
+ var SDK_VERSION = "1.0.0";
472
+ var DEFAULT_ENDPOINT = "https://api.lynxops.co";
473
+ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
474
+ var DEFAULT_BATCH_SIZE = 50;
475
+ var LynxPolicyError = class extends Error {
476
+ constructor(message, action, policyId, severity, reason, metadata) {
477
+ super(message);
478
+ this.action = action;
479
+ this.policyId = policyId;
480
+ this.severity = severity;
481
+ this.reason = reason;
482
+ this.metadata = metadata;
483
+ this.name = "LynxPolicyError";
484
+ }
485
+ action;
486
+ policyId;
487
+ severity;
488
+ reason;
489
+ metadata;
490
+ };
491
+ function stableHash2(data) {
492
+ const str = typeof data === "string" ? data : JSON.stringify(data ?? "");
493
+ let hash = 0;
494
+ for (let i = 0; i < str.length; i++) {
495
+ hash = hash * 31 + str.charCodeAt(i) >>> 0;
496
+ }
497
+ return hash.toString(16).padStart(8, "0");
498
+ }
499
+ var LynxTracer = class _LynxTracer {
500
+ static storage = new AsyncLocalStorage();
501
+ static instances = /* @__PURE__ */ new Set();
502
+ static beforeExitHookRegistered = false;
503
+ config;
504
+ pendingPromises = /* @__PURE__ */ new Set();
505
+ eventQueue = [];
506
+ retryQueue = [];
507
+ lastFailureTime = 0;
508
+ backoffDelayMs = 1e3;
509
+ flushTimer = null;
510
+ isShuttingDown = false;
511
+ isFlushInProgress = false;
512
+ consecutiveFlushFailures = 0;
513
+ circuitOpenedAt = 0;
514
+ droppedEventCount = 0;
515
+ lastDeliveryAt;
516
+ lastError;
517
+ /**
518
+ * Creates a tracer with the provided Lynx telemetry configuration.
519
+ *
520
+ * The constructor starts a background flush timer. The timer is unref'ed in
521
+ * Node.js so it will not keep the process alive by itself. Call `shutdown()`
522
+ * in tests, short-lived scripts, or server shutdown hooks to flush remaining
523
+ * telemetry and clear the timer.
524
+ *
525
+ * @param config Runtime configuration for telemetry capture and delivery.
526
+ */
527
+ constructor(config) {
528
+ this.config = {
529
+ ...config,
530
+ endpoint: config.endpoint ?? DEFAULT_ENDPOINT
531
+ };
532
+ _LynxTracer.instances.add(this);
533
+ const interval = this.config.delivery?.flushIntervalMs ?? 3e3;
534
+ this.flushTimer = setInterval(() => {
535
+ void this.flushInternal(false);
536
+ }, interval);
537
+ if (this.flushTimer && typeof this.flushTimer.unref === "function") {
538
+ this.flushTimer.unref();
539
+ }
540
+ if (typeof process !== "undefined" && !_LynxTracer.beforeExitHookRegistered) {
541
+ _LynxTracer.beforeExitHookRegistered = true;
542
+ process.once("beforeExit", () => {
543
+ void _LynxTracer.shutdownAll();
544
+ });
545
+ }
546
+ }
547
+ static async shutdownAll() {
548
+ await Promise.allSettled(
549
+ Array.from(_LynxTracer.instances).map((instance) => instance.shutdown())
550
+ );
551
+ }
552
+ /**
553
+ * Returns the current Lynx async execution context, if one is active.
554
+ *
555
+ * This is mainly useful for advanced instrumentation helpers that need to
556
+ * attach their own telemetry to the currently running `run()` context.
557
+ *
558
+ * @returns The current context, or `undefined` when called outside `run()`.
559
+ */
560
+ static getStore() {
561
+ return _LynxTracer.storage.getStore();
562
+ }
563
+ getFlushOnRunEnd() {
564
+ return this.config.delivery?.flushOnRunEnd ?? false;
565
+ }
566
+ getRequestTimeoutMs() {
567
+ return this.config.delivery?.timeoutMs ?? 1e3;
568
+ }
569
+ isBackgroundOnly() {
570
+ return (this.config.delivery?.mode ?? "BACKGROUND") === "BACKGROUND";
571
+ }
572
+ getMaxQueueSize() {
573
+ return this.config.delivery?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
574
+ }
575
+ getOverflowStrategy() {
576
+ return this.config.delivery?.overflowStrategy ?? "DROP_OLDEST";
577
+ }
578
+ enqueueEvent(event) {
579
+ const maxQueueSize = this.getMaxQueueSize();
580
+ if (maxQueueSize <= 0) {
581
+ this.droppedEventCount += 1;
582
+ return false;
583
+ }
584
+ const totalQueued = this.eventQueue.length + this.retryQueue.length;
585
+ if (totalQueued >= maxQueueSize) {
586
+ this.droppedEventCount += 1;
587
+ if (this.getOverflowStrategy() === "DROP_NEWEST") {
588
+ return false;
589
+ }
590
+ if (this.retryQueue.length > 0) {
591
+ this.retryQueue.shift();
592
+ } else {
593
+ this.eventQueue.shift();
594
+ }
595
+ }
596
+ this.eventQueue.push(event);
597
+ return true;
598
+ }
599
+ enqueueRetryEvent(event) {
600
+ const maxQueueSize = this.getMaxQueueSize();
601
+ if (maxQueueSize <= 0) {
602
+ this.droppedEventCount += 1;
603
+ return false;
604
+ }
605
+ const totalQueued = this.eventQueue.length + this.retryQueue.length;
606
+ if (totalQueued >= maxQueueSize) {
607
+ this.droppedEventCount += 1;
608
+ if (this.getOverflowStrategy() === "DROP_NEWEST") {
609
+ return false;
610
+ }
611
+ if (this.eventQueue.length > 0) {
612
+ this.eventQueue.shift();
613
+ } else {
614
+ this.retryQueue.shift();
615
+ }
616
+ }
617
+ this.retryQueue.push(event);
618
+ return true;
619
+ }
620
+ /**
621
+ * Flushes queued telemetry events to the Lynx ingestion endpoint.
622
+ *
623
+ * Events are sent in a single batch request. If delivery fails, the batch is
624
+ * moved into an in-memory retry queue and future flushes are delayed with
625
+ * exponential backoff. Normal applications usually do not need to call this
626
+ * manually because the tracer flushes on an interval.
627
+ *
628
+ * @returns A promise that resolves after the current flush attempt completes.
629
+ */
630
+ async flush() {
631
+ await this.flushInternal(true);
632
+ }
633
+ async flushInternal(waitForDelivery) {
634
+ if (this.eventQueue.length === 0 && this.retryQueue.length === 0) {
635
+ return;
636
+ }
637
+ if (this.isFlushInProgress) {
638
+ return;
639
+ }
640
+ if (this.isCircuitOpen()) {
641
+ return;
642
+ }
643
+ if (this.lastFailureTime > 0 && Date.now() - this.lastFailureTime < this.backoffDelayMs) {
644
+ return;
645
+ }
646
+ const eventsToFlush = [...this.retryQueue, ...this.eventQueue];
647
+ this.retryQueue.length = 0;
648
+ this.eventQueue.length = 0;
649
+ if (eventsToFlush.length === 0) {
650
+ return;
651
+ }
652
+ this.isFlushInProgress = true;
653
+ const url = `${this.config.endpoint}/openapi/v1/events/batch`;
654
+ const headers = {
655
+ "Content-Type": "application/json"
656
+ };
657
+ if (this.config.apiKey) {
658
+ headers["x-api-key"] = this.config.apiKey;
659
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
660
+ }
661
+ const promise = fetch(url, {
662
+ method: "POST",
663
+ headers,
664
+ body: JSON.stringify(eventsToFlush),
665
+ signal: AbortSignal.timeout(this.getRequestTimeoutMs())
666
+ }).then(async (res) => {
667
+ if (!res.ok) {
668
+ const text = await res.text();
669
+ console.error(
670
+ `[LynxTracer] telemetry batch flush failed with status ${res.status}: ${text}`
671
+ );
672
+ this.handleFailedEvents(eventsToFlush, "http_error");
673
+ } else {
674
+ const result = await res.json().catch(() => ({ success: true }));
675
+ if (result && result.success === false) {
676
+ console.error(
677
+ "[LynxTracer] telemetry batch flush returned failed details:",
678
+ result
679
+ );
680
+ this.handleFailedEvents(eventsToFlush, "batch_failed");
681
+ } else {
682
+ this.handleSuccessfulFlush();
683
+ }
684
+ }
685
+ }).catch((err) => {
686
+ console.error("LynxTracer telemetry batch flush failed:", err);
687
+ this.handleFailedEvents(eventsToFlush, "network_error");
688
+ }).finally(() => {
689
+ this.pendingPromises.delete(promise);
690
+ this.isFlushInProgress = false;
691
+ });
692
+ this.pendingPromises.add(promise);
693
+ if (waitForDelivery || this.isShuttingDown) {
694
+ await promise;
695
+ }
696
+ }
697
+ /**
698
+ * Returns a lightweight snapshot of SDK delivery health.
699
+ *
700
+ * Use this for readiness diagnostics, operational dashboards, or tests. The
701
+ * status reflects local SDK state only; it does not perform a network request.
702
+ *
703
+ * @returns Current queue, circuit breaker, and delivery state.
704
+ */
705
+ getStatus() {
706
+ return {
707
+ queueSize: this.eventQueue.length + this.retryQueue.length,
708
+ droppedEvents: this.droppedEventCount,
709
+ circuitState: this.getCircuitState(),
710
+ lastDeliveryAt: this.lastDeliveryAt,
711
+ lastError: this.lastError,
712
+ pendingTransmissions: this.pendingPromises.size
713
+ };
714
+ }
715
+ /**
716
+ * Flushes queued telemetry and releases SDK timers.
717
+ *
718
+ * Use this for graceful process shutdown, tests, CLI scripts, and serverless
719
+ * handlers where the runtime may terminate before the background interval
720
+ * fires. Calling `shutdown()` more than once is safe.
721
+ *
722
+ * @returns A promise that resolves after pending telemetry transmissions settle.
723
+ */
724
+ async shutdown(options = {}) {
725
+ if (this.isShuttingDown) return;
726
+ this.isShuttingDown = true;
727
+ if (this.flushTimer) {
728
+ clearInterval(this.flushTimer);
729
+ this.flushTimer = null;
730
+ }
731
+ const shutdownWork = async () => {
732
+ await this.flushInternal(true);
733
+ if (this.pendingPromises.size > 0) {
734
+ await Promise.allSettled(Array.from(this.pendingPromises));
735
+ }
736
+ };
737
+ if (options.timeoutMs !== void 0) {
738
+ await Promise.race([
739
+ shutdownWork(),
740
+ new Promise((resolve) => setTimeout(resolve, options.timeoutMs))
741
+ ]);
742
+ } else {
743
+ await shutdownWork();
744
+ }
745
+ _LynxTracer.instances.delete(this);
746
+ }
747
+ isCircuitBreakerEnabled() {
748
+ return this.config.circuitBreaker?.enabled ?? true;
749
+ }
750
+ isCircuitOpen() {
751
+ if (!this.isCircuitBreakerEnabled() || this.circuitOpenedAt === 0) {
752
+ return false;
753
+ }
754
+ return Date.now() - this.circuitOpenedAt < (this.config.circuitBreaker?.cooldownMs ?? 3e4);
755
+ }
756
+ getCircuitState() {
757
+ if (!this.isCircuitBreakerEnabled()) {
758
+ return "DISABLED";
759
+ }
760
+ if (this.circuitOpenedAt === 0) {
761
+ return "CLOSED";
762
+ }
763
+ return this.isCircuitOpen() ? "OPEN" : "HALF_OPEN";
764
+ }
765
+ handleSuccessfulFlush() {
766
+ this.lastFailureTime = 0;
767
+ this.backoffDelayMs = 1e3;
768
+ this.consecutiveFlushFailures = 0;
769
+ this.circuitOpenedAt = 0;
770
+ this.lastDeliveryAt = (/* @__PURE__ */ new Date()).toISOString();
771
+ this.lastError = void 0;
772
+ }
773
+ handleFailedEvents(events, reason) {
774
+ this.lastFailureTime = Date.now();
775
+ this.consecutiveFlushFailures += 1;
776
+ if (this.isCircuitBreakerEnabled() && this.consecutiveFlushFailures >= (this.config.circuitBreaker?.failureThreshold ?? 3)) {
777
+ this.circuitOpenedAt = Date.now();
778
+ console.warn(
779
+ `[LynxTracer] Telemetry circuit breaker opened after ${this.consecutiveFlushFailures} consecutive failures. Reason: ${reason}.`
780
+ );
781
+ }
782
+ this.lastError = reason;
783
+ for (const event of events) {
784
+ this.enqueueRetryEvent(event);
785
+ }
786
+ this.backoffDelayMs = Math.min(this.backoffDelayMs * 2, 6e4);
787
+ console.warn(
788
+ `[LynxTracer] Offline retry buffer saved ${events.length} events. Total size: ${this.retryQueue.length}. Backing off for ${this.backoffDelayMs}ms.`
789
+ );
790
+ }
791
+ /**
792
+ * Runs code inside a Lynx trace context.
793
+ *
794
+ * Every semantic event, instrumented LLM call, and instrumented tool call made
795
+ * inside `executionBlock` will be associated with the same run/session. Nested
796
+ * `run()` calls automatically inherit the parent run id and create a child
797
+ * span relationship.
798
+ *
799
+ * @typeParam T The value returned by the wrapped execution block.
800
+ * @param optionsOrAgentName Agent name or detailed run options.
801
+ * @param executionBlock Function to execute while the Lynx context is active.
802
+ * @returns The original return value of `executionBlock`.
803
+ *
804
+ * @example
805
+ * ```ts
806
+ * await lynx.run({ agentName: "SupportAgent", sessionId: "s-123" }, async () => {
807
+ * lynx.userInput("I need a refund");
808
+ * return await agent.handle();
809
+ * });
810
+ * ```
811
+ */
812
+ async run(optionsOrAgentName, executionBlock) {
813
+ const options = typeof optionsOrAgentName === "string" ? { agentName: optionsOrAgentName } : optionsOrAgentName;
814
+ const agentName = options.agentName;
815
+ const parentContext = _LynxTracer.storage.getStore();
816
+ const runId = options.runId ?? (parentContext ? parentContext.runId : generateTraceId());
817
+ const parentSpanId = parentContext ? parentContext.spanId : void 0;
818
+ const spanId = generateSpanId();
819
+ const workspaceId = options.workspaceId ?? parentContext?.workspaceId ?? this.config.workspaceId ?? process.env.LYNX_WORKSPACE_ID;
820
+ const agentId = options.agentId ?? parentContext?.agentId ?? this.config.agentId ?? process.env.LYNX_AGENT_ID ?? agentName;
821
+ const sessionId = options.sessionId ?? parentContext?.sessionId ?? process.env.LYNX_SESSION_ID ?? `session_${runId}`;
822
+ const sampled = parentContext ? parentContext.sampled ?? true : this.config.sampleRate !== void 0 ? Math.random() < this.config.sampleRate : true;
823
+ const currentContext = {
824
+ runId,
825
+ agentName,
826
+ spanId,
827
+ parentSpanId,
828
+ sampled,
829
+ workspaceId,
830
+ agentId,
831
+ sessionId,
832
+ eventCounts: parentContext?.eventCounts ?? {},
833
+ attributes: { ...parentContext?.attributes }
834
+ };
835
+ this.captureInternal(
836
+ "CONTEXT_ALERT",
837
+ `run.start:${agentName}`,
838
+ {
839
+ message: `Agent execution thread started`,
840
+ phase: "start",
841
+ spanId,
842
+ parentSpanId
843
+ },
844
+ currentContext
845
+ );
846
+ const startTime = Date.now();
847
+ return _LynxTracer.storage.run(currentContext, async () => {
848
+ try {
849
+ const result = await executionBlock();
850
+ const latency = Date.now() - startTime;
851
+ this.captureInternal(
852
+ "CONTEXT_ALERT",
853
+ `run.end:${agentName}`,
854
+ {
855
+ message: `Agent execution thread succeeded`,
856
+ phase: "end",
857
+ spanId,
858
+ parentSpanId,
859
+ latency
860
+ },
861
+ currentContext
862
+ );
863
+ return result;
864
+ } catch (err) {
865
+ const latency = Date.now() - startTime;
866
+ this.captureInternal(
867
+ "ERROR",
868
+ `run.error:${agentName}`,
869
+ {
870
+ error: err.message,
871
+ phase: "error",
872
+ spanId,
873
+ parentSpanId,
874
+ latency
875
+ },
876
+ currentContext
877
+ );
878
+ throw err;
879
+ } finally {
880
+ if (this.getFlushOnRunEnd()) {
881
+ await this.flushInternal(!this.isBackgroundOnly());
882
+ }
883
+ if (!this.isBackgroundOnly() && this.pendingPromises.size > 0) {
884
+ await Promise.all(Array.from(this.pendingPromises));
885
+ }
886
+ }
887
+ });
888
+ }
889
+ /**
890
+ * Captures a custom context event for the active run.
891
+ *
892
+ * Prefer semantic helpers such as `userInput()`, `decision()`, `context()`,
893
+ * `memory()`, and `outcome()` when the event has a clear meaning. Use `log()`
894
+ * for compatibility or for ad-hoc diagnostic payloads.
895
+ *
896
+ * @param label Human-readable event label.
897
+ * @param payload JSON-serializable diagnostic payload.
898
+ */
899
+ log(label, payload) {
900
+ const context = _LynxTracer.storage.getStore();
901
+ if (context) {
902
+ this.captureInternal("CONTEXT_ALERT", label, payload, context);
903
+ } else {
904
+ console.warn(
905
+ "[LynxTracer] Log was called outside a running context. Event ignored."
906
+ );
907
+ }
908
+ }
909
+ /**
910
+ * Captures the user input that started or influenced the current agent run.
911
+ *
912
+ * This event gives root-cause analysis a clear starting point and separates
913
+ * user intent from prompts, tool results, and internal agent state.
914
+ *
915
+ * @param input Raw or structured user input.
916
+ * @param metadata Optional metadata such as `userId`, `channel`, or `locale`.
917
+ */
918
+ userInput(input, metadata = {}) {
919
+ this.capture("USER_INPUT", "user.input", { input, ...metadata });
920
+ }
921
+ /**
922
+ * Captures an agent decision and the reason behind it.
923
+ *
924
+ * Use this when the agent chooses a workflow, tool, policy branch, or final
925
+ * action. The `options` object can include candidates, confidence scores, or
926
+ * any domain-specific reasoning metadata.
927
+ *
928
+ * @param reason Short explanation of the selected action.
929
+ * @param options Optional structured metadata about the decision.
930
+ */
931
+ decision(reasonOrDecision, options = {}) {
932
+ if (typeof reasonOrDecision === "string") {
933
+ this.capture("AGENT_DECISION", "agent.decision", {
934
+ reason: reasonOrDecision,
935
+ ...options
936
+ });
937
+ return;
938
+ }
939
+ this.capture("AGENT_DECISION", `agent.decision:${reasonOrDecision.name}`, {
940
+ ...reasonOrDecision,
941
+ ...reasonOrDecision.metadata
942
+ });
943
+ }
944
+ /**
945
+ * Captures retrieved or constructed context used by the agent.
946
+ *
947
+ * Use this for RAG results, conversation summaries, selected documents,
948
+ * request-scoped state, or any context that may have influenced the next LLM
949
+ * or tool call.
950
+ *
951
+ * @param data Context data or a summary of the context.
952
+ * @param metadata Optional metadata such as source, query, score, or label.
953
+ */
954
+ context(labelOrData, dataOrMetadata = {}, metadata = {}) {
955
+ if (typeof labelOrData === "string") {
956
+ this.capture("CONTEXT_RETRIEVAL", labelOrData, {
957
+ data: dataOrMetadata,
958
+ ...metadata
959
+ });
960
+ return;
961
+ }
962
+ this.capture(
963
+ "CONTEXT_RETRIEVAL",
964
+ dataOrMetadata.label ?? "context.retrieval",
965
+ {
966
+ data: labelOrData,
967
+ ...dataOrMetadata
968
+ }
969
+ );
970
+ }
971
+ /**
972
+ * Adds attributes to the current run context.
973
+ *
974
+ * Attributes are attached to subsequent events captured in the same async
975
+ * context. Use this for stable request or business identifiers such as
976
+ * `orderId`, `tenantId`, or `workflowId`.
977
+ *
978
+ * @param attributes Key-value attributes to merge into the active context.
979
+ */
980
+ setAttributes(attributes) {
981
+ const context = _LynxTracer.storage.getStore();
982
+ if (!context) {
983
+ console.warn("[LynxTracer] setAttributes called outside a run context.");
984
+ return;
985
+ }
986
+ context.attributes = {
987
+ ...context.attributes,
988
+ ...attributes
989
+ };
990
+ }
991
+ /**
992
+ * Captures access to short-term or long-term agent memory.
993
+ *
994
+ * This helps identify stale memory, missing memory, or memory values that
995
+ * influenced an incorrect action.
996
+ *
997
+ * @param operation Memory operation name, such as `read`, `write`, or `search`.
998
+ * @param data Memory key/value, query result, or a safe summary.
999
+ * @param metadata Optional metadata such as hit/miss, store name, or freshness.
1000
+ */
1001
+ memory(operation, data, metadata = {}) {
1002
+ this.capture("MEMORY_ACCESS", `memory.${operation}`, {
1003
+ operation,
1004
+ data,
1005
+ ...metadata
1006
+ });
1007
+ }
1008
+ /**
1009
+ * Captures the final technical and business outcome of the current session.
1010
+ *
1011
+ * Use this to distinguish "the code ran successfully" from "the business task
1012
+ * succeeded." That distinction is central for detecting AI failures that do
1013
+ * not throw runtime exceptions.
1014
+ *
1015
+ * @param options Outcome status, business status, reason, impact, and metadata.
1016
+ */
1017
+ outcome(options) {
1018
+ this.capture("SESSION_OUTCOME", "session.outcome", options);
1019
+ }
1020
+ /**
1021
+ * Captures a human-readable annotation for the active run.
1022
+ *
1023
+ * `annotate()` is intended for breadcrumbs that help operators understand the
1024
+ * trace but do not fit one of the stricter semantic event helpers.
1025
+ *
1026
+ * @param label Annotation label.
1027
+ * @param payload JSON-serializable annotation payload.
1028
+ */
1029
+ annotate(label, payload) {
1030
+ this.capture("CONTEXT_ALERT", label, { annotation: true, ...payload });
1031
+ }
1032
+ /**
1033
+ * Captures a semantic event for the active Lynx run context.
1034
+ */
1035
+ capture(eventType, label, payload) {
1036
+ const context = _LynxTracer.storage.getStore();
1037
+ if (!context) {
1038
+ console.warn(
1039
+ `[LynxTracer] Warning: event ${eventType} was captured outside a LynxTracer context!`
1040
+ );
1041
+ return;
1042
+ }
1043
+ this.captureInternal(eventType, label, payload, context);
1044
+ }
1045
+ /**
1046
+ * Captures an event using an explicit Lynx context.
1047
+ *
1048
+ * This method is public for low-level instrumentation modules, but application
1049
+ * code should usually call the semantic helpers or `instrumentLLM()` /
1050
+ * `instrumentTool()` instead. Payloads are processed for PII masking,
1051
+ * capture-mode filtering, runtime metadata, and loop detection before they are
1052
+ * queued for delivery.
1053
+ *
1054
+ * @param eventType Lynx event type to record.
1055
+ * @param label Human-readable event label.
1056
+ * @param payload JSON-serializable event payload.
1057
+ * @param context Explicit Lynx trace context.
1058
+ */
1059
+ captureInternal(eventType, label, payload, context) {
1060
+ const isError = eventType === "ERROR" || payload && (payload.error || "error" in payload);
1061
+ if (context.sampled === false && !isError) {
1062
+ return;
1063
+ }
1064
+ const loopPayload = this.detectLoop(eventType, label, payload, context);
1065
+ const processedPayload = processPayload(
1066
+ {
1067
+ ...context.attributes,
1068
+ ...payload,
1069
+ ...loopPayload,
1070
+ sdkVersion: SDK_VERSION,
1071
+ droppedEventCount: this.droppedEventCount || void 0,
1072
+ appVersion: payload?.appVersion ?? this.config.appVersion,
1073
+ deploymentId: payload?.deploymentId ?? this.config.deploymentId,
1074
+ environment: payload?.environment ?? this.config.environment,
1075
+ policyVersion: payload?.policyVersion ?? this.config.policyVersion
1076
+ },
1077
+ this.config
1078
+ );
1079
+ const eventPayload = {
1080
+ ...processedPayload,
1081
+ spanId: processedPayload.spanId || context.spanId,
1082
+ parentSpanId: processedPayload.parentSpanId || context.parentSpanId
1083
+ };
1084
+ const eventDto = {
1085
+ eventId: generateEventId(),
1086
+ clientId: this.config.clientId,
1087
+ runId: context.runId,
1088
+ agentName: context.agentName,
1089
+ eventType,
1090
+ label,
1091
+ payload: eventPayload,
1092
+ timestamp: Date.now(),
1093
+ workspaceId: context.workspaceId,
1094
+ agentId: context.agentId,
1095
+ sessionId: context.sessionId,
1096
+ schemaVersion: "1"
1097
+ };
1098
+ const queued = this.enqueueEvent(eventDto);
1099
+ if (!queued) {
1100
+ return;
1101
+ }
1102
+ if (loopPayload.loopDetected && eventType !== "LOOP_DETECTED") {
1103
+ this.captureInternal(
1104
+ "LOOP_DETECTED",
1105
+ `loop:${label}`,
1106
+ {
1107
+ repeatedLabel: label,
1108
+ loopCount: loopPayload.loopCount,
1109
+ argsHash: loopPayload.argsHash
1110
+ },
1111
+ context
1112
+ );
1113
+ }
1114
+ const maxBatchSize = this.config.delivery?.batchSize ?? DEFAULT_BATCH_SIZE;
1115
+ if (!this.isBackgroundOnly() && this.eventQueue.length >= maxBatchSize) {
1116
+ void this.flushInternal(false);
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Wraps an LLM client with automatic Lynx telemetry.
1121
+ *
1122
+ * The returned proxy preserves the original client shape while intercepting
1123
+ * supported generation methods such as OpenAI `chat.completions.create`,
1124
+ * Anthropic `messages.create`, Google `generateContent`, Vercel AI SDK
1125
+ * `generateText`, LangChain `invoke`, and similar methods. Captured telemetry
1126
+ * includes input/output, provider, model, generation config, latency, token
1127
+ * usage, span ids, and errors.
1128
+ *
1129
+ * @typeParam T LLM client object type.
1130
+ * @param instance LLM client instance to wrap.
1131
+ * @param optionsOrLabel Event label or custom extraction/interception rules.
1132
+ * @returns A proxy with the same public interface as `instance`.
1133
+ */
1134
+ instrumentLLM(instance, optionsOrLabel = "llm.inference") {
1135
+ return instrumentLLM(this, instance, optionsOrLabel);
1136
+ }
1137
+ /**
1138
+ * Wraps a tool function with automatic Lynx telemetry.
1139
+ *
1140
+ * The wrapped function emits `TOOL_CALL` before execution and `TOOL_RESULT`
1141
+ * after success. Errors are captured as `ERROR` and then rethrown. Use
1142
+ * `metadata` to describe side effects, risk level, external targets, or tool
1143
+ * version so debugging and governance views can reason about the call.
1144
+ *
1145
+ * @typeParam T Tool function type.
1146
+ * @param toolName Stable tool name shown in traces and analytics.
1147
+ * @param fn Tool function to execute.
1148
+ * @param metadata Optional tool metadata for governance and RCA.
1149
+ * @returns A wrapped function with the same call signature as `fn`.
1150
+ */
1151
+ instrumentTool(toolName, fn, metadata = {}) {
1152
+ return instrumentTool(this, toolName, fn, metadata);
1153
+ }
1154
+ getDefaultFailureMode(riskLevel) {
1155
+ if (riskLevel === "HIGH" || riskLevel === "CRITICAL") {
1156
+ return "FAIL_CLOSED";
1157
+ }
1158
+ if (riskLevel === "MEDIUM") {
1159
+ return "REQUIRE_APPROVAL";
1160
+ }
1161
+ return "FAIL_OPEN";
1162
+ }
1163
+ normalizePolicyDecision(decision) {
1164
+ const action = decision.action ?? (decision.allow === false ? "BLOCK" : "ALLOW");
1165
+ const allow = action === "ALLOW" || action === "WARN";
1166
+ return {
1167
+ ...decision,
1168
+ action,
1169
+ allow
1170
+ };
1171
+ }
1172
+ decisionFromPolicyError(err, options) {
1173
+ const failureMode = options.failureMode ?? this.getDefaultFailureMode(options.riskLevel);
1174
+ const reason = err instanceof Error ? err.message : "Policy evaluation failed";
1175
+ const action = failureMode === "FAIL_OPEN" ? "ALLOW" : failureMode === "REQUIRE_APPROVAL" ? "REQUIRE_APPROVAL" : "BLOCK";
1176
+ return {
1177
+ action,
1178
+ allow: action === "ALLOW",
1179
+ reason,
1180
+ severity: options.riskLevel,
1181
+ metadata: {
1182
+ policyError: true,
1183
+ failureMode
1184
+ }
1185
+ };
1186
+ }
1187
+ /**
1188
+ * Wraps a tool with a local policy check before execution.
1189
+ *
1190
+ * `guardTool()` emits a `POLICY_EVALUATION` event before the tool runs. When
1191
+ * `beforeCall` returns `{ allow: false }`, Lynx also emits
1192
+ * `POLICY_VIOLATION` and `GUARDRAIL_ACTIVATED`, then throws before executing
1193
+ * the original tool. This provides the SDK-side hook needed for "observe first,
1194
+ * then prevent recurrence" workflows.
1195
+ *
1196
+ * @typeParam T Tool function type.
1197
+ * @param toolName Stable tool name shown in traces and policy events.
1198
+ * @param fn Tool function to guard.
1199
+ * @param options Tool metadata and an optional `beforeCall` policy callback.
1200
+ * @returns A guarded function with the same call signature as `fn`.
1201
+ */
1202
+ guardTool(toolName, fn, options = {}) {
1203
+ const guarded = (async (...args) => {
1204
+ const input = args[0];
1205
+ let decision;
1206
+ try {
1207
+ decision = this.normalizePolicyDecision(
1208
+ options.beforeCall ? await options.beforeCall({
1209
+ toolName,
1210
+ input,
1211
+ args,
1212
+ metadata: options
1213
+ }) : { action: "ALLOW" }
1214
+ );
1215
+ } catch (err) {
1216
+ decision = this.decisionFromPolicyError(err, options);
1217
+ }
1218
+ this.capture("POLICY_EVALUATION", `policy:${toolName}`, {
1219
+ toolName,
1220
+ input,
1221
+ args,
1222
+ argsHash: stableHash2(input),
1223
+ action: decision.action,
1224
+ allow: decision.allow,
1225
+ policyId: decision.policyId,
1226
+ policyVersion: decision.policyVersion ?? options.policyVersion ?? this.config.policyVersion,
1227
+ reason: decision.reason,
1228
+ severity: decision.severity,
1229
+ metadata: decision.metadata
1230
+ });
1231
+ if (!decision.allow) {
1232
+ this.capture("POLICY_VIOLATION", `policy.violation:${toolName}`, {
1233
+ toolName,
1234
+ input,
1235
+ args,
1236
+ argsHash: stableHash2(input),
1237
+ action: decision.action,
1238
+ policyId: decision.policyId,
1239
+ policyVersion: decision.policyVersion ?? options.policyVersion ?? this.config.policyVersion,
1240
+ reason: decision.reason,
1241
+ severity: decision.severity ?? options.riskLevel
1242
+ });
1243
+ this.capture("GUARDRAIL_ACTIVATED", `guardrail.blocked:${toolName}`, {
1244
+ toolName,
1245
+ action: decision.action,
1246
+ reason: decision.reason,
1247
+ riskLevel: decision.severity ?? options.riskLevel
1248
+ });
1249
+ throw new LynxPolicyError(
1250
+ decision.reason || `Lynx guard blocked tool call: ${toolName}`,
1251
+ decision.action === "REQUIRE_APPROVAL" ? "REQUIRE_APPROVAL" : "BLOCK",
1252
+ decision.policyId,
1253
+ decision.severity ?? options.riskLevel,
1254
+ decision.reason,
1255
+ decision.metadata
1256
+ );
1257
+ }
1258
+ const instrumented = this.instrumentTool(toolName, fn, options);
1259
+ return instrumented(...args);
1260
+ });
1261
+ return guarded;
1262
+ }
1263
+ detectLoop(eventType, label, payload, context) {
1264
+ if (eventType !== "TOOL_CALL" && eventType !== "CALL_TOOLS" && eventType !== "LLM_CALL") {
1265
+ return {};
1266
+ }
1267
+ const phase = payload?.phase;
1268
+ if (phase && phase !== "start") {
1269
+ return {};
1270
+ }
1271
+ const argsHash = stableHash2(
1272
+ payload?.args ?? payload?.input ?? payload?.model ?? label
1273
+ );
1274
+ const key = `${eventType}:${label}:${argsHash}`;
1275
+ context.eventCounts ??= {};
1276
+ const loopCount = (context.eventCounts[key] ?? 0) + 1;
1277
+ context.eventCounts[key] = loopCount;
1278
+ if (loopCount < 5) {
1279
+ return { argsHash };
1280
+ }
1281
+ return {
1282
+ argsHash,
1283
+ loopDetected: true,
1284
+ loopCount,
1285
+ repeatedLabel: label
1286
+ };
1287
+ }
1288
+ };
1289
+
1290
+ // src/core/register.ts
1291
+ var parseBool = (val) => {
1292
+ if (val === void 0) return void 0;
1293
+ return val.toLowerCase() === "true";
1294
+ };
1295
+ var parseNum = (val) => {
1296
+ if (val === void 0) return void 0;
1297
+ const num = parseFloat(val);
1298
+ return isNaN(num) ? void 0 : num;
1299
+ };
1300
+ var lynx = new LynxTracer({
1301
+ clientId: process.env.LYNX_CLIENT_ID || "local_dev_env",
1302
+ endpoint: process.env.LYNX_ENDPOINT || DEFAULT_ENDPOINT,
1303
+ sampleRate: parseNum(process.env.LYNX_SAMPLE_RATE),
1304
+ captureInput: parseBool(process.env.LYNX_CAPTURE_INPUT),
1305
+ captureOutput: parseBool(process.env.LYNX_CAPTURE_OUTPUT),
1306
+ maxPayloadLength: parseNum(process.env.LYNX_MAX_PAYLOAD_LENGTH),
1307
+ captureMode: process.env.LYNX_CAPTURE_MODE || "smart",
1308
+ workspaceId: process.env.LYNX_WORKSPACE_ID,
1309
+ agentId: process.env.LYNX_AGENT_ID,
1310
+ apiKey: process.env.LYNX_API_KEY,
1311
+ appVersion: process.env.LYNX_APP_VERSION,
1312
+ deploymentId: process.env.LYNX_DEPLOYMENT_ID,
1313
+ environment: process.env.LYNX_ENVIRONMENT || process.env.NODE_ENV,
1314
+ policyVersion: process.env.LYNX_POLICY_VERSION,
1315
+ delivery: {
1316
+ mode: process.env.LYNX_DELIVERY_MODE,
1317
+ timeoutMs: parseNum(process.env.LYNX_DELIVERY_TIMEOUT_MS),
1318
+ flushOnRunEnd: parseBool(process.env.LYNX_DELIVERY_FLUSH_ON_RUN_END),
1319
+ flushIntervalMs: parseNum(process.env.LYNX_DELIVERY_FLUSH_INTERVAL_MS),
1320
+ batchSize: parseNum(process.env.LYNX_DELIVERY_BATCH_SIZE),
1321
+ maxQueueSize: parseNum(process.env.LYNX_DELIVERY_MAX_QUEUE_SIZE),
1322
+ overflowStrategy: process.env.LYNX_DELIVERY_OVERFLOW_STRATEGY
1323
+ },
1324
+ circuitBreaker: {
1325
+ enabled: parseBool(process.env.LYNX_CIRCUIT_BREAKER_ENABLED),
1326
+ failureThreshold: parseNum(
1327
+ process.env.LYNX_CIRCUIT_BREAKER_FAILURE_THRESHOLD
1328
+ ),
1329
+ cooldownMs: parseNum(process.env.LYNX_CIRCUIT_BREAKER_COOLDOWN_MS)
1330
+ }
1331
+ });
1332
+ export {
1333
+ DEFAULT_ENDPOINT,
1334
+ LynxPolicyError,
1335
+ LynxTracer,
1336
+ instrumentLLM,
1337
+ instrumentTool,
1338
+ lynx
1339
+ };