pi-langfuse 1.5.6 → 1.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/handlers/agent.ts +2 -2
- package/src/handlers/tool.ts +6 -6
- package/src/langfuse.ts +267 -50
- package/src/limits.ts +97 -0
- package/src/redaction.ts +12 -8
- package/src/types.ts +27 -0
- package/src/utils.ts +25 -19
package/README.md
CHANGED
|
@@ -104,6 +104,27 @@ export LANGFUSE_CAPTURE_CWD=false
|
|
|
104
104
|
|
|
105
105
|
All captured payloads are redacted before upload. The extension masks common API keys, bearer tokens, passwords, cookies, private keys, Langfuse keys, GitHub/npm/AWS-style tokens, and local absolute paths.
|
|
106
106
|
|
|
107
|
+
### Payload limits
|
|
108
|
+
|
|
109
|
+
Before upload, payloads are shaped: strings are truncated and deeply nested or
|
|
110
|
+
very wide structures are trimmed. These caps keep traces small and protect the
|
|
111
|
+
Langfuse ingestion pipeline. Override any of them (no rebuild needed):
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export PI_LANGFUSE_MAX_STRING_LENGTH=12000 # per-string chars (system prompt, inputs)
|
|
115
|
+
export PI_LANGFUSE_MAX_TOOL_PAYLOAD_LENGTH=24000 # per tool input/output chars
|
|
116
|
+
export PI_LANGFUSE_MAX_DEPTH=6 # max nesting depth
|
|
117
|
+
export PI_LANGFUSE_MAX_ARRAY_ITEMS=50 # max array elements kept
|
|
118
|
+
export PI_LANGFUSE_MAX_OBJECT_KEYS=80 # max object keys kept
|
|
119
|
+
export PI_LANGFUSE_MAX_PAYLOAD_NODES=2000 # max total nodes per payload
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Set any limit to `0`, `off`, `none`, or `unlimited` to disable that cap
|
|
123
|
+
entirely (captures the full value). Unset or invalid values fall back to the
|
|
124
|
+
defaults shown above. To capture a very large system prompt or big tool
|
|
125
|
+
payloads in full, raise or disable the relevant limit (e.g.
|
|
126
|
+
`PI_LANGFUSE_MAX_STRING_LENGTH=off`).
|
|
127
|
+
|
|
107
128
|
### Method 3: Persistent `config.json`
|
|
108
129
|
|
|
109
130
|
Create or update `~/.pi/agent/pi-langfuse/config.json`:
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIG_PATH, DEFAULT_LANGFUSE_HOST } from "./constants.js";
|
|
|
5
5
|
import { state } from "./state.js";
|
|
6
6
|
import { forceShutdownRuntime } from "./langfuse.js";
|
|
7
7
|
import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
|
|
8
|
+
import { createPayloadLimits } from "./limits.js";
|
|
8
9
|
|
|
9
10
|
export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.env as EnvLike): Config | null {
|
|
10
11
|
if (existsSync(path)) {
|
|
@@ -22,6 +23,7 @@ export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.en
|
|
|
22
23
|
secretKey: config.secretKey,
|
|
23
24
|
host: config.host || DEFAULT_LANGFUSE_HOST,
|
|
24
25
|
capturePolicy: createCapturePolicy(captureSource),
|
|
26
|
+
limits: createPayloadLimits(env),
|
|
25
27
|
};
|
|
26
28
|
}
|
|
27
29
|
} catch (e) {
|
|
@@ -44,6 +46,7 @@ export function loadConfigFromEnv(env: EnvLike = process.env as EnvLike): Config
|
|
|
44
46
|
secretKey,
|
|
45
47
|
host: env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
|
|
46
48
|
capturePolicy: createCapturePolicy(env),
|
|
49
|
+
limits: createPayloadLimits(env),
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
package/src/handlers/agent.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { state, resetRunState, computeEvaluationScores } from "../state.js";
|
|
2
2
|
import { getRuntime, sendScore } from "../langfuse.js";
|
|
3
3
|
import { ensureConfig } from "../config.js";
|
|
4
|
-
import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput, getCapturePolicy } from "../utils.js";
|
|
4
|
+
import { shapePayload, truncate, extractFinalAssistant, extractAssistantOutput, getCapturePolicy, getLimits } from "../utils.js";
|
|
5
5
|
import { closeDanglingObservations } from "./tool.js";
|
|
6
6
|
import { applyCapturePolicy } from "../capture-policy.js";
|
|
7
7
|
import { collectSourceMetadata } from "../source-metadata.js";
|
|
@@ -79,7 +79,7 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
|
|
|
79
79
|
...(state.currentProvider ? { provider: state.currentProvider } : {}),
|
|
80
80
|
sessionId: state.currentSessionId || undefined,
|
|
81
81
|
},
|
|
82
|
-
systemPrompt: systemPrompt ? truncate(String(systemPrompt),
|
|
82
|
+
systemPrompt: systemPrompt ? truncate(String(systemPrompt), getLimits().maxString) : undefined,
|
|
83
83
|
},
|
|
84
84
|
getCapturePolicy(),
|
|
85
85
|
);
|
package/src/handlers/tool.ts
CHANGED
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
truncate,
|
|
11
11
|
estimatePayloadBytes,
|
|
12
12
|
getCapturePolicy,
|
|
13
|
+
getLimits,
|
|
13
14
|
} from "../utils.js";
|
|
14
|
-
import { MAX_TOOL_PAYLOAD_LENGTH } from "../constants.js";
|
|
15
15
|
import { applyCapturePolicy } from "../capture-policy.js";
|
|
16
16
|
import { redactString } from "../redaction.js";
|
|
17
17
|
|
|
@@ -28,7 +28,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
|
|
|
28
28
|
try {
|
|
29
29
|
const toolName = getToolName(event);
|
|
30
30
|
const toolInput = getToolInput(event);
|
|
31
|
-
const shapedInput = shapePayload(toolInput, { maxString:
|
|
31
|
+
const shapedInput = shapePayload(toolInput, { maxString: getLimits().maxToolPayload });
|
|
32
32
|
const captured = applyCapturePolicy(
|
|
33
33
|
{
|
|
34
34
|
toolInput: shapedInput,
|
|
@@ -36,7 +36,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
|
|
|
36
36
|
},
|
|
37
37
|
getCapturePolicy(),
|
|
38
38
|
);
|
|
39
|
-
const inputBytes = estimatePayloadBytes(captured.toolInput,
|
|
39
|
+
const inputBytes = estimatePayloadBytes(captured.toolInput, getLimits().maxToolPayload);
|
|
40
40
|
const parent = state.agentState.activeTurn ?? state.agentState.root;
|
|
41
41
|
const tool = await startChildObservation({
|
|
42
42
|
parent,
|
|
@@ -79,7 +79,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
|
|
|
79
79
|
|
|
80
80
|
const isError = Boolean(event.isError ?? event.error ?? event.status === "error");
|
|
81
81
|
const output =
|
|
82
|
-
extractTextContent(event.content,
|
|
82
|
+
extractTextContent(event.content, getLimits().maxToolPayload) ??
|
|
83
83
|
event.output ??
|
|
84
84
|
event.result ??
|
|
85
85
|
event.error ??
|
|
@@ -87,7 +87,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
|
|
|
87
87
|
event;
|
|
88
88
|
|
|
89
89
|
try {
|
|
90
|
-
const shapedOutput = shapePayload(output, { maxString:
|
|
90
|
+
const shapedOutput = shapePayload(output, { maxString: getLimits().maxToolPayload });
|
|
91
91
|
const captured = applyCapturePolicy(
|
|
92
92
|
{
|
|
93
93
|
toolOutput: shapedOutput,
|
|
@@ -99,7 +99,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
|
|
|
99
99
|
},
|
|
100
100
|
getCapturePolicy(),
|
|
101
101
|
);
|
|
102
|
-
const outputBytes = estimatePayloadBytes(captured.toolOutput,
|
|
102
|
+
const outputBytes = estimatePayloadBytes(captured.toolOutput, getLimits().maxToolPayload);
|
|
103
103
|
const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
|
|
104
104
|
|
|
105
105
|
activeTool.observation
|
package/src/langfuse.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LangfuseRuntime, LangfuseScoreClient } from "./types.js";
|
|
1
|
+
import type { LangfuseRuntime, LangfuseScoreClient, PendingScore } from "./types.js";
|
|
2
2
|
import { state } from "./state.js";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
|
|
@@ -60,6 +60,11 @@ interface RestFallbackStore {
|
|
|
60
60
|
const OTEL_VISIBILITY_TIMEOUT_MS = 1_500;
|
|
61
61
|
const OTEL_VISIBILITY_POLL_INTERVAL_MS = 200;
|
|
62
62
|
const DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
|
|
63
|
+
const DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS = 5;
|
|
64
|
+
const DEFAULT_SCORE_FLUSH_AT = 10;
|
|
65
|
+
const DEFAULT_SCORE_FLUSH_INTERVAL_MS = 1_000;
|
|
66
|
+
const MAX_SCORE_QUEUE_SIZE = 100_000;
|
|
67
|
+
const MAX_SCORE_BATCH_SIZE = 100;
|
|
63
68
|
|
|
64
69
|
let shutdownStepTimeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS;
|
|
65
70
|
|
|
@@ -67,8 +72,27 @@ function nowIso() {
|
|
|
67
72
|
return new Date().toISOString();
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
function
|
|
71
|
-
|
|
75
|
+
function resolvePositiveEnvNumber(name: string, fallback: number, integer = false): number {
|
|
76
|
+
const parsed = Number(process.env[name]);
|
|
77
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
return integer ? Math.floor(parsed) : parsed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function delay(ms: number, signal?: AbortSignal) {
|
|
84
|
+
return new Promise<void>((resolve, reject) => {
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
reject(signal.reason);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const timeout = setTimeout(resolve, ms);
|
|
91
|
+
signal?.addEventListener("abort", () => {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
reject(signal.reason);
|
|
94
|
+
}, { once: true });
|
|
95
|
+
});
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
function debugLog(message: string) {
|
|
@@ -107,7 +131,14 @@ export function getLastRuntimeError(): { scope: string; message: string; timesta
|
|
|
107
131
|
return lastRuntimeError;
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
async function
|
|
134
|
+
async function withShutdownDeadline<T>(label: string, startOperation: () => Promise<T> | undefined, deadline: number): Promise<T | undefined> {
|
|
135
|
+
const remainingMs = deadline - Date.now();
|
|
136
|
+
if (remainingMs <= 0) {
|
|
137
|
+
debugLog(`📊 Langfuse: Skipped ${label}; shutdown deadline elapsed`);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const operation = startOperation();
|
|
111
142
|
if (!operation) {
|
|
112
143
|
return undefined;
|
|
113
144
|
}
|
|
@@ -118,9 +149,9 @@ async function withTimeout<T>(label: string, operation: Promise<T> | undefined):
|
|
|
118
149
|
operation,
|
|
119
150
|
new Promise<undefined>((resolve) => {
|
|
120
151
|
timeout = setTimeout(() => {
|
|
121
|
-
debugLog(`📊 Langfuse: ${label} timed out
|
|
152
|
+
debugLog(`📊 Langfuse: ${label} timed out; shutdown deadline elapsed`);
|
|
122
153
|
resolve(undefined);
|
|
123
|
-
},
|
|
154
|
+
}, remainingMs);
|
|
124
155
|
}),
|
|
125
156
|
]);
|
|
126
157
|
} finally {
|
|
@@ -130,6 +161,142 @@ async function withTimeout<T>(label: string, operation: Promise<T> | undefined):
|
|
|
130
161
|
}
|
|
131
162
|
}
|
|
132
163
|
|
|
164
|
+
function getRuntimeConfig(rt: LangfuseRuntime) {
|
|
165
|
+
return rt.runtimeConfig ?? state.config;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ingestionHeaders(rt: LangfuseRuntime): Record<string, string> {
|
|
169
|
+
const config = getRuntimeConfig(rt);
|
|
170
|
+
if (!config) {
|
|
171
|
+
throw new Error("Langfuse runtime config is unavailable");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const auth = Buffer.from(`${config.publicKey}:${config.secretKey}`).toString("base64");
|
|
175
|
+
return {
|
|
176
|
+
Authorization: `Basic ${auth}`,
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function ingestBatch(rt: LangfuseRuntime, batch: unknown[], signal: AbortSignal): Promise<unknown[]> {
|
|
182
|
+
const config = getRuntimeConfig(rt);
|
|
183
|
+
if (!config) {
|
|
184
|
+
throw new Error("Langfuse runtime config is unavailable");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const response = await fetch(`${config.host.replace(/\/$/, "")}/api/public/ingestion`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: ingestionHeaders(rt),
|
|
190
|
+
body: JSON.stringify({ batch }),
|
|
191
|
+
signal,
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
throw new Error(`Langfuse ingestion failed with HTTP ${response.status}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const text = await response.text();
|
|
198
|
+
if (!text) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const responseBody = JSON.parse(text) as { errors?: unknown[] };
|
|
203
|
+
return Array.isArray(responseBody.errors) ? responseBody.errors : [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function flushPendingScores(rt: LangfuseRuntime, signal: AbortSignal): Promise<void> {
|
|
207
|
+
const pendingScores = rt.pendingScores;
|
|
208
|
+
if (!pendingScores || pendingScores.length === 0) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
while (pendingScores.length > 0) {
|
|
213
|
+
const scores = pendingScores.slice(0, MAX_SCORE_BATCH_SIZE);
|
|
214
|
+
try {
|
|
215
|
+
const errors = await ingestBatch(
|
|
216
|
+
rt,
|
|
217
|
+
scores.map((score) => ({
|
|
218
|
+
type: "score-create",
|
|
219
|
+
id: randomUUID(),
|
|
220
|
+
timestamp: nowIso(),
|
|
221
|
+
body: score,
|
|
222
|
+
})),
|
|
223
|
+
signal,
|
|
224
|
+
);
|
|
225
|
+
pendingScores.splice(0, scores.length);
|
|
226
|
+
if (errors.length > 0) {
|
|
227
|
+
rememberRuntimeError("score ingestion", new Error(JSON.stringify(errors)));
|
|
228
|
+
console.warn("📊 Langfuse: Score ingestion reported errors", errors);
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if ((error as { name?: string }).name !== "AbortError") {
|
|
232
|
+
rememberRuntimeError("score ingestion", error);
|
|
233
|
+
console.warn("📊 Langfuse: Failed to flush scores", error);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function clearScoreFlushTimer(rt: LangfuseRuntime) {
|
|
241
|
+
if (rt.scoreFlushTimer) {
|
|
242
|
+
clearTimeout(rt.scoreFlushTimer);
|
|
243
|
+
rt.scoreFlushTimer = undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function scheduleScoreFlush(rt: LangfuseRuntime) {
|
|
248
|
+
if (
|
|
249
|
+
rt.scoreFlushStopped
|
|
250
|
+
|| rt.scoreFlushTimer
|
|
251
|
+
|| rt.scoreFlushPromise
|
|
252
|
+
|| !rt.pendingScores?.length
|
|
253
|
+
) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
rt.scoreFlushTimer = setTimeout(() => {
|
|
258
|
+
rt.scoreFlushTimer = undefined;
|
|
259
|
+
void startScoreFlush(rt);
|
|
260
|
+
}, rt.scoreFlushIntervalMs ?? DEFAULT_SCORE_FLUSH_INTERVAL_MS);
|
|
261
|
+
rt.scoreFlushTimer.unref?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function startScoreFlush(rt: LangfuseRuntime): Promise<void> {
|
|
265
|
+
if (rt.scoreFlushPromise) {
|
|
266
|
+
return rt.scoreFlushPromise;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
clearScoreFlushTimer(rt);
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const timeout = setTimeout(
|
|
272
|
+
() => controller.abort(new DOMException("Langfuse score request timed out", "AbortError")),
|
|
273
|
+
rt.scoreRequestTimeoutMs ?? DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS * 1_000,
|
|
274
|
+
);
|
|
275
|
+
timeout.unref?.();
|
|
276
|
+
rt.scoreFlushController = controller;
|
|
277
|
+
|
|
278
|
+
const promise = flushPendingScores(rt, controller.signal).finally(() => {
|
|
279
|
+
clearTimeout(timeout);
|
|
280
|
+
if (rt.scoreFlushPromise === promise) {
|
|
281
|
+
rt.scoreFlushPromise = undefined;
|
|
282
|
+
}
|
|
283
|
+
if (rt.scoreFlushController === controller) {
|
|
284
|
+
rt.scoreFlushController = undefined;
|
|
285
|
+
}
|
|
286
|
+
scheduleScoreFlush(rt);
|
|
287
|
+
});
|
|
288
|
+
rt.scoreFlushPromise = promise;
|
|
289
|
+
return promise;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function stopScoreFlush(rt: LangfuseRuntime) {
|
|
293
|
+
rt.scoreFlushStopped = true;
|
|
294
|
+
clearScoreFlushTimer(rt);
|
|
295
|
+
rt.scoreFlushController?.abort(
|
|
296
|
+
new DOMException("Langfuse score flushing stopped", "AbortError"),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
133
300
|
function toIso(value: unknown): string | undefined {
|
|
134
301
|
if (!value) {
|
|
135
302
|
return undefined;
|
|
@@ -258,27 +425,40 @@ function wrapObservation(
|
|
|
258
425
|
};
|
|
259
426
|
}
|
|
260
427
|
|
|
261
|
-
async function traceExists(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
|
|
428
|
+
async function traceExists(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
|
|
429
|
+
const config = getRuntimeConfig(rt);
|
|
430
|
+
if (!config) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
|
|
262
434
|
try {
|
|
263
|
-
const
|
|
264
|
-
|
|
435
|
+
const response = await fetch(
|
|
436
|
+
`${config.host.replace(/\/$/, "")}/api/public/traces/${encodeURIComponent(traceId)}`,
|
|
437
|
+
{
|
|
438
|
+
headers: ingestionHeaders(rt),
|
|
439
|
+
signal,
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
if (response.status === 404) {
|
|
265
443
|
return false;
|
|
266
444
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return false;
|
|
445
|
+
if (!response.ok) {
|
|
446
|
+
throw new Error(`Langfuse trace visibility check failed with HTTP ${response.status}`);
|
|
270
447
|
}
|
|
271
448
|
return true;
|
|
272
|
-
} catch {
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (signal.aborted) {
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
273
453
|
return false;
|
|
274
454
|
}
|
|
275
455
|
}
|
|
276
456
|
|
|
277
|
-
async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string): Promise<boolean> {
|
|
457
|
+
async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string, signal: AbortSignal): Promise<boolean> {
|
|
278
458
|
const deadline = Date.now() + OTEL_VISIBILITY_TIMEOUT_MS;
|
|
279
459
|
|
|
280
460
|
while (true) {
|
|
281
|
-
if (await traceExists(rt, traceId)) {
|
|
461
|
+
if (await traceExists(rt, traceId, signal)) {
|
|
282
462
|
return true;
|
|
283
463
|
}
|
|
284
464
|
|
|
@@ -287,7 +467,7 @@ async function waitForTraceVisibility(rt: LangfuseRuntime, traceId: string): Pro
|
|
|
287
467
|
return false;
|
|
288
468
|
}
|
|
289
469
|
|
|
290
|
-
await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs));
|
|
470
|
+
await delay(Math.min(OTEL_VISIBILITY_POLL_INTERVAL_MS, remainingMs), signal);
|
|
291
471
|
}
|
|
292
472
|
}
|
|
293
473
|
|
|
@@ -295,14 +475,14 @@ function eventTimestamp(record: { endTime?: string; startTime?: string; timestam
|
|
|
295
475
|
return record.endTime ?? record.startTime ?? record.timestamp ?? nowIso();
|
|
296
476
|
}
|
|
297
477
|
|
|
298
|
-
async function fallbackToRestIngestion(rt: LangfuseRuntime) {
|
|
478
|
+
async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal) {
|
|
299
479
|
const store = rt.restFallback as RestFallbackStore | undefined;
|
|
300
480
|
if (!store?.trace || store.attempted) {
|
|
301
481
|
return;
|
|
302
482
|
}
|
|
303
483
|
store.attempted = true;
|
|
304
484
|
|
|
305
|
-
if (await waitForTraceVisibility(rt, store.trace.id)) {
|
|
485
|
+
if (await waitForTraceVisibility(rt, store.trace.id, signal)) {
|
|
306
486
|
return;
|
|
307
487
|
}
|
|
308
488
|
|
|
@@ -355,31 +535,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime) {
|
|
|
355
535
|
});
|
|
356
536
|
}
|
|
357
537
|
|
|
358
|
-
const
|
|
359
|
-
if (!ingestionApi?.batch) {
|
|
360
|
-
debugLog("📊 Langfuse: REST fallback ingestion is unavailable");
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const response = await withTimeout(
|
|
365
|
-
"REST fallback ingestion",
|
|
366
|
-
ingestionApi.batch({
|
|
367
|
-
batch,
|
|
368
|
-
metadata: {
|
|
369
|
-
source: "pi-langfuse",
|
|
370
|
-
fallback: "rest-ingestion",
|
|
371
|
-
reason: "otel-trace-not-visible-after-flush",
|
|
372
|
-
},
|
|
373
|
-
}),
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
if (!response) {
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const responseBody = response as { errors?: unknown[] } | undefined;
|
|
381
|
-
const responseErrors = responseBody?.errors;
|
|
382
|
-
const errors = Array.isArray(responseErrors) ? responseErrors : [];
|
|
538
|
+
const errors = await ingestBatch(rt, batch, signal);
|
|
383
539
|
if (errors.length > 0) {
|
|
384
540
|
rememberRuntimeError("REST fallback ingestion", new Error(JSON.stringify(errors)));
|
|
385
541
|
console.warn("📊 Langfuse: REST fallback ingestion reported errors", errors);
|
|
@@ -426,6 +582,11 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
426
582
|
|
|
427
583
|
try {
|
|
428
584
|
ensureOtelContextManager(context, AsyncHooksContextManager);
|
|
585
|
+
const scoreFlushAt = resolvePositiveEnvNumber("LANGFUSE_FLUSH_AT", DEFAULT_SCORE_FLUSH_AT, true);
|
|
586
|
+
const scoreFlushIntervalMs =
|
|
587
|
+
resolvePositiveEnvNumber("LANGFUSE_FLUSH_INTERVAL", DEFAULT_SCORE_FLUSH_INTERVAL_MS / 1_000) * 1_000;
|
|
588
|
+
const scoreRequestTimeoutMs =
|
|
589
|
+
resolvePositiveEnvNumber("LANGFUSE_TIMEOUT", DEFAULT_LANGFUSE_REQUEST_TIMEOUT_SECONDS) * 1_000;
|
|
429
590
|
const spanProcessor = new LangfuseSpanProcessor({
|
|
430
591
|
publicKey: state.config.publicKey,
|
|
431
592
|
secretKey: state.config.secretKey,
|
|
@@ -449,6 +610,16 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
|
|
|
449
610
|
tracerProvider,
|
|
450
611
|
clearTracerProvider: () => tracing.setLangfuseTracerProvider(null),
|
|
451
612
|
restFallback,
|
|
613
|
+
pendingScores: [],
|
|
614
|
+
scoreFlushAt,
|
|
615
|
+
scoreFlushIntervalMs,
|
|
616
|
+
scoreRequestTimeoutMs,
|
|
617
|
+
scoreFlushStopped: false,
|
|
618
|
+
runtimeConfig: {
|
|
619
|
+
publicKey: state.config.publicKey,
|
|
620
|
+
secretKey: state.config.secretKey,
|
|
621
|
+
host: state.config.host,
|
|
622
|
+
},
|
|
452
623
|
};
|
|
453
624
|
lastRuntimeError = null;
|
|
454
625
|
} catch (e) {
|
|
@@ -468,17 +639,36 @@ function doShutdownRuntime(): Promise<void> {
|
|
|
468
639
|
|
|
469
640
|
const rt = runtime;
|
|
470
641
|
runtime = null;
|
|
642
|
+
const deadline = Date.now() + shutdownStepTimeoutMs;
|
|
643
|
+
const controller = new AbortController();
|
|
644
|
+
const abortTimeout = setTimeout(() => controller.abort(), shutdownStepTimeoutMs);
|
|
645
|
+
stopScoreFlush(rt);
|
|
471
646
|
|
|
472
647
|
try {
|
|
473
|
-
await
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
648
|
+
await withShutdownDeadline(
|
|
649
|
+
"Active score flush",
|
|
650
|
+
() => rt.scoreFlushPromise,
|
|
651
|
+
deadline,
|
|
652
|
+
);
|
|
653
|
+
await withShutdownDeadline("OTel force flush", () => rt.tracerProvider?.forceFlush?.(), deadline);
|
|
654
|
+
await withShutdownDeadline(
|
|
655
|
+
"REST fallback ingestion",
|
|
656
|
+
() => fallbackToRestIngestion(rt, controller.signal),
|
|
657
|
+
deadline,
|
|
658
|
+
);
|
|
659
|
+
await flushPendingScores(rt, controller.signal);
|
|
660
|
+
await withShutdownDeadline("Langfuse score flush", () => rt.scoreClient.flush?.(), deadline);
|
|
661
|
+
await withShutdownDeadline("Langfuse client shutdown", () => rt.scoreClient.shutdown?.(), deadline);
|
|
662
|
+
await withShutdownDeadline("OTel tracer shutdown", () => rt.tracerProvider?.shutdown?.(), deadline);
|
|
478
663
|
} catch (e) {
|
|
479
664
|
rememberRuntimeError("runtime shutdown", e);
|
|
480
665
|
console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
|
|
481
666
|
} finally {
|
|
667
|
+
clearTimeout(abortTimeout);
|
|
668
|
+
clearScoreFlushTimer(rt);
|
|
669
|
+
rt.scoreFlushController?.abort();
|
|
670
|
+
rt.scoreFlushController = undefined;
|
|
671
|
+
rt.scoreFlushPromise = undefined;
|
|
482
672
|
if (!runtime) {
|
|
483
673
|
rt.clearTracerProvider?.();
|
|
484
674
|
}
|
|
@@ -516,7 +706,13 @@ export async function forceShutdownRuntime(): Promise<void> {
|
|
|
516
706
|
}
|
|
517
707
|
|
|
518
708
|
export function __setRuntimeForTest(rt: LangfuseRuntime | null, timeoutMs = DEFAULT_SHUTDOWN_STEP_TIMEOUT_MS): void {
|
|
709
|
+
if (runtime && runtime !== rt) {
|
|
710
|
+
stopScoreFlush(runtime);
|
|
711
|
+
}
|
|
519
712
|
runtime = rt;
|
|
713
|
+
if (rt) {
|
|
714
|
+
rt.scoreFlushStopped = false;
|
|
715
|
+
}
|
|
520
716
|
shutdownStepTimeoutMs = timeoutMs;
|
|
521
717
|
activeSessions.clear();
|
|
522
718
|
}
|
|
@@ -524,14 +720,35 @@ export function __setRuntimeForTest(rt: LangfuseRuntime | null, timeoutMs = DEFA
|
|
|
524
720
|
export async function sendScore(name: string, value: number, options: { traceId?: string; observationId?: string } = {}) {
|
|
525
721
|
try {
|
|
526
722
|
const rt = await getRuntime();
|
|
527
|
-
|
|
723
|
+
const score: PendingScore = {
|
|
528
724
|
name,
|
|
529
725
|
value,
|
|
530
726
|
dataType: name === "session_had_errors" || name === "tool_is_error" ? "BOOLEAN" : "NUMERIC",
|
|
531
727
|
traceId: options.traceId,
|
|
532
728
|
observationId: options.observationId,
|
|
533
729
|
sessionId: options.traceId ? undefined : state.currentSessionId || undefined,
|
|
534
|
-
|
|
730
|
+
...(process.env.LANGFUSE_TRACING_ENVIRONMENT
|
|
731
|
+
? { environment: process.env.LANGFUSE_TRACING_ENVIRONMENT }
|
|
732
|
+
: {}),
|
|
733
|
+
};
|
|
734
|
+
if (!rt.pendingScores) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (rt.pendingScores.length >= MAX_SCORE_QUEUE_SIZE) {
|
|
738
|
+
const error = new Error(
|
|
739
|
+
`Langfuse score queue is full (${MAX_SCORE_QUEUE_SIZE}); dropping score`,
|
|
740
|
+
);
|
|
741
|
+
rememberRuntimeError("score queue", error);
|
|
742
|
+
console.warn(`📊 Langfuse: ${error.message}`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
rt.pendingScores.push(score);
|
|
747
|
+
if (rt.pendingScores.length >= (rt.scoreFlushAt ?? DEFAULT_SCORE_FLUSH_AT)) {
|
|
748
|
+
void startScoreFlush(rt);
|
|
749
|
+
} else {
|
|
750
|
+
scheduleScoreFlush(rt);
|
|
751
|
+
}
|
|
535
752
|
} catch (e) {
|
|
536
753
|
rememberRuntimeError(`score ${name}`, e);
|
|
537
754
|
console.warn(`📊 Langfuse: Failed to send score ${name}`, e);
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_ARRAY_ITEMS,
|
|
3
|
+
MAX_DEPTH,
|
|
4
|
+
MAX_OBJECT_KEYS,
|
|
5
|
+
MAX_PAYLOAD_NODES,
|
|
6
|
+
MAX_STRING_LENGTH,
|
|
7
|
+
MAX_TOOL_PAYLOAD_LENGTH,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import type { EnvLike } from "./capture-policy.js";
|
|
10
|
+
import { state } from "./state.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Payload-shaping limits. Every field is a positive integer, or
|
|
14
|
+
* `Number.POSITIVE_INFINITY` to disable that limit entirely (capture everything).
|
|
15
|
+
* Resolved once from the environment and stored on the loaded config; consumers
|
|
16
|
+
* read the resolved values via `getLimits()` rather than the raw constants.
|
|
17
|
+
*/
|
|
18
|
+
export interface PayloadLimits {
|
|
19
|
+
/** Max characters kept per captured string (generation/agent inputs, outputs, system prompt). */
|
|
20
|
+
readonly maxString: number;
|
|
21
|
+
/** Max characters kept for tool inputs/outputs (their payloads run larger than chat strings). */
|
|
22
|
+
readonly maxToolPayload: number;
|
|
23
|
+
/** Max nesting depth walked when shaping a structured payload. */
|
|
24
|
+
readonly maxDepth: number;
|
|
25
|
+
/** Max array elements kept per array. */
|
|
26
|
+
readonly maxArrayItems: number;
|
|
27
|
+
/** Max own-keys kept per object. */
|
|
28
|
+
readonly maxObjectKeys: number;
|
|
29
|
+
/** Max total nodes visited across a whole payload before bailing with `[payload too large]`. */
|
|
30
|
+
readonly maxNodes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_LIMITS: PayloadLimits = {
|
|
34
|
+
maxString: MAX_STRING_LENGTH,
|
|
35
|
+
maxToolPayload: MAX_TOOL_PAYLOAD_LENGTH,
|
|
36
|
+
maxDepth: MAX_DEPTH,
|
|
37
|
+
maxArrayItems: MAX_ARRAY_ITEMS,
|
|
38
|
+
maxObjectKeys: MAX_OBJECT_KEYS,
|
|
39
|
+
maxNodes: MAX_PAYLOAD_NODES,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Words that mean "no limit" when supplied as an env value. */
|
|
43
|
+
const UNLIMITED_WORDS = new Set(["off", "none", "false", "no", "unlimited", "inf", "infinity"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse one limit env value.
|
|
47
|
+
* - unset / blank / unparseable -> `fallback` (the built-in default)
|
|
48
|
+
* - "off"/"none"/"unlimited"/... or a value <= 0 -> `Infinity` (limit removed)
|
|
49
|
+
* - a positive number -> that integer
|
|
50
|
+
*/
|
|
51
|
+
export function parseLimit(raw: string | undefined, fallback: number): number {
|
|
52
|
+
if (raw === undefined) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
const trimmed = raw.trim().toLowerCase();
|
|
56
|
+
if (trimmed === "") {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
if (UNLIMITED_WORDS.has(trimmed)) {
|
|
60
|
+
return Number.POSITIVE_INFINITY;
|
|
61
|
+
}
|
|
62
|
+
const parsed = Number(trimmed);
|
|
63
|
+
if (!Number.isFinite(parsed)) {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
if (parsed <= 0) {
|
|
67
|
+
return Number.POSITIVE_INFINITY;
|
|
68
|
+
}
|
|
69
|
+
return Math.floor(parsed);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve payload limits from the environment. Each `PI_LANGFUSE_MAX_*` var overrides
|
|
74
|
+
* the corresponding default; set any to `0`/`off`/`unlimited` to remove that limit.
|
|
75
|
+
* Namespaced `PI_LANGFUSE_*` (not `LANGFUSE_*`) to avoid clashing with Langfuse
|
|
76
|
+
* server env vars such as `LANGFUSE_MAX_EVENT_SIZE_BYTES`.
|
|
77
|
+
*/
|
|
78
|
+
export function createPayloadLimits(env: EnvLike = process.env as EnvLike): PayloadLimits {
|
|
79
|
+
return {
|
|
80
|
+
maxString: parseLimit(env.PI_LANGFUSE_MAX_STRING_LENGTH, DEFAULT_LIMITS.maxString),
|
|
81
|
+
maxToolPayload: parseLimit(env.PI_LANGFUSE_MAX_TOOL_PAYLOAD_LENGTH, DEFAULT_LIMITS.maxToolPayload),
|
|
82
|
+
maxDepth: parseLimit(env.PI_LANGFUSE_MAX_DEPTH, DEFAULT_LIMITS.maxDepth),
|
|
83
|
+
maxArrayItems: parseLimit(env.PI_LANGFUSE_MAX_ARRAY_ITEMS, DEFAULT_LIMITS.maxArrayItems),
|
|
84
|
+
maxObjectKeys: parseLimit(env.PI_LANGFUSE_MAX_OBJECT_KEYS, DEFAULT_LIMITS.maxObjectKeys),
|
|
85
|
+
maxNodes: parseLimit(env.PI_LANGFUSE_MAX_PAYLOAD_NODES, DEFAULT_LIMITS.maxNodes),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolved limits for the current session: the config-loaded values when a
|
|
91
|
+
* config is active, otherwise a fresh resolve from the environment. Every
|
|
92
|
+
* capture/redaction path reads limits through this so a single env change
|
|
93
|
+
* (or config) governs truncation everywhere.
|
|
94
|
+
*/
|
|
95
|
+
export function getLimits(): PayloadLimits {
|
|
96
|
+
return state.config?.limits ?? createPayloadLimits();
|
|
97
|
+
}
|
package/src/redaction.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { getLimits } from "./limits.js";
|
|
2
3
|
|
|
3
4
|
export const REDACTED = "[REDACTED_SECRET]";
|
|
4
5
|
|
|
@@ -9,12 +10,15 @@ export interface RedactOptions {
|
|
|
9
10
|
maxStringLength: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
function defaultOptions(): RedactOptions {
|
|
14
|
+
const limits = getLimits();
|
|
15
|
+
return {
|
|
16
|
+
maxDepth: limits.maxDepth,
|
|
17
|
+
maxArrayItems: limits.maxArrayItems,
|
|
18
|
+
maxObjectKeys: limits.maxObjectKeys,
|
|
19
|
+
maxStringLength: limits.maxString,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
18
22
|
|
|
19
23
|
const SECRET_ASSIGNMENT_RE =
|
|
20
24
|
/\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASS|API[_-]?KEY|PRIVATE[_-]?KEY|AUTH|COOKIE)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
|
|
@@ -36,7 +40,7 @@ function truncate(value: string, maxStringLength: number): string {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function redactString(value: string, options: Partial<RedactOptions> = {}): string {
|
|
39
|
-
const merged = { ...
|
|
43
|
+
const merged = { ...defaultOptions(), ...options };
|
|
40
44
|
const truncated = truncate(value, merged.maxStringLength);
|
|
41
45
|
return truncated
|
|
42
46
|
.replace(PRIVATE_KEY_RE, REDACTED)
|
|
@@ -110,6 +114,6 @@ function visit(value: unknown, options: RedactOptions, depth: number, seen: Weak
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export function redactValue(value: unknown, options: Partial<RedactOptions> = {}): unknown {
|
|
113
|
-
const merged: RedactOptions = { ...
|
|
117
|
+
const merged: RedactOptions = { ...defaultOptions(), ...options };
|
|
114
118
|
return visit(value, merged, merged.maxDepth, new WeakSet<object>());
|
|
115
119
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { CapturePolicy } from "./capture-policy.js";
|
|
2
|
+
import type { PayloadLimits } from "./limits.js";
|
|
2
3
|
|
|
3
4
|
export interface Config {
|
|
4
5
|
publicKey: string;
|
|
5
6
|
secretKey: string;
|
|
6
7
|
host: string;
|
|
7
8
|
capturePolicy?: CapturePolicy;
|
|
9
|
+
limits?: PayloadLimits;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface LangfuseObservation {
|
|
@@ -57,6 +59,22 @@ export interface LangfuseScoreClient {
|
|
|
57
59
|
shutdown?: () => Promise<void>;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
export interface PendingScore {
|
|
63
|
+
traceId?: string;
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
observationId?: string;
|
|
66
|
+
name: string;
|
|
67
|
+
value: number;
|
|
68
|
+
dataType?: "NUMERIC" | "BOOLEAN";
|
|
69
|
+
environment?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LangfuseRuntimeConfig {
|
|
73
|
+
publicKey: string;
|
|
74
|
+
secretKey: string;
|
|
75
|
+
host: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
export interface LangfuseRuntime {
|
|
61
79
|
startObservation: (
|
|
62
80
|
name: string,
|
|
@@ -77,6 +95,15 @@ export interface LangfuseRuntime {
|
|
|
77
95
|
tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
|
|
78
96
|
clearTracerProvider?: () => void;
|
|
79
97
|
restFallback?: unknown;
|
|
98
|
+
pendingScores?: PendingScore[];
|
|
99
|
+
scoreFlushAt?: number;
|
|
100
|
+
scoreFlushIntervalMs?: number;
|
|
101
|
+
scoreRequestTimeoutMs?: number;
|
|
102
|
+
scoreFlushTimer?: NodeJS.Timeout;
|
|
103
|
+
scoreFlushPromise?: Promise<void>;
|
|
104
|
+
scoreFlushController?: AbortController;
|
|
105
|
+
scoreFlushStopped?: boolean;
|
|
106
|
+
runtimeConfig?: LangfuseRuntimeConfig;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
export interface GenerationState {
|
package/src/utils.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
MAX_ARRAY_ITEMS,
|
|
3
|
-
MAX_DEPTH,
|
|
4
|
-
MAX_OBJECT_KEYS,
|
|
5
|
-
MAX_PAYLOAD_NODES,
|
|
6
|
-
MAX_STRING_LENGTH,
|
|
7
|
-
MAX_TOOL_PAYLOAD_LENGTH,
|
|
8
|
-
} from "./constants.js";
|
|
1
|
+
import { getLimits } from "./limits.js";
|
|
9
2
|
import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
|
|
10
3
|
import { redactValue } from "./redaction.js";
|
|
11
4
|
import { state } from "./state.js";
|
|
@@ -14,7 +7,9 @@ export function getCapturePolicy(): CapturePolicy {
|
|
|
14
7
|
return state.config?.capturePolicy ?? createCapturePolicy();
|
|
15
8
|
}
|
|
16
9
|
|
|
17
|
-
export
|
|
10
|
+
export { getLimits };
|
|
11
|
+
|
|
12
|
+
export function truncate(value: string, maxLength = getLimits().maxString): string {
|
|
18
13
|
return value.length > maxLength ? `${value.slice(0, maxLength)}... [truncated]` : value;
|
|
19
14
|
}
|
|
20
15
|
|
|
@@ -35,11 +30,22 @@ const PAYLOAD_TOO_LARGE = "[payload too large]";
|
|
|
35
30
|
|
|
36
31
|
export function shapePayload(
|
|
37
32
|
value: unknown,
|
|
38
|
-
options: {
|
|
33
|
+
options: {
|
|
34
|
+
maxString?: number;
|
|
35
|
+
depth?: number;
|
|
36
|
+
maxNodes?: number;
|
|
37
|
+
maxArrayItems?: number;
|
|
38
|
+
maxObjectKeys?: number;
|
|
39
|
+
redact?: boolean;
|
|
40
|
+
parseJson?: boolean;
|
|
41
|
+
} = {},
|
|
39
42
|
): unknown {
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
+
const limits = getLimits();
|
|
44
|
+
const maxString = options.maxString ?? limits.maxString;
|
|
45
|
+
const depth = options.depth ?? limits.maxDepth;
|
|
46
|
+
const maxNodes = options.maxNodes ?? limits.maxNodes;
|
|
47
|
+
const maxArrayItems = options.maxArrayItems ?? limits.maxArrayItems;
|
|
48
|
+
const maxObjectKeys = options.maxObjectKeys ?? limits.maxObjectKeys;
|
|
43
49
|
const budget = { exhausted: false, nodeCount: 0 };
|
|
44
50
|
|
|
45
51
|
function visit(item: unknown, remainingDepth: number, seen: WeakSet<object>): unknown {
|
|
@@ -88,7 +94,7 @@ export function shapePayload(
|
|
|
88
94
|
|
|
89
95
|
if (Array.isArray(item)) {
|
|
90
96
|
const output: unknown[] = [];
|
|
91
|
-
const limit = Math.min(item.length,
|
|
97
|
+
const limit = Math.min(item.length, maxArrayItems);
|
|
92
98
|
for (let index = 0; index < limit; index++) {
|
|
93
99
|
output.push(visit(item[index], remainingDepth - 1, seen));
|
|
94
100
|
if (budget.exhausted) {
|
|
@@ -120,7 +126,7 @@ export function shapePayload(
|
|
|
120
126
|
}
|
|
121
127
|
output[key] = visit((item as Record<string, unknown>)[key], remainingDepth - 1, seen);
|
|
122
128
|
keyCount++;
|
|
123
|
-
if (budget.exhausted || keyCount >=
|
|
129
|
+
if (budget.exhausted || keyCount >= maxObjectKeys) {
|
|
124
130
|
break;
|
|
125
131
|
}
|
|
126
132
|
}
|
|
@@ -136,12 +142,12 @@ export function shapePayload(
|
|
|
136
142
|
: redactValue(shaped, {
|
|
137
143
|
maxDepth: depth,
|
|
138
144
|
maxStringLength: maxString,
|
|
139
|
-
maxArrayItems
|
|
140
|
-
maxObjectKeys
|
|
145
|
+
maxArrayItems,
|
|
146
|
+
maxObjectKeys,
|
|
141
147
|
});
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
export function safeSerialize(value: unknown, maxLength =
|
|
150
|
+
export function safeSerialize(value: unknown, maxLength = getLimits().maxToolPayload): string {
|
|
145
151
|
try {
|
|
146
152
|
return truncate(JSON.stringify(shapePayload(value, { maxString: maxLength }), null, 2), maxLength);
|
|
147
153
|
} catch {
|
|
@@ -149,7 +155,7 @@ export function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGT
|
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
export function estimatePayloadBytes(value: unknown, maxLength =
|
|
158
|
+
export function estimatePayloadBytes(value: unknown, maxLength = getLimits().maxToolPayload): number {
|
|
153
159
|
return new TextEncoder().encode(safeSerialize(value, maxLength)).length;
|
|
154
160
|
}
|
|
155
161
|
|