fix: upsert traces to handle duplicate IDs from intermediate flushes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@@ -184,33 +184,42 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Insert traces using interactive transaction to control insert order.
|
||||
// Spans must be inserted before decision points due to the
|
||||
// DecisionPoint.parentSpanId FK referencing Span.id.
|
||||
// Upsert traces using interactive transaction to control insert order.
|
||||
// If a trace ID already exists, update the trace and replace all child
|
||||
// records (spans, decision points, events) so intermediate flushes and
|
||||
// final flushes both work seamlessly.
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const created: string[] = [];
|
||||
const upserted: string[] = [];
|
||||
|
||||
for (const trace of body.traces) {
|
||||
// 1. Create the trace record (no nested relations)
|
||||
await tx.trace.create({
|
||||
data: {
|
||||
id: trace.id,
|
||||
name: trace.name,
|
||||
sessionId: trace.sessionId,
|
||||
status: trace.status,
|
||||
tags: trace.tags,
|
||||
metadata: trace.metadata as Prisma.InputJsonValue,
|
||||
totalCost: trace.totalCost,
|
||||
totalTokens: trace.totalTokens,
|
||||
totalDuration: trace.totalDuration,
|
||||
startedAt: new Date(trace.startedAt),
|
||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||
},
|
||||
const traceData = {
|
||||
name: trace.name,
|
||||
sessionId: trace.sessionId,
|
||||
status: trace.status,
|
||||
tags: trace.tags,
|
||||
metadata: trace.metadata as Prisma.InputJsonValue,
|
||||
totalCost: trace.totalCost,
|
||||
totalTokens: trace.totalTokens,
|
||||
totalDuration: trace.totalDuration,
|
||||
startedAt: new Date(trace.startedAt),
|
||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||
};
|
||||
|
||||
// 1. Upsert the trace record
|
||||
await tx.trace.upsert({
|
||||
where: { id: trace.id },
|
||||
create: { id: trace.id, ...traceData },
|
||||
update: traceData,
|
||||
});
|
||||
|
||||
// 2. Create spans FIRST (decision points may reference them via parentSpanId)
|
||||
// 2. Delete existing child records (order matters for FK constraints:
|
||||
// decision points reference spans, so delete decisions first)
|
||||
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
||||
await tx.event.deleteMany({ where: { traceId: trace.id } });
|
||||
await tx.span.deleteMany({ where: { traceId: trace.id } });
|
||||
|
||||
// 3. Recreate spans (parents before children for self-referencing FK)
|
||||
if (trace.spans.length > 0) {
|
||||
// Sort spans so parents come before children
|
||||
const spanOrder = topologicalSortSpans(trace.spans);
|
||||
for (const span of spanOrder) {
|
||||
await tx.span.create({
|
||||
@@ -235,9 +244,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create decision points AFTER spans exist
|
||||
// 4. Recreate decision points
|
||||
if (trace.decisionPoints.length > 0) {
|
||||
// Collect valid span IDs so we can null-out invalid parentSpanId refs
|
||||
const validSpanIds = new Set(trace.spans.map((s) => s.id));
|
||||
|
||||
await tx.decisionPoint.createMany({
|
||||
@@ -257,7 +265,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Create events
|
||||
// 5. Recreate events
|
||||
if (trace.events.length > 0) {
|
||||
await tx.event.createMany({
|
||||
data: trace.events.map((event) => ({
|
||||
@@ -272,10 +280,10 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
created.push(trace.id);
|
||||
upserted.push(trace.id);
|
||||
}
|
||||
|
||||
return created;
|
||||
return upserted;
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, count: result.length }, { status: 200 });
|
||||
@@ -284,11 +292,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Handle unique constraint violations
|
||||
if (error instanceof Error && error.message.includes("Unique constraint")) {
|
||||
return NextResponse.json({ error: "Duplicate trace ID detected" }, { status: 409 });
|
||||
}
|
||||
|
||||
console.error("Error processing traces:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
|
||||
26
launch/linkedin.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# AgentLens Launch -- LinkedIn Post
|
||||
|
||||
---
|
||||
|
||||
**Open-sourcing AgentLens: observability for AI agents that traces decisions, not just API calls**
|
||||
|
||||
If you're building AI agents, you've probably hit this: your agent does something unexpected, you open your observability dashboard, and all you see is a list of LLM API calls with latencies and token counts. Helpful for cost tracking. Not helpful for understanding why the agent chose that path.
|
||||
|
||||
I spent the last two weeks building AgentLens to address this. It's an open-source observability tool that traces agent decisions -- tool selection, routing, planning, retries, escalation, memory retrieval -- and captures the reasoning and alternatives at each decision point.
|
||||
|
||||
The idea is simple: if you can see what your agent considered and why it chose what it chose, debugging and improving agent behavior gets a lot more tractable.
|
||||
|
||||
What's included:
|
||||
|
||||
- Python SDK with OpenAI auto-instrumentation (pip install vectry-agentlens)
|
||||
- Next.js dashboard for exploring decision flows and timelines
|
||||
- Self-hostable via Docker Compose (PostgreSQL + Redis)
|
||||
- MIT licensed
|
||||
|
||||
This is v0.1.0. It works, but it's early. The decision taxonomy is still evolving and there are rough edges. I'm sharing it now because I'd rather get feedback from people actually building agents than polish it in isolation.
|
||||
|
||||
Live demo: https://agentlens.vectry.tech
|
||||
Repository: https://gitea.repi.fun/repi/agentlens
|
||||
PyPI: https://pypi.org/project/vectry-agentlens/
|
||||
|
||||
If you're working with autonomous agents, I'd genuinely like to hear: what does useful observability look like for your use case? What decision types matter most to you?
|
||||
39
launch/show-hn.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Show HN: AgentLens -- Open-source observability for AI agents that traces decisions, not just API calls
|
||||
|
||||
**Repo:** https://gitea.repi.fun/repi/agentlens
|
||||
**Live demo:** https://agentlens.vectry.tech
|
||||
**PyPI:** https://pypi.org/project/vectry-agentlens/
|
||||
|
||||
---
|
||||
|
||||
I've been building AI agents for a while and kept running into the same problem: when an agent does something unexpected, the existing observability tools (LangSmith, Helicone, etc.) show me the LLM API calls that happened, but not *why* the agent chose a particular path.
|
||||
|
||||
Knowing that GPT-4 was called with these tokens and returned in 1.2s doesn't help me understand why my agent picked tool A over tool B, or why it decided to escalate instead of retry.
|
||||
|
||||
So I built AgentLens. It traces agent *decisions* -- tool selection, routing, planning, retries, escalation, memory retrieval -- and captures the reasoning, alternatives considered, and confidence scores at each decision point.
|
||||
|
||||
Quick setup:
|
||||
|
||||
```bash
|
||||
pip install vectry-agentlens
|
||||
```
|
||||
|
||||
```python
|
||||
import agentlens
|
||||
from agentlens.integrations.openai import wrap_openai
|
||||
from openai import OpenAI
|
||||
|
||||
agentlens.init(api_key="your-key", endpoint="http://localhost:4200")
|
||||
client = OpenAI()
|
||||
wrap_openai(client)
|
||||
```
|
||||
|
||||
The `wrap_openai` call auto-instruments your OpenAI client. From there you can log decisions with `agentlens.log_decision()` specifying the type (TOOL_SELECTION, ROUTING, PLANNING, RETRY, ESCALATION, MEMORY_RETRIEVAL, or CUSTOM), what was chosen, what the alternatives were, and why.
|
||||
|
||||
The dashboard is a Next.js app that shows decision flows, timelines, and lets you drill into individual agent runs. You can filter by decision type, search by outcome, and see where agents are spending their "thinking" time.
|
||||
|
||||
Stack: Python SDK + Next.js 15 dashboard + PostgreSQL + Redis. Self-hostable via Docker Compose. MIT licensed.
|
||||
|
||||
Honest caveats: this is v0.1.0. I built it solo in about two weeks. The decision model works but the taxonomy is still evolving. There are rough edges. The SDK currently supports Python only. I haven't done serious load testing yet.
|
||||
|
||||
Would love feedback on the decision model and what decision types you'd want to see. If you're building agents and have opinions on what "observability" should actually mean for autonomous systems, I'd really like to hear it.
|
||||
67
launch/twitter.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# AgentLens Launch -- Twitter/X Thread
|
||||
|
||||
---
|
||||
|
||||
**Tweet 1 (Hook)**
|
||||
|
||||
Current agent observability tools tell you WHAT API calls your agent made.
|
||||
|
||||
They don't tell you WHY it picked tool A over tool B, or why it retried instead of escalating.
|
||||
|
||||
That's the gap I kept hitting. So I built something to fix it.
|
||||
|
||||
---
|
||||
|
||||
**Tweet 2 (What it does)**
|
||||
|
||||
AgentLens traces agent decisions, not just LLM calls.
|
||||
|
||||
It captures tool selection, routing, planning, retries, and escalation -- with the reasoning, alternatives considered, and confidence at each step.
|
||||
|
||||
Open source. MIT licensed. Built solo in 2 weeks.
|
||||
|
||||
#AI #OpenSource #Agents
|
||||
|
||||
---
|
||||
|
||||
**Tweet 3 (Code)**
|
||||
|
||||
Four lines to get started:
|
||||
|
||||
```
|
||||
pip install vectry-agentlens
|
||||
|
||||
import agentlens
|
||||
agentlens.init(api_key="key", endpoint="http://localhost:4200")
|
||||
wrap_openai(openai.OpenAI())
|
||||
```
|
||||
|
||||
Auto-instruments your OpenAI client. Then trace decisions as they happen.
|
||||
|
||||
---
|
||||
|
||||
**Tweet 4 (Features)**
|
||||
|
||||
What you get:
|
||||
|
||||
- Live Next.js dashboard with decision flows
|
||||
- OpenAI auto-instrumentation via wrap_openai()
|
||||
- 7 decision types: routing, planning, tool selection, retry, escalation, memory retrieval, custom
|
||||
- Self-host with Docker Compose
|
||||
- Python SDK on PyPI
|
||||
|
||||
#DevTools #LLM
|
||||
|
||||
---
|
||||
|
||||
**Tweet 5 (CTA)**
|
||||
|
||||
AgentLens is v0.1.0 -- early but functional. Rough edges exist.
|
||||
|
||||
Try the live demo: https://agentlens.vectry.tech
|
||||
Repo: https://gitea.repi.fun/repi/agentlens
|
||||
Install: pip install vectry-agentlens
|
||||
|
||||
Feedback welcome, especially on the decision model.
|
||||
|
||||
#OpenSource #AI #Agents
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-agentlens",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "vectry-agentlens"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Agent observability that traces decisions, not just API calls"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentlens-sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
||||
@@ -27,7 +27,12 @@ export class BatchTransport {
|
||||
}
|
||||
|
||||
add(trace: TracePayload): void {
|
||||
this.buffer.push(trace);
|
||||
const idx = this.buffer.findIndex((t) => t.id === trace.id);
|
||||
if (idx !== -1) {
|
||||
this.buffer[idx] = trace;
|
||||
} else {
|
||||
this.buffer.push(trace);
|
||||
}
|
||||
if (this.buffer.length >= this.maxBatchSize) {
|
||||
void this._doFlush();
|
||||
}
|
||||
|
||||
BIN
screenshots/dashboard-filters.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
screenshots/dashboard-list.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
screenshots/dashboard-trace-detail.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
screenshots/docs-api-reference.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
screenshots/docs-getting-started.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
screenshots/docs-landing.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
screenshots/docs-opencode-plugin.png
Normal file
|
After Width: | Height: | Size: 573 KiB |
BIN
screenshots/docs-python-sdk.png
Normal file
|
After Width: | Height: | Size: 961 KiB |
BIN
screenshots/docs-self-hosting.png
Normal file
|
After Width: | Height: | Size: 828 KiB |
BIN
screenshots/docs-typescript-sdk.png
Normal file
|
After Width: | Height: | Size: 820 KiB |
BIN
screenshots/landing-full.png
Normal file
|
After Width: | Height: | Size: 654 KiB |