| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { computeContextKey } from "./contextKey.ts";
|
| import { detectKind } from "./kindDetector.ts";
|
| import type { InterceptedRequest, ListFilters, WsEvent } from "./types.ts";
|
|
|
| const TRUNCATION_MARKER = "\n…(truncated for performance)";
|
|
|
| function parseEnvNumber(value: string | undefined, fallback: number): number {
|
| if (!value) return fallback;
|
| const parsed = Number(value);
|
| if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
| return parsed;
|
| }
|
|
|
| function getMaxBodyBytes(): number {
|
| const kb = parseEnvNumber(process.env.INSPECTOR_MAX_BODY_KB, 1024);
|
| return Math.max(1, Math.floor(kb)) * 1024;
|
| }
|
|
|
| function capBody(body: string | null, maxBytes: number): string | null {
|
| if (body == null) return body;
|
| if (body.length <= maxBytes) return body;
|
| return body.slice(0, maxBytes) + TRUNCATION_MARKER;
|
| }
|
|
|
| function statusBucket(status: InterceptedRequest["status"]): string {
|
| if (status === "error") return "error";
|
| if (status === "in-flight") return "in-flight";
|
| if (typeof status !== "number") return "unknown";
|
| if (status >= 200 && status < 300) return "2xx";
|
| if (status >= 300 && status < 400) return "3xx";
|
| if (status >= 400 && status < 500) return "4xx";
|
| if (status >= 500 && status < 600) return "5xx";
|
| return "unknown";
|
| }
|
|
|
| function matchesFilters(req: InterceptedRequest, filters?: ListFilters): boolean {
|
| if (!filters) return true;
|
|
|
| if (filters.profile && filters.profile !== "all") {
|
| if (filters.profile === "llm" && req.detectedKind !== "llm") return false;
|
| if (filters.profile === "custom" && req.source !== "custom-host") return false;
|
| }
|
|
|
| if (filters.host && req.host !== filters.host) return false;
|
| if (filters.agent && req.agent !== filters.agent) return false;
|
| if (filters.source && req.source !== filters.source) return false;
|
| if (filters.sessionId && req.sessionId !== filters.sessionId) return false;
|
|
|
| if (filters.status) {
|
| const bucket = statusBucket(req.status);
|
| if (bucket !== filters.status) return false;
|
| }
|
|
|
| return true;
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export class TrafficBuffer {
|
| private buffer: InterceptedRequest[] = [];
|
| private subscribers = new Set<(ev: WsEvent) => void>();
|
| private maxSize: number;
|
| private maxBodyBytes: number;
|
|
|
| constructor(
|
| maxSize: number = parseEnvNumber(process.env.INSPECTOR_BUFFER_SIZE, 1000),
|
| maxBodyBytes: number = getMaxBodyBytes()
|
| ) {
|
| this.maxSize = Math.max(1, Math.floor(maxSize));
|
| this.maxBodyBytes = Math.max(1, Math.floor(maxBodyBytes));
|
| }
|
|
|
| |
| |
| |
| |
|
|
| push(req: InterceptedRequest): void {
|
| if (!req.detectedKind) {
|
| req.detectedKind = detectKind(req);
|
| }
|
| if (!req.contextKey && req.detectedKind === "llm") {
|
| const key = computeContextKey(req);
|
| if (key) req.contextKey = key;
|
| }
|
|
|
| req.requestBody = capBody(req.requestBody, this.maxBodyBytes);
|
| req.responseBody = capBody(req.responseBody, this.maxBodyBytes);
|
|
|
| this.buffer.push(req);
|
| while (this.buffer.length > this.maxSize) {
|
| this.buffer.shift();
|
| }
|
|
|
| this.broadcast({ type: "new", data: req });
|
| }
|
|
|
| |
| |
| |
|
|
| update(id: string, req: InterceptedRequest): void {
|
| const idx = this.buffer.findIndex((r) => r.id === id);
|
| if (idx < 0) return;
|
|
|
| req.requestBody = capBody(req.requestBody, this.maxBodyBytes);
|
| req.responseBody = capBody(req.responseBody, this.maxBodyBytes);
|
|
|
| this.buffer[idx] = req;
|
| this.broadcast({ type: "update", data: req });
|
| }
|
|
|
| |
| |
|
|
| get(id: string): InterceptedRequest | null {
|
| return this.buffer.find((r) => r.id === id) ?? null;
|
| }
|
|
|
| |
| |
| |
|
|
| list(filters?: ListFilters): InterceptedRequest[] {
|
| if (!filters) return [...this.buffer];
|
| return this.buffer.filter((r) => matchesFilters(r, filters));
|
| }
|
|
|
| |
| |
|
|
| clear(): void {
|
| this.buffer = [];
|
| this.broadcast({ type: "clear" });
|
| }
|
|
|
| |
| |
| |
|
|
| subscribe(fn: (ev: WsEvent) => void): () => void {
|
| this.subscribers.add(fn);
|
| try {
|
| fn({ type: "snapshot", data: [...this.buffer] });
|
| } catch {
|
|
|
| }
|
| return () => {
|
| this.subscribers.delete(fn);
|
| };
|
| }
|
|
|
| |
| |
|
|
| subscriberCount(): number {
|
| return this.subscribers.size;
|
| }
|
|
|
| |
| |
|
|
| size(): number {
|
| return this.buffer.length;
|
| }
|
|
|
| private broadcast(ev: WsEvent): void {
|
| for (const fn of this.subscribers) {
|
| try {
|
| fn(ev);
|
| } catch {
|
|
|
| }
|
| }
|
| }
|
| }
|
|
|
| |
| |
| |
|
|
| export const globalTrafficBuffer = new TrafficBuffer();
|
|
|