feat: TypeScript SDK (agentlens-sdk) and OpenCode plugin (opencode-agentlens)

- packages/sdk-ts: BatchTransport, TraceBuilder, models, decision helpers
  Zero external deps, native fetch, ESM+CJS output
- packages/opencode-plugin: OpenCode plugin with hooks for:
  - Session lifecycle (create/idle/error/delete/diff)
  - Tool execution capture (before/after -> TOOL_CALL spans + TOOL_SELECTION decisions)
  - LLM call tracking (chat.message -> LLM_CALL spans with model/provider)
  - Permission flow (permission.ask -> ESCALATION decisions)
  - File edit events
  - Model cost estimation (Claude, GPT-4o, o3-mini pricing)
This commit is contained in:
Vectry
2026-02-10 03:08:51 +00:00
parent 0149e0a6f4
commit 6bed493275
17 changed files with 2589 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
{
"name": "opencode-agentlens",
"version": "0.1.0",
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"agentlens-sdk": "*"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.1.53",
"tsup": "^8.3.0",
"typescript": "^5.7.0"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.1.0"
},
"keywords": [
"opencode",
"agentlens",
"observability",
"tracing",
"coding-agent"
],
"license": "MIT"
}

View File

@@ -0,0 +1,41 @@
export interface PluginConfig {
apiKey: string;
endpoint: string;
enabled: boolean;
/** Opt-in: capture full message content in traces */
captureContent: boolean;
/** Maximum characters for tool output before truncation */
maxOutputLength: number;
/** Milliseconds between automatic flushes */
flushInterval: number;
/** Maximum traces per batch */
maxBatchSize: number;
}
export function loadConfig(): PluginConfig {
const apiKey = process.env["AGENTLENS_API_KEY"] ?? "";
if (!apiKey) {
console.warn(
"[agentlens] AGENTLENS_API_KEY not set — plugin will be disabled",
);
}
return {
apiKey,
endpoint:
process.env["AGENTLENS_ENDPOINT"] ?? "https://agentlens.vectry.tech",
enabled: (process.env["AGENTLENS_ENABLED"] ?? "true") === "true",
captureContent:
(process.env["AGENTLENS_CAPTURE_CONTENT"] ?? "false") === "true",
maxOutputLength: parseInt(
process.env["AGENTLENS_MAX_OUTPUT_LENGTH"] ?? "2000",
10,
),
flushInterval: parseInt(
process.env["AGENTLENS_FLUSH_INTERVAL"] ?? "5000",
10,
),
maxBatchSize: parseInt(process.env["AGENTLENS_BATCH_SIZE"] ?? "10", 10),
};
}

View File

@@ -0,0 +1,146 @@
import type { Plugin } from "@opencode-ai/plugin";
import type { JsonValue } from "agentlens-sdk";
import { init, flush, EventType as EventTypeValues } from "agentlens-sdk";
import { loadConfig } from "./config.js";
import { SessionState } from "./state.js";
import { truncate, safeJsonValue } from "./utils.js";
const plugin: Plugin = async ({ project, directory, worktree }) => {
const config = loadConfig();
if (!config.enabled || !config.apiKey) {
console.log("[agentlens] Plugin disabled — missing AGENTLENS_API_KEY");
return {};
}
init({
apiKey: config.apiKey,
endpoint: config.endpoint,
flushInterval: config.flushInterval,
maxBatchSize: config.maxBatchSize,
});
const state = new SessionState();
return {
event: async ({ event }) => {
const type = event.type;
const props = (event as Record<string, unknown>).properties as
| Record<string, unknown>
| undefined;
if (type === "session.created" && props?.["id"]) {
state.startSession(String(props["id"]), {
project: project.id,
directory,
worktree,
});
}
if (type === "session.idle") {
const sessionId = props?.["sessionID"] ?? props?.["id"];
if (sessionId) await flush();
}
if (type === "session.error") {
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
if (sessionId) {
const trace = state.getTrace(sessionId);
if (trace) {
trace.addEvent({
type: EventTypeValues.ERROR,
name: String(props?.["error"] ?? "session error"),
metadata: safeJsonValue(props) as JsonValue,
});
}
}
}
if (type === "session.deleted") {
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
if (sessionId) state.endSession(sessionId);
}
if (type === "session.diff") {
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
if (sessionId) {
const trace = state.getTrace(sessionId);
if (trace) {
trace.setMetadata({
diff: truncate(String(props?.["diff"] ?? ""), 5000),
});
}
}
}
if (type === "file.edited") {
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
const trace = sessionId ? state.getTrace(sessionId) : undefined;
if (trace) {
trace.addEvent({
type: EventTypeValues.CUSTOM,
name: "file.edited",
metadata: safeJsonValue({
filePath: props?.["filePath"],
}) as JsonValue,
});
}
}
},
"tool.execute.before": async (input, output) => {
state.startToolCall(
input.callID,
input.tool,
output.args as unknown,
input.sessionID,
);
},
"tool.execute.after": async (input, output) => {
state.endToolCall(
input.callID,
truncate(output.output ?? "", config.maxOutputLength),
output.title ?? input.tool,
output.metadata as unknown,
);
},
"chat.message": async (input) => {
if (input.model) {
state.recordLLMCall(input.sessionID, {
model: input.model,
agent: input.agent,
messageID: input.messageID,
});
}
},
"chat.params": async (input, output) => {
const trace = state.getTrace(input.sessionID);
if (trace) {
trace.addEvent({
type: EventTypeValues.CUSTOM,
name: "chat.params",
metadata: safeJsonValue({
agent: input.agent,
model: input.model.id,
provider: input.provider.info.id,
temperature: output.temperature,
topP: output.topP,
topK: output.topK,
}) as JsonValue,
});
}
},
"permission.ask": async (input, output) => {
state.recordPermission(input.sessionID, input, output.status);
},
};
};
export default plugin;
export { plugin as AgentLensPlugin };
export type { PluginConfig } from "./config.js";
export { loadConfig } from "./config.js";

View File

@@ -0,0 +1,183 @@
import {
TraceBuilder,
SpanType,
SpanStatus,
DecisionType,
nowISO,
} from "agentlens-sdk";
import type { JsonValue, TraceStatus } from "agentlens-sdk";
import { extractToolMetadata, safeJsonValue } from "./utils.js";
interface ToolCallState {
startTime: number;
tool: string;
args: unknown;
sessionID: string;
}
export class SessionState {
private traces = new Map<string, TraceBuilder>();
private toolCalls = new Map<string, ToolCallState>();
private rootSpans = new Map<string, string>();
startSession(
sessionId: string,
metadata?: Record<string, unknown>,
): TraceBuilder {
const trace = new TraceBuilder("opencode-session", {
sessionId,
tags: ["opencode", "coding-agent"],
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
});
const rootSpanId = trace.addSpan({
name: "session",
type: SpanType.AGENT,
startedAt: nowISO(),
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
});
this.traces.set(sessionId, trace);
this.rootSpans.set(sessionId, rootSpanId);
return trace;
}
getTrace(sessionId: string): TraceBuilder | undefined {
return this.traces.get(sessionId);
}
endSession(sessionId: string, status?: TraceStatus): void {
const trace = this.traces.get(sessionId);
if (!trace) return;
const rootSpanId = this.rootSpans.get(sessionId);
if (rootSpanId) {
trace.addSpan({
id: rootSpanId,
name: "session",
type: SpanType.AGENT,
status: status === "ERROR" ? SpanStatus.ERROR : SpanStatus.COMPLETED,
endedAt: nowISO(),
});
}
trace.end({ status: status ?? "COMPLETED" });
this.traces.delete(sessionId);
this.rootSpans.delete(sessionId);
}
startToolCall(
callID: string,
tool: string,
args: unknown,
sessionID: string,
): void {
this.toolCalls.set(callID, {
startTime: Date.now(),
tool,
args,
sessionID,
});
}
endToolCall(
callID: string,
output: string,
title: string,
metadata: unknown,
): void {
const call = this.toolCalls.get(callID);
if (!call) return;
this.toolCalls.delete(callID);
const trace = this.traces.get(call.sessionID);
if (!trace) return;
const durationMs = Date.now() - call.startTime;
const rootSpanId = this.rootSpans.get(call.sessionID);
const toolMeta = extractToolMetadata(call.tool, call.args);
trace.addSpan({
name: title,
type: SpanType.TOOL_CALL,
parentSpanId: rootSpanId,
input: safeJsonValue(call.args),
output: output as JsonValue,
durationMs,
status: SpanStatus.COMPLETED,
startedAt: new Date(call.startTime).toISOString(),
endedAt: nowISO(),
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
});
trace.addDecision({
type: DecisionType.TOOL_SELECTION,
chosen: call.tool as JsonValue,
alternatives: [],
reasoning: title,
durationMs,
parentSpanId: rootSpanId,
});
}
recordLLMCall(
sessionId: string,
options: {
model?: { providerID: string; modelID: string };
agent?: string;
messageID?: string;
},
): void {
const trace = this.traces.get(sessionId);
if (!trace) return;
const rootSpanId = this.rootSpans.get(sessionId);
const agentName = options.agent ?? "assistant";
const modelName = options.model?.modelID ?? "unknown";
trace.addSpan({
name: `${agentName}${modelName}`,
type: SpanType.LLM_CALL,
parentSpanId: rootSpanId,
status: SpanStatus.COMPLETED,
startedAt: nowISO(),
endedAt: nowISO(),
metadata: safeJsonValue({
provider: options.model?.providerID,
model: options.model?.modelID,
agent: options.agent,
messageID: options.messageID,
}),
});
}
recordPermission(
sessionId: string,
permission: unknown,
status: string,
): void {
const trace = this.traces.get(sessionId);
if (!trace) return;
const rootSpanId = this.rootSpans.get(sessionId);
const p = permission as Record<string, unknown> | null;
const title = (p?.["title"] as string) ?? "permission";
const permType = (p?.["type"] as string) ?? "unknown";
trace.addDecision({
type: DecisionType.ESCALATION,
chosen: safeJsonValue({ action: status }),
alternatives: [
"allow" as JsonValue,
"deny" as JsonValue,
"ask" as JsonValue,
],
reasoning: `${permType}: ${title}`,
parentSpanId: rootSpanId,
});
}
getRootSpanId(sessionId: string): string | undefined {
return this.rootSpans.get(sessionId);
}
}

View File

@@ -0,0 +1,97 @@
import type { JsonValue } from "agentlens-sdk";
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + "... [truncated]";
}
export function extractToolMetadata(
tool: string,
args: unknown,
): Record<string, unknown> {
const a = args as Record<string, unknown> | null | undefined;
if (!a || typeof a !== "object") return {};
switch (tool) {
case "read":
case "mcp_read":
return { filePath: a["filePath"] };
case "write":
case "mcp_write":
return { filePath: a["filePath"] };
case "edit":
case "mcp_edit":
return { filePath: a["filePath"] };
case "bash":
case "mcp_bash":
return {
command: truncate(String(a["command"] ?? ""), 200),
};
case "glob":
case "mcp_glob":
return { pattern: a["pattern"] };
case "grep":
case "mcp_grep":
return { pattern: a["pattern"], path: a["path"] };
case "task":
case "mcp_task":
return {
category: a["category"],
description: a["description"],
};
default:
return {};
}
}
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
"claude-opus-4-20250514": { input: 15, output: 75 },
"claude-sonnet-4-20250514": { input: 3, output: 15 },
"claude-haiku-3-20250307": { input: 0.25, output: 1.25 },
"gpt-4o": { input: 2.5, output: 10 },
"gpt-4o-mini": { input: 0.15, output: 0.6 },
"gpt-4-turbo": { input: 10, output: 30 },
"o3-mini": { input: 1.1, output: 4.4 },
};
export function getModelCost(
modelId: string,
): { input: number; output: number } | undefined {
const direct = MODEL_COSTS[modelId];
if (direct) return direct;
for (const [key, cost] of Object.entries(MODEL_COSTS)) {
if (modelId.includes(key)) return cost;
}
return undefined;
}
/** Coerce arbitrary values into SDK-compatible `JsonValue`, stringifying unknowns. */
export function safeJsonValue(value: unknown): JsonValue {
if (value === null || value === undefined) return null;
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
return value.map((v) => safeJsonValue(v));
}
if (typeof value === "object") {
const result: Record<string, JsonValue> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = safeJsonValue(v);
}
return result;
}
return String(value);
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
});

View File

@@ -0,0 +1,38 @@
{
"name": "agentlens-sdk",
"version": "0.1.0",
"description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=20"
},
"license": "MIT",
"devDependencies": {
"tsup": "^8.3.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,11 @@
import type { BatchTransport } from "./transport.js";
let _transport: BatchTransport | null = null;
export function _setTransport(transport: BatchTransport | null): void {
_transport = transport;
}
export function _getTransport(): BatchTransport | null {
return _transport;
}

View File

@@ -0,0 +1,29 @@
import type { DecisionPointPayload, DecisionType, JsonValue } from "./models.js";
import { generateId, nowISO } from "./models.js";
export interface CreateDecisionInput {
type: DecisionType;
chosen: JsonValue;
alternatives?: JsonValue[];
reasoning?: string;
contextSnapshot?: JsonValue;
durationMs?: number;
costUsd?: number;
parentSpanId?: string;
timestamp?: string;
}
export function createDecision(input: CreateDecisionInput): DecisionPointPayload {
return {
id: generateId(),
type: input.type,
chosen: input.chosen,
alternatives: input.alternatives ?? [],
reasoning: input.reasoning,
contextSnapshot: input.contextSnapshot,
durationMs: input.durationMs,
costUsd: input.costUsd,
parentSpanId: input.parentSpanId,
timestamp: input.timestamp ?? nowISO(),
};
}

View File

@@ -0,0 +1,76 @@
import { BatchTransport } from "./transport.js";
import { _setTransport, _getTransport } from "./_registry.js";
export interface InitOptions {
apiKey: string;
endpoint?: string;
maxBatchSize?: number;
flushInterval?: number;
}
export function init(options: InitOptions): void {
const existing = _getTransport();
if (existing) {
void existing.shutdown();
}
_setTransport(
new BatchTransport({
apiKey: options.apiKey,
endpoint: options.endpoint ?? "https://agentlens.vectry.tech",
maxBatchSize: options.maxBatchSize,
flushInterval: options.flushInterval,
}),
);
}
export async function shutdown(): Promise<void> {
const transport = _getTransport();
if (transport) {
await transport.shutdown();
_setTransport(null);
}
}
export function getClient(): BatchTransport | null {
return _getTransport();
}
export async function flush(): Promise<void> {
const transport = _getTransport();
if (transport) {
await transport.flush();
}
}
export {
TraceStatus,
DecisionType,
SpanType,
SpanStatus,
EventType,
generateId,
nowISO,
} from "./models.js";
export type {
JsonValue,
DecisionPointPayload,
SpanPayload,
EventPayload,
TracePayload,
} from "./models.js";
export { BatchTransport } from "./transport.js";
export type { BatchTransportOptions } from "./transport.js";
export { TraceBuilder } from "./trace.js";
export type {
TraceBuilderOptions,
AddSpanInput,
AddDecisionInput,
AddEventInput,
EndOptions,
} from "./trace.js";
export { createDecision } from "./decision.js";
export type { CreateDecisionInput } from "./decision.js";

View File

@@ -0,0 +1,136 @@
import { randomUUID } from "crypto";
// ---------------------------------------------------------------------------
// JSON value type (replaces Prisma.JsonValue for the SDK)
// ---------------------------------------------------------------------------
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
// ---------------------------------------------------------------------------
// Enums (as const + type union pattern — NO TypeScript enum keyword)
// ---------------------------------------------------------------------------
export const TraceStatus = {
RUNNING: "RUNNING",
COMPLETED: "COMPLETED",
ERROR: "ERROR",
} as const;
export type TraceStatus = (typeof TraceStatus)[keyof typeof TraceStatus];
export const DecisionType = {
TOOL_SELECTION: "TOOL_SELECTION",
ROUTING: "ROUTING",
RETRY: "RETRY",
ESCALATION: "ESCALATION",
MEMORY_RETRIEVAL: "MEMORY_RETRIEVAL",
PLANNING: "PLANNING",
CUSTOM: "CUSTOM",
} as const;
export type DecisionType = (typeof DecisionType)[keyof typeof DecisionType];
export const SpanType = {
LLM_CALL: "LLM_CALL",
TOOL_CALL: "TOOL_CALL",
MEMORY_OP: "MEMORY_OP",
CHAIN: "CHAIN",
AGENT: "AGENT",
CUSTOM: "CUSTOM",
} as const;
export type SpanType = (typeof SpanType)[keyof typeof SpanType];
export const SpanStatus = {
RUNNING: "RUNNING",
COMPLETED: "COMPLETED",
ERROR: "ERROR",
} as const;
export type SpanStatus = (typeof SpanStatus)[keyof typeof SpanStatus];
export const EventType = {
ERROR: "ERROR",
RETRY: "RETRY",
FALLBACK: "FALLBACK",
CONTEXT_OVERFLOW: "CONTEXT_OVERFLOW",
USER_FEEDBACK: "USER_FEEDBACK",
CUSTOM: "CUSTOM",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
// ---------------------------------------------------------------------------
// Wire-format interfaces (camelCase, matching POST /api/traces contract)
// ---------------------------------------------------------------------------
export interface DecisionPointPayload {
id: string;
type: DecisionType;
chosen: JsonValue;
alternatives: JsonValue[];
reasoning?: string;
contextSnapshot?: JsonValue;
durationMs?: number;
costUsd?: number;
parentSpanId?: string;
timestamp: string;
}
export interface SpanPayload {
id: string;
parentSpanId?: string;
name: string;
type: SpanType;
input?: JsonValue;
output?: JsonValue;
tokenCount?: number;
costUsd?: number;
durationMs?: number;
status: SpanStatus;
statusMessage?: string;
startedAt: string;
endedAt?: string;
metadata?: JsonValue;
}
export interface EventPayload {
id: string;
spanId?: string;
type: EventType;
name: string;
metadata?: JsonValue;
timestamp: string;
}
export interface TracePayload {
id: string;
name: string;
sessionId?: string;
status: TraceStatus;
tags: string[];
metadata?: JsonValue;
totalCost?: number;
totalTokens?: number;
totalDuration?: number;
startedAt: string;
endedAt?: string;
decisionPoints: DecisionPointPayload[];
spans: SpanPayload[];
events: EventPayload[];
}
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
/** Generate a v4 UUID. */
export function generateId(): string {
return randomUUID();
}
/** Return the current time as an ISO-8601 string. */
export function nowISO(): string {
return new Date().toISOString();
}

View File

@@ -0,0 +1,185 @@
import type {
TracePayload,
SpanPayload,
DecisionPointPayload,
EventPayload,
JsonValue,
TraceStatus,
SpanType,
SpanStatus,
DecisionType,
EventType,
} from "./models.js";
import {
generateId,
nowISO,
TraceStatus as TraceStatusValues,
SpanStatus as SpanStatusValues,
} from "./models.js";
import { _getTransport } from "./_registry.js";
export interface TraceBuilderOptions {
sessionId?: string;
tags?: string[];
metadata?: JsonValue;
}
export interface AddSpanInput {
id?: string;
parentSpanId?: string;
name: string;
type: SpanType;
input?: JsonValue;
output?: JsonValue;
tokenCount?: number;
costUsd?: number;
durationMs?: number;
status?: SpanStatus;
statusMessage?: string;
startedAt?: string;
endedAt?: string;
metadata?: JsonValue;
}
export interface AddDecisionInput {
id?: string;
type: DecisionType;
chosen: JsonValue;
alternatives?: JsonValue[];
reasoning?: string;
contextSnapshot?: JsonValue;
durationMs?: number;
costUsd?: number;
parentSpanId?: string;
timestamp?: string;
}
export interface AddEventInput {
id?: string;
spanId?: string;
type: EventType;
name: string;
metadata?: JsonValue;
timestamp?: string;
}
export interface EndOptions {
status?: TraceStatus;
metadata?: JsonValue;
totalCost?: number;
totalTokens?: number;
}
export class TraceBuilder {
private readonly trace: TracePayload;
private readonly startMs: number;
constructor(name: string, options?: TraceBuilderOptions) {
this.startMs = Date.now();
this.trace = {
id: generateId(),
name,
sessionId: options?.sessionId,
status: TraceStatusValues.RUNNING,
tags: options?.tags ?? [],
metadata: options?.metadata,
startedAt: nowISO(),
decisionPoints: [],
spans: [],
events: [],
};
}
addSpan(input: AddSpanInput): string {
const id = input.id ?? generateId();
const span: SpanPayload = {
id,
parentSpanId: input.parentSpanId,
name: input.name,
type: input.type,
input: input.input,
output: input.output,
tokenCount: input.tokenCount,
costUsd: input.costUsd,
durationMs: input.durationMs,
status: input.status ?? SpanStatusValues.RUNNING,
statusMessage: input.statusMessage,
startedAt: input.startedAt ?? nowISO(),
endedAt: input.endedAt,
metadata: input.metadata,
};
this.trace.spans.push(span);
return id;
}
addDecision(input: AddDecisionInput): string {
const id = input.id ?? generateId();
const decision: DecisionPointPayload = {
id,
type: input.type,
chosen: input.chosen,
alternatives: input.alternatives ?? [],
reasoning: input.reasoning,
contextSnapshot: input.contextSnapshot,
durationMs: input.durationMs,
costUsd: input.costUsd,
parentSpanId: input.parentSpanId,
timestamp: input.timestamp ?? nowISO(),
};
this.trace.decisionPoints.push(decision);
return id;
}
addEvent(input: AddEventInput): string {
const id = input.id ?? generateId();
const event: EventPayload = {
id,
spanId: input.spanId,
type: input.type,
name: input.name,
metadata: input.metadata,
timestamp: input.timestamp ?? nowISO(),
};
this.trace.events.push(event);
return id;
}
setStatus(status: TraceStatus): this {
this.trace.status = status;
return this;
}
setMetadata(metadata: JsonValue): this {
this.trace.metadata = metadata;
return this;
}
toPayload(): TracePayload {
return { ...this.trace };
}
end(options?: EndOptions): TracePayload {
const endedAt = nowISO();
this.trace.endedAt = endedAt;
this.trace.totalDuration = Date.now() - this.startMs;
this.trace.status =
options?.status ?? TraceStatusValues.COMPLETED;
if (options?.metadata !== undefined) {
this.trace.metadata = options.metadata;
}
if (options?.totalCost !== undefined) {
this.trace.totalCost = options.totalCost;
}
if (options?.totalTokens !== undefined) {
this.trace.totalTokens = options.totalTokens;
}
const transport = _getTransport();
if (transport) {
transport.add(this.trace);
}
return { ...this.trace };
}
}

View File

@@ -0,0 +1,77 @@
import type { TracePayload } from "./models.js";
export interface BatchTransportOptions {
apiKey: string;
endpoint: string;
maxBatchSize?: number;
flushInterval?: number;
}
export class BatchTransport {
private readonly apiKey: string;
private readonly endpoint: string;
private readonly maxBatchSize: number;
private readonly flushInterval: number;
private buffer: TracePayload[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
constructor(options: BatchTransportOptions) {
this.apiKey = options.apiKey;
this.endpoint = options.endpoint.replace(/\/+$/, "");
this.maxBatchSize = options.maxBatchSize ?? 10;
this.flushInterval = options.flushInterval ?? 5_000;
this.timer = setInterval(() => {
void this._doFlush();
}, this.flushInterval);
}
add(trace: TracePayload): void {
this.buffer.push(trace);
if (this.buffer.length >= this.maxBatchSize) {
void this._doFlush();
}
}
async flush(): Promise<void> {
await this._doFlush();
}
async shutdown(): Promise<void> {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
await this._doFlush();
}
private async _doFlush(): Promise<void> {
if (this.buffer.length === 0) {
return;
}
const batch = this.buffer.splice(0, this.buffer.length);
try {
const response = await fetch(`${this.endpoint}/api/traces`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ traces: batch }),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.warn(
`AgentLens: Failed to send traces (HTTP ${response.status}): ${text.slice(0, 200)}`,
);
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : String(error);
console.warn(`AgentLens: Failed to send traces: ${message}`);
}
}
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
});