@43world/llm-logger-openclaw-plugin 0.0.3
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/DESIGN.md +352 -0
- package/README.md +80 -0
- package/index.ts +46 -0
- package/openclaw.plugin.json +40 -0
- package/package.json +14 -0
- package/src/config.ts +142 -0
- package/src/jsonl-writer.ts +52 -0
- package/src/manager.ts +844 -0
- package/src/openclaw-root.ts +52 -0
- package/src/redaction.ts +57 -0
package/src/manager.ts
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import type {
|
|
7
|
+
OpenClawPluginServiceContext,
|
|
8
|
+
OpenClawPluginApi,
|
|
9
|
+
} from "openclaw/plugin-sdk/core";
|
|
10
|
+
import type { PluginConfig, ResolvedPluginConfig } from "./config.js";
|
|
11
|
+
import { finalizePluginConfig } from "./config.js";
|
|
12
|
+
import { JsonlWriter } from "./jsonl-writer.js";
|
|
13
|
+
import { resolveOpenClawRoot } from "./openclaw-root.js";
|
|
14
|
+
import { redactHeaders, redactValue } from "./redaction.js";
|
|
15
|
+
|
|
16
|
+
type Logger = Pick<OpenClawPluginServiceContext["logger"], "debug" | "error" | "info" | "warn">;
|
|
17
|
+
|
|
18
|
+
type LlmHookContext = Parameters<OpenClawPluginApi["on"]>[1] extends never ? never : {
|
|
19
|
+
agentId?: string;
|
|
20
|
+
sessionKey?: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
workspaceDir?: string;
|
|
23
|
+
messageProvider?: string;
|
|
24
|
+
trigger?: string;
|
|
25
|
+
channelId?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type TurnMetadata = {
|
|
29
|
+
runId?: string;
|
|
30
|
+
agentId?: string;
|
|
31
|
+
sessionKey?: string;
|
|
32
|
+
workspaceDir?: string;
|
|
33
|
+
channelId?: string;
|
|
34
|
+
trigger?: string;
|
|
35
|
+
messageProvider?: string;
|
|
36
|
+
provider?: string;
|
|
37
|
+
model?: string;
|
|
38
|
+
updatedAt: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CallContext = TurnMetadata & {
|
|
42
|
+
callId: string;
|
|
43
|
+
callSequence: number;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
provider?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type StartParams = {
|
|
50
|
+
pluginId: string;
|
|
51
|
+
stateDir: string;
|
|
52
|
+
workspaceDir?: string;
|
|
53
|
+
defaultLogFile: string;
|
|
54
|
+
logger: Logger;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ActiveState = {
|
|
58
|
+
pluginId: string;
|
|
59
|
+
logger: Logger;
|
|
60
|
+
config: ResolvedPluginConfig;
|
|
61
|
+
writers: Map<string, JsonlWriter>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type AgentLike = {
|
|
65
|
+
_sessionId?: string;
|
|
66
|
+
streamFn: (
|
|
67
|
+
model: unknown,
|
|
68
|
+
context: unknown,
|
|
69
|
+
options?: Record<string, unknown>,
|
|
70
|
+
) => unknown;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type FetchRequestSummary = {
|
|
74
|
+
url: string;
|
|
75
|
+
method: string;
|
|
76
|
+
headers: Record<string, string>;
|
|
77
|
+
body?: unknown;
|
|
78
|
+
truncated?: boolean;
|
|
79
|
+
bodyBytes?: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type FetchResponseSummary = {
|
|
83
|
+
status: number;
|
|
84
|
+
headers: Record<string, string>;
|
|
85
|
+
body?: unknown;
|
|
86
|
+
truncated?: boolean;
|
|
87
|
+
bodyBytes?: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const GLOBAL_KEY = Symbol.for("llm-logger-openclaw-plugin.manager");
|
|
91
|
+
const WEBSOCKET_CALL_CONTEXT = Symbol.for("llm-logger-openclaw-plugin.wsCallContext");
|
|
92
|
+
const OBSERVED_WS_PATHS = ["/v1/responses"];
|
|
93
|
+
const TURN_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
94
|
+
const UNKNOWN_SESSION_DIR = "_unknown_session";
|
|
95
|
+
|
|
96
|
+
type PatchHandles = {
|
|
97
|
+
restore: () => void;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
class PluginManager {
|
|
101
|
+
#registrationLogger: Logger | null = null;
|
|
102
|
+
#pluginConfig: PluginConfig = {
|
|
103
|
+
enabled: true,
|
|
104
|
+
maxBodyBytes: 262_144,
|
|
105
|
+
redactAuthorization: true,
|
|
106
|
+
includeHooks: true,
|
|
107
|
+
includeHttp: true,
|
|
108
|
+
includeWebSocket: true,
|
|
109
|
+
};
|
|
110
|
+
#activeState: ActiveState | null = null;
|
|
111
|
+
#refCount = 0;
|
|
112
|
+
#patchHandles: PatchHandles | null = null;
|
|
113
|
+
#callContextStorage = new AsyncLocalStorage<CallContext>();
|
|
114
|
+
#turnMetadataBySessionId = new Map<string, TurnMetadata>();
|
|
115
|
+
|
|
116
|
+
setRegistrationLogger(logger: Logger): void {
|
|
117
|
+
this.#registrationLogger = logger;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setPluginConfig(config: PluginConfig): void {
|
|
121
|
+
this.#pluginConfig = config;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async start(params: StartParams): Promise<void> {
|
|
125
|
+
this.#refCount += 1;
|
|
126
|
+
if (this.#activeState) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const config = finalizePluginConfig(this.#pluginConfig, params.defaultLogFile);
|
|
131
|
+
this.#activeState = {
|
|
132
|
+
pluginId: params.pluginId,
|
|
133
|
+
logger: params.logger,
|
|
134
|
+
config,
|
|
135
|
+
writers: new Map(),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (!config.enabled) {
|
|
139
|
+
params.logger.info(`[${params.pluginId}] disabled by plugin config`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
this.#patchHandles = await this.#installPatches({
|
|
145
|
+
workspaceDir: params.workspaceDir,
|
|
146
|
+
});
|
|
147
|
+
params.logger.info(
|
|
148
|
+
`[${params.pluginId}] logging LLM traffic under session directories based on ${config.logFile} (maxBodyBytes=${config.maxBodyBytes})`,
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
params.logger.warn(`[${params.pluginId}] failed to install runtime patches: ${this.#formatError(error)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async stop(): Promise<void> {
|
|
156
|
+
this.#refCount = Math.max(0, this.#refCount - 1);
|
|
157
|
+
if (this.#refCount > 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.#patchHandles?.restore();
|
|
162
|
+
this.#patchHandles = null;
|
|
163
|
+
|
|
164
|
+
if (this.#activeState) {
|
|
165
|
+
await Promise.all(Array.from(this.#activeState.writers.values(), (writer) => writer.close()));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.#activeState = null;
|
|
169
|
+
this.#turnMetadataBySessionId.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
recordLlmInput(event: Record<string, unknown>, ctx: LlmHookContext): void {
|
|
173
|
+
const state = this.#activeState;
|
|
174
|
+
if (!state?.config.enabled || !state.config.includeHooks) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const sessionId =
|
|
179
|
+
typeof event.sessionId === "string" && event.sessionId.trim().length > 0
|
|
180
|
+
? event.sessionId
|
|
181
|
+
: ctx.sessionId;
|
|
182
|
+
if (sessionId) {
|
|
183
|
+
this.#turnMetadataBySessionId.set(sessionId, {
|
|
184
|
+
runId: typeof event.runId === "string" ? event.runId : undefined,
|
|
185
|
+
agentId: ctx.agentId,
|
|
186
|
+
sessionKey: ctx.sessionKey,
|
|
187
|
+
workspaceDir: ctx.workspaceDir,
|
|
188
|
+
channelId: ctx.channelId,
|
|
189
|
+
trigger: ctx.trigger,
|
|
190
|
+
messageProvider: ctx.messageProvider,
|
|
191
|
+
provider: typeof event.provider === "string" ? event.provider : undefined,
|
|
192
|
+
model: typeof event.model === "string" ? event.model : undefined,
|
|
193
|
+
updatedAt: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.#write({
|
|
198
|
+
eventType: "llm_input",
|
|
199
|
+
sessionId,
|
|
200
|
+
runId: typeof event.runId === "string" ? event.runId : undefined,
|
|
201
|
+
provider: typeof event.provider === "string" ? event.provider : undefined,
|
|
202
|
+
model: typeof event.model === "string" ? event.model : undefined,
|
|
203
|
+
agentId: ctx.agentId,
|
|
204
|
+
sessionKey: ctx.sessionKey,
|
|
205
|
+
workspaceDir: ctx.workspaceDir,
|
|
206
|
+
channelId: ctx.channelId,
|
|
207
|
+
trigger: ctx.trigger,
|
|
208
|
+
messageProvider: ctx.messageProvider,
|
|
209
|
+
payload: this.#redact(event),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
recordLlmOutput(event: Record<string, unknown>, ctx: LlmHookContext): void {
|
|
214
|
+
const state = this.#activeState;
|
|
215
|
+
if (!state?.config.enabled || !state.config.includeHooks) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sessionId =
|
|
220
|
+
typeof event.sessionId === "string" && event.sessionId.trim().length > 0
|
|
221
|
+
? event.sessionId
|
|
222
|
+
: ctx.sessionId;
|
|
223
|
+
|
|
224
|
+
this.#write({
|
|
225
|
+
eventType: "llm_output",
|
|
226
|
+
sessionId,
|
|
227
|
+
runId: typeof event.runId === "string" ? event.runId : undefined,
|
|
228
|
+
provider: typeof event.provider === "string" ? event.provider : undefined,
|
|
229
|
+
model: typeof event.model === "string" ? event.model : undefined,
|
|
230
|
+
agentId: ctx.agentId,
|
|
231
|
+
sessionKey: ctx.sessionKey,
|
|
232
|
+
workspaceDir: ctx.workspaceDir,
|
|
233
|
+
channelId: ctx.channelId,
|
|
234
|
+
trigger: ctx.trigger,
|
|
235
|
+
messageProvider: ctx.messageProvider,
|
|
236
|
+
payload: this.#redact(event),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#logger(): Logger {
|
|
241
|
+
return this.#activeState?.logger ?? this.#registrationLogger ?? console;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#currentConfig(): ResolvedPluginConfig | null {
|
|
245
|
+
return this.#activeState?.config ?? null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#currentCallContext(): CallContext | undefined {
|
|
249
|
+
return this.#callContextStorage.getStore();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#shouldCaptureHttp(): boolean {
|
|
253
|
+
return this.#currentConfig()?.enabled === true && this.#currentConfig()?.includeHttp === true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#shouldCaptureWebSocket(): boolean {
|
|
257
|
+
return (
|
|
258
|
+
this.#currentConfig()?.enabled === true && this.#currentConfig()?.includeWebSocket === true
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#redact(value: unknown): unknown {
|
|
263
|
+
const config = this.#currentConfig();
|
|
264
|
+
return redactValue(value, config?.redactAuthorization !== false);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#mergeTurnMetadata(callContext: CallContext): CallContext {
|
|
268
|
+
if (!callContext.sessionId) {
|
|
269
|
+
return callContext;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const turnMetadata = this.#turnMetadataBySessionId.get(callContext.sessionId);
|
|
273
|
+
if (!turnMetadata) {
|
|
274
|
+
return callContext;
|
|
275
|
+
}
|
|
276
|
+
if (Date.now() - turnMetadata.updatedAt > TURN_METADATA_TTL_MS) {
|
|
277
|
+
this.#turnMetadataBySessionId.delete(callContext.sessionId);
|
|
278
|
+
return callContext;
|
|
279
|
+
}
|
|
280
|
+
if (
|
|
281
|
+
turnMetadata.provider &&
|
|
282
|
+
callContext.provider &&
|
|
283
|
+
turnMetadata.provider !== callContext.provider
|
|
284
|
+
) {
|
|
285
|
+
return callContext;
|
|
286
|
+
}
|
|
287
|
+
if (turnMetadata.model && callContext.model && turnMetadata.model !== callContext.model) {
|
|
288
|
+
return callContext;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...turnMetadata,
|
|
293
|
+
...callContext,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async #installPatches(params: {
|
|
298
|
+
workspaceDir?: string;
|
|
299
|
+
}): Promise<PatchHandles> {
|
|
300
|
+
const openClawRoot = resolveOpenClawRoot({
|
|
301
|
+
workspaceDir: params.workspaceDir,
|
|
302
|
+
});
|
|
303
|
+
const openClawRequire = createRequire(path.join(openClawRoot, "package.json"));
|
|
304
|
+
const agentModulePath = openClawRequire.resolve("@mariozechner/pi-agent-core");
|
|
305
|
+
const agentModule = (await import(pathToFileURL(agentModulePath).href)) as {
|
|
306
|
+
Agent?: {
|
|
307
|
+
prototype: {
|
|
308
|
+
_runLoop?: (...args: unknown[]) => Promise<unknown>;
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
const AgentCtor = agentModule.Agent;
|
|
313
|
+
if (!AgentCtor?.prototype?._runLoop) {
|
|
314
|
+
throw new Error("unable to locate Agent.prototype._runLoop");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const wsModule = openClawRequire("ws") as {
|
|
318
|
+
prototype?: {
|
|
319
|
+
send?: (...args: unknown[]) => unknown;
|
|
320
|
+
emit?: (...args: unknown[]) => unknown;
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
if (!wsModule?.prototype?.send || !wsModule?.prototype?.emit) {
|
|
324
|
+
throw new Error("unable to locate ws prototype");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const originalRunLoop = AgentCtor.prototype._runLoop;
|
|
328
|
+
const originalFetch = globalThis.fetch;
|
|
329
|
+
const originalWsSend = wsModule.prototype.send;
|
|
330
|
+
const originalWsEmit = wsModule.prototype.emit;
|
|
331
|
+
|
|
332
|
+
const manager = this;
|
|
333
|
+
|
|
334
|
+
AgentCtor.prototype._runLoop = async function patchedRunLoop(
|
|
335
|
+
this: AgentLike,
|
|
336
|
+
...args: unknown[]
|
|
337
|
+
): Promise<unknown> {
|
|
338
|
+
const originalStreamFn = this.streamFn;
|
|
339
|
+
let callSequence = 0;
|
|
340
|
+
|
|
341
|
+
this.streamFn = (model: unknown, context: unknown, options?: Record<string, unknown>) => {
|
|
342
|
+
callSequence += 1;
|
|
343
|
+
const baseContext = manager.#mergeTurnMetadata({
|
|
344
|
+
callId: randomUUID(),
|
|
345
|
+
callSequence,
|
|
346
|
+
sessionId: typeof this._sessionId === "string" ? this._sessionId : undefined,
|
|
347
|
+
provider: manager.#readString(model, "provider"),
|
|
348
|
+
model: manager.#readString(model, "id"),
|
|
349
|
+
updatedAt: Date.now(),
|
|
350
|
+
});
|
|
351
|
+
const previousOnPayload = options?.onPayload;
|
|
352
|
+
const nextOptions = {
|
|
353
|
+
...options,
|
|
354
|
+
onPayload: async (payload: unknown, payloadModel: unknown) => {
|
|
355
|
+
manager.#write({
|
|
356
|
+
eventType: "provider_request_payload",
|
|
357
|
+
...baseContext,
|
|
358
|
+
payload: manager.#redact(payload),
|
|
359
|
+
payloadModel:
|
|
360
|
+
payloadModel && typeof payloadModel === "object"
|
|
361
|
+
? manager.#redact(payloadModel)
|
|
362
|
+
: payloadModel,
|
|
363
|
+
});
|
|
364
|
+
if (typeof previousOnPayload === "function") {
|
|
365
|
+
return await previousOnPayload(payload, payloadModel);
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
return manager.#callContextStorage.run(baseContext, () =>
|
|
372
|
+
originalStreamFn.call(this, model, context, nextOptions),
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
return await originalRunLoop.apply(this, args);
|
|
378
|
+
} finally {
|
|
379
|
+
this.streamFn = originalStreamFn;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
globalThis.fetch = async function patchedFetch(
|
|
384
|
+
input: RequestInfo | URL,
|
|
385
|
+
init?: RequestInit,
|
|
386
|
+
): Promise<Response> {
|
|
387
|
+
const callContext = manager.#currentCallContext();
|
|
388
|
+
if (!callContext || !manager.#shouldCaptureHttp()) {
|
|
389
|
+
return originalFetch(input, init);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const startedAt = Date.now();
|
|
393
|
+
const requestSummary = await manager.#summarizeFetchRequest(input, init);
|
|
394
|
+
manager.#write({
|
|
395
|
+
eventType: "http_request",
|
|
396
|
+
...callContext,
|
|
397
|
+
request: requestSummary,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const response = await originalFetch(input, init);
|
|
402
|
+
void manager.#captureFetchResponse(callContext, response, Date.now() - startedAt);
|
|
403
|
+
return response;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
manager.#write({
|
|
406
|
+
eventType: "http_error",
|
|
407
|
+
...callContext,
|
|
408
|
+
request: requestSummary,
|
|
409
|
+
durationMs: Date.now() - startedAt,
|
|
410
|
+
error: manager.#formatError(error),
|
|
411
|
+
});
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
wsModule.prototype.send = function patchedWsSend(
|
|
417
|
+
this: Record<PropertyKey, unknown> & { url?: unknown },
|
|
418
|
+
data: unknown,
|
|
419
|
+
...args: unknown[]
|
|
420
|
+
) {
|
|
421
|
+
const callContext = manager.#currentCallContext();
|
|
422
|
+
const url = typeof this.url === "string" ? this.url : undefined;
|
|
423
|
+
if (callContext && url && manager.#shouldCaptureWebSocket() && manager.#isObservedWsUrl(url)) {
|
|
424
|
+
this[WEBSOCKET_CALL_CONTEXT] = callContext;
|
|
425
|
+
manager.#write({
|
|
426
|
+
eventType: "ws_send",
|
|
427
|
+
...callContext,
|
|
428
|
+
url,
|
|
429
|
+
payload: manager.#summarizeSocketData(data),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return originalWsSend.call(this, data, ...args);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
wsModule.prototype.emit = function patchedWsEmit(
|
|
436
|
+
this: Record<PropertyKey, unknown> & { url?: unknown },
|
|
437
|
+
eventName: unknown,
|
|
438
|
+
...args: unknown[]
|
|
439
|
+
) {
|
|
440
|
+
const url = typeof this.url === "string" ? this.url : undefined;
|
|
441
|
+
if (
|
|
442
|
+
eventName === "message" &&
|
|
443
|
+
url &&
|
|
444
|
+
manager.#shouldCaptureWebSocket() &&
|
|
445
|
+
manager.#isObservedWsUrl(url)
|
|
446
|
+
) {
|
|
447
|
+
const callContext = this[WEBSOCKET_CALL_CONTEXT] as CallContext | undefined;
|
|
448
|
+
if (callContext) {
|
|
449
|
+
manager.#write({
|
|
450
|
+
eventType: "ws_message",
|
|
451
|
+
...callContext,
|
|
452
|
+
url,
|
|
453
|
+
payload: manager.#summarizeSocketData(args[0]),
|
|
454
|
+
isBinary: Boolean(args[1]),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return originalWsEmit.call(this, eventName, ...args);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
restore() {
|
|
464
|
+
AgentCtor.prototype._runLoop = originalRunLoop;
|
|
465
|
+
globalThis.fetch = originalFetch;
|
|
466
|
+
wsModule.prototype?.send && (wsModule.prototype.send = originalWsSend);
|
|
467
|
+
wsModule.prototype?.emit && (wsModule.prototype.emit = originalWsEmit);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#write(record: Record<string, unknown>): void {
|
|
473
|
+
const state = this.#activeState;
|
|
474
|
+
if (!state?.config.enabled) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const event = {
|
|
479
|
+
ts: new Date().toISOString(),
|
|
480
|
+
plugin: state.pluginId,
|
|
481
|
+
sessionId: this.#currentCallContext()?.sessionId,
|
|
482
|
+
...record,
|
|
483
|
+
};
|
|
484
|
+
const sessionId =
|
|
485
|
+
typeof event.sessionId === "string" && event.sessionId.trim().length > 0
|
|
486
|
+
? event.sessionId
|
|
487
|
+
: undefined;
|
|
488
|
+
const writer = this.#getWriterForEvent(state, sessionId, new Date());
|
|
489
|
+
|
|
490
|
+
void writer.write(event).catch((error) => {
|
|
491
|
+
this.#logger().warn(`[${state.pluginId}] failed to write log entry: ${this.#formatError(error)}`);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#getWriterForEvent(state: ActiveState, sessionId: string | undefined, now: Date): JsonlWriter {
|
|
496
|
+
const sessionDir = this.#sanitizeSessionDirName(sessionId);
|
|
497
|
+
const dateSuffix = this.#formatDateSuffix(now);
|
|
498
|
+
const key = `${sessionDir}|${dateSuffix}`;
|
|
499
|
+
const cachedWriter = state.writers.get(key);
|
|
500
|
+
if (cachedWriter) {
|
|
501
|
+
return cachedWriter;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const filePath = this.#buildSessionLogPath(state.config.logFile, sessionDir, dateSuffix);
|
|
505
|
+
const writer = new JsonlWriter(filePath);
|
|
506
|
+
state.writers.set(key, writer);
|
|
507
|
+
return writer;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
#buildSessionLogPath(baseLogFile: string, sessionDir: string, dateSuffix: string): string {
|
|
511
|
+
const parsed = path.parse(baseLogFile);
|
|
512
|
+
const ext = parsed.ext || ".jsonl";
|
|
513
|
+
const baseName = parsed.name || "llm-log";
|
|
514
|
+
const filename = `${baseName}-${dateSuffix}${ext}`;
|
|
515
|
+
return path.join(parsed.dir, sessionDir, filename);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#sanitizeSessionDirName(sessionId: string | undefined): string {
|
|
519
|
+
if (!sessionId) {
|
|
520
|
+
return UNKNOWN_SESSION_DIR;
|
|
521
|
+
}
|
|
522
|
+
const normalized = sessionId.trim();
|
|
523
|
+
if (!normalized) {
|
|
524
|
+
return UNKNOWN_SESSION_DIR;
|
|
525
|
+
}
|
|
526
|
+
return normalized.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#formatDateSuffix(date: Date): string {
|
|
530
|
+
const year = date.getFullYear();
|
|
531
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
532
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
533
|
+
return `${year}-${month}-${day}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async #captureFetchResponse(
|
|
537
|
+
callContext: CallContext,
|
|
538
|
+
response: Response,
|
|
539
|
+
durationMs: number,
|
|
540
|
+
): Promise<void> {
|
|
541
|
+
let summary: FetchResponseSummary = {
|
|
542
|
+
status: response.status,
|
|
543
|
+
headers: redactHeaders(
|
|
544
|
+
this.#headersToRecord(response.headers),
|
|
545
|
+
this.#currentConfig()?.redactAuthorization !== false,
|
|
546
|
+
),
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
const captured = await this.#captureResponseBody(response.clone());
|
|
551
|
+
summary = {
|
|
552
|
+
...summary,
|
|
553
|
+
...captured,
|
|
554
|
+
};
|
|
555
|
+
} catch (error) {
|
|
556
|
+
summary = {
|
|
557
|
+
...summary,
|
|
558
|
+
body: `[capture failed: ${this.#formatError(error)}]`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this.#write({
|
|
563
|
+
eventType: "http_response",
|
|
564
|
+
...callContext,
|
|
565
|
+
durationMs,
|
|
566
|
+
response: summary,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async #summarizeFetchRequest(
|
|
571
|
+
input: RequestInfo | URL,
|
|
572
|
+
init?: RequestInit,
|
|
573
|
+
): Promise<FetchRequestSummary> {
|
|
574
|
+
const url = this.#resolveRequestUrl(input);
|
|
575
|
+
const method = this.#resolveRequestMethod(input, init);
|
|
576
|
+
const headers = redactHeaders(
|
|
577
|
+
this.#resolveRequestHeaders(input, init),
|
|
578
|
+
this.#currentConfig()?.redactAuthorization !== false,
|
|
579
|
+
);
|
|
580
|
+
const body = await this.#captureRequestBody(input, init);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
url,
|
|
584
|
+
method,
|
|
585
|
+
headers,
|
|
586
|
+
...(body ?? {}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#resolveRequestUrl(input: RequestInfo | URL): string {
|
|
591
|
+
if (typeof input === "string") {
|
|
592
|
+
return input;
|
|
593
|
+
}
|
|
594
|
+
if (input instanceof URL) {
|
|
595
|
+
return input.toString();
|
|
596
|
+
}
|
|
597
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
598
|
+
return input.url;
|
|
599
|
+
}
|
|
600
|
+
return String(input);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
#resolveRequestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
604
|
+
if (typeof init?.method === "string" && init.method.trim().length > 0) {
|
|
605
|
+
return init.method.toUpperCase();
|
|
606
|
+
}
|
|
607
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
608
|
+
return input.method.toUpperCase();
|
|
609
|
+
}
|
|
610
|
+
return "GET";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#resolveRequestHeaders(input: RequestInfo | URL, init?: RequestInit): Record<string, string> {
|
|
614
|
+
const merged = new Headers();
|
|
615
|
+
|
|
616
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
617
|
+
input.headers.forEach((value, key) => {
|
|
618
|
+
merged.set(key, value);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const initHeaders = new Headers(init?.headers ?? undefined);
|
|
623
|
+
initHeaders.forEach((value, key) => {
|
|
624
|
+
merged.set(key, value);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return this.#headersToRecord(merged);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async #captureRequestBody(
|
|
631
|
+
input: RequestInfo | URL,
|
|
632
|
+
init?: RequestInit,
|
|
633
|
+
): Promise<Partial<FetchRequestSummary> | undefined> {
|
|
634
|
+
const maxBodyBytes = this.#currentConfig()?.maxBodyBytes ?? 262_144;
|
|
635
|
+
|
|
636
|
+
if (init?.body !== undefined) {
|
|
637
|
+
return await this.#captureBodyLike(init.body as Request | BodyInit, maxBodyBytes);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (typeof Request !== "undefined" && input instanceof Request && !input.bodyUsed) {
|
|
641
|
+
try {
|
|
642
|
+
return await this.#captureBodyLike(input.clone(), maxBodyBytes);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
return {
|
|
645
|
+
body: `[capture failed: ${this.#formatError(error)}]`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async #captureResponseBody(
|
|
654
|
+
response: Response,
|
|
655
|
+
): Promise<Partial<FetchResponseSummary>> {
|
|
656
|
+
if (!response.body) {
|
|
657
|
+
return {};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const captured = await this.#readStreamLimit(response.body);
|
|
661
|
+
return {
|
|
662
|
+
body: this.#maybeParseJson(captured.text),
|
|
663
|
+
truncated: captured.truncated,
|
|
664
|
+
bodyBytes: captured.bodyBytes,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async #captureBodyLike(
|
|
669
|
+
body: BodyInit | Request,
|
|
670
|
+
maxBodyBytes: number,
|
|
671
|
+
): Promise<Partial<FetchRequestSummary>> {
|
|
672
|
+
if (typeof body === "string") {
|
|
673
|
+
return this.#captureTextBody(body, maxBodyBytes);
|
|
674
|
+
}
|
|
675
|
+
if (body instanceof URLSearchParams) {
|
|
676
|
+
return this.#captureTextBody(body.toString(), maxBodyBytes);
|
|
677
|
+
}
|
|
678
|
+
if (body instanceof ArrayBuffer) {
|
|
679
|
+
return this.#captureBinaryBody(new Uint8Array(body), maxBodyBytes);
|
|
680
|
+
}
|
|
681
|
+
if (ArrayBuffer.isView(body)) {
|
|
682
|
+
return this.#captureBinaryBody(new Uint8Array(body.buffer, body.byteOffset, body.byteLength), maxBodyBytes);
|
|
683
|
+
}
|
|
684
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
685
|
+
return this.#captureTextBody(await body.text(), maxBodyBytes);
|
|
686
|
+
}
|
|
687
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
688
|
+
return {
|
|
689
|
+
body: "[form-data]",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (typeof Request !== "undefined" && body instanceof Request) {
|
|
693
|
+
return this.#captureTextBody(await body.text(), maxBodyBytes);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
body: `[unsupported body type: ${Object.prototype.toString.call(body)}]`,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
#captureBinaryBody(
|
|
702
|
+
buffer: Uint8Array,
|
|
703
|
+
maxBodyBytes: number,
|
|
704
|
+
): Partial<FetchRequestSummary> {
|
|
705
|
+
const truncated = buffer.byteLength > maxBodyBytes;
|
|
706
|
+
const limited = truncated ? buffer.subarray(0, maxBodyBytes) : buffer;
|
|
707
|
+
return {
|
|
708
|
+
body: Buffer.from(limited).toString("base64"),
|
|
709
|
+
truncated,
|
|
710
|
+
bodyBytes: limited.byteLength,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#captureTextBody(text: string, maxBodyBytes: number): Partial<FetchRequestSummary> {
|
|
715
|
+
const buffer = Buffer.from(text, "utf8");
|
|
716
|
+
const truncated = buffer.byteLength > maxBodyBytes;
|
|
717
|
+
const limited = truncated ? buffer.subarray(0, maxBodyBytes) : buffer;
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
body: this.#maybeParseJson(limited.toString("utf8")),
|
|
721
|
+
truncated,
|
|
722
|
+
bodyBytes: limited.byteLength,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async #readStreamLimit(
|
|
727
|
+
stream: ReadableStream<Uint8Array>,
|
|
728
|
+
): Promise<{ text: string; truncated: boolean; bodyBytes: number }> {
|
|
729
|
+
const maxBodyBytes = this.#currentConfig()?.maxBodyBytes ?? 262_144;
|
|
730
|
+
const reader = stream.getReader();
|
|
731
|
+
const chunks: Uint8Array[] = [];
|
|
732
|
+
let totalBytes = 0;
|
|
733
|
+
let capturedBytes = 0;
|
|
734
|
+
let truncated = false;
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
while (true) {
|
|
738
|
+
const { done, value } = await reader.read();
|
|
739
|
+
if (done) {
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
743
|
+
totalBytes += chunk.byteLength;
|
|
744
|
+
|
|
745
|
+
if (capturedBytes < maxBodyBytes) {
|
|
746
|
+
const remaining = maxBodyBytes - capturedBytes;
|
|
747
|
+
const slice = chunk.subarray(0, remaining);
|
|
748
|
+
chunks.push(slice);
|
|
749
|
+
capturedBytes += slice.byteLength;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (totalBytes > maxBodyBytes) {
|
|
753
|
+
truncated = true;
|
|
754
|
+
void reader.cancel().catch(() => {});
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} finally {
|
|
759
|
+
reader.releaseLock();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
text: new TextDecoder().decode(this.#concatUint8Arrays(chunks)),
|
|
764
|
+
truncated,
|
|
765
|
+
bodyBytes: capturedBytes,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
#concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
|
|
770
|
+
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
771
|
+
const merged = new Uint8Array(totalSize);
|
|
772
|
+
let offset = 0;
|
|
773
|
+
for (const chunk of chunks) {
|
|
774
|
+
merged.set(chunk, offset);
|
|
775
|
+
offset += chunk.byteLength;
|
|
776
|
+
}
|
|
777
|
+
return merged;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#headersToRecord(headers: Headers): Record<string, string> {
|
|
781
|
+
const record: Record<string, string> = {};
|
|
782
|
+
headers.forEach((value, key) => {
|
|
783
|
+
record[key] = value;
|
|
784
|
+
});
|
|
785
|
+
return record;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#summarizeSocketData(data: unknown): unknown {
|
|
789
|
+
const maxBodyBytes = this.#currentConfig()?.maxBodyBytes ?? 262_144;
|
|
790
|
+
|
|
791
|
+
if (typeof data === "string") {
|
|
792
|
+
return this.#captureTextBody(data, maxBodyBytes).body;
|
|
793
|
+
}
|
|
794
|
+
if (data instanceof ArrayBuffer) {
|
|
795
|
+
return this.#captureBinaryBody(new Uint8Array(data), maxBodyBytes).body;
|
|
796
|
+
}
|
|
797
|
+
if (ArrayBuffer.isView(data)) {
|
|
798
|
+
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
799
|
+
return this.#maybeParseJson(Buffer.from(view.subarray(0, maxBodyBytes)).toString("utf8"));
|
|
800
|
+
}
|
|
801
|
+
return String(data);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#isObservedWsUrl(url: string): boolean {
|
|
805
|
+
return OBSERVED_WS_PATHS.some((pathname) => url.includes(pathname));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#readString(value: unknown, key: string): string | undefined {
|
|
809
|
+
if (!value || typeof value !== "object") {
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
const candidate = (value as Record<string, unknown>)[key];
|
|
813
|
+
return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : undefined;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#maybeParseJson(text: string): unknown {
|
|
817
|
+
const trimmed = text.trim();
|
|
818
|
+
if (!trimmed) {
|
|
819
|
+
return "";
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
return this.#redact(JSON.parse(trimmed));
|
|
823
|
+
} catch {
|
|
824
|
+
return trimmed;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
#formatError(error: unknown): string {
|
|
829
|
+
if (error instanceof Error) {
|
|
830
|
+
return error.stack ?? error.message;
|
|
831
|
+
}
|
|
832
|
+
return String(error);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function getPluginManager(): PluginManager {
|
|
837
|
+
const globalStore = globalThis as typeof globalThis & {
|
|
838
|
+
[GLOBAL_KEY]?: PluginManager;
|
|
839
|
+
};
|
|
840
|
+
if (!globalStore[GLOBAL_KEY]) {
|
|
841
|
+
globalStore[GLOBAL_KEY] = new PluginManager();
|
|
842
|
+
}
|
|
843
|
+
return globalStore[GLOBAL_KEY]!;
|
|
844
|
+
}
|