6 Commits

Author SHA1 Message Date
Vectry
61268f870f feat: user auth, API keys, Stripe billing, and dashboard scoping
- NextAuth v5 credentials auth with registration/login pages
- API key CRUD (create, list, revoke) with secure hashing
- Stripe checkout, webhooks, and customer portal integration
- Rate limiting per subscription tier
- All dashboard API endpoints scoped to authenticated user
- Prisma schema: User, Account, Session, ApiKey, plus Stripe fields
- Auth middleware protecting dashboard and API routes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 15:37:49 +00:00
Vectry
07cf717c15 fix: remove all console.log/warn to avoid breaking TUI 2026-02-10 13:46:55 +00:00
Vectry
638a5d2640 fix: complete traces on idle, improve dashboard span/event/analytics views 2026-02-10 13:25:19 +00:00
Vectry
7534c709f5 fix: guard span name to always be a non-empty string 2026-02-10 12:35:31 +00:00
Vectry
7e44ccb9e7 fix: optional chaining on chat.params provider/model access 2026-02-10 11:49:17 +00:00
Vectry
bdd6362c1a 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>
2026-02-10 11:41:49 +00:00
56 changed files with 2535 additions and 144 deletions

View File

@@ -15,14 +15,19 @@
"@agentlens/database": "*",
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.469.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.30",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.22.0"
"shiki": "^3.22.0",
"stripe": "^20.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",

View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const passwordValid = password.length >= 8;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Please enter a valid email address");
return;
}
if (!passwordValid) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">Welcome back</h1>
<p className="mt-1 text-sm text-neutral-400">
Sign in to your AgentLens account
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
email && !emailValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{email && !emailValid && (
<p className="text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-300"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
password && !passwordValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{password && !passwordValid && (
<p className="text-xs text-red-400">
Password must be at least 8 characters
</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Create one
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const passwordValid = password.length >= 8;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Please enter a valid email address");
return;
}
if (!passwordValid) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
...(name.trim() ? { name: name.trim() } : {}),
}),
});
if (!res.ok) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Registration failed");
setLoading(false);
return;
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Account created but sign-in failed. Please log in manually.");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
setLoading(false);
}
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Create your account
</h1>
<p className="mt-1 text-sm text-neutral-400">
Start monitoring your AI agents with AgentLens
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="name"
className="block text-sm font-medium text-neutral-300"
>
Name{" "}
<span className="text-neutral-500 font-normal">(optional)</span>
</label>
<input
id="name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jane Doe"
className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-emerald-500"
/>
</div>
<div className="space-y-2">
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
email && !emailValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{email && !emailValid && (
<p className="text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-300"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
password && !passwordValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{password && !passwordValid && (
<p className="text-xs text-red-400">
Password must be at least 8 characters
</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Already have an account?{" "}
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const registerSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(1).optional(),
});
export async function POST(request: Request) {
try {
const body: unknown = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { email, password, name } = parsed.data;
const normalizedEmail = email.toLowerCase();
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
email: normalizedEmail,
passwordHash,
name: name ?? null,
subscription: {
create: {
tier: "FREE",
sessionsLimit: 20,
},
},
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return NextResponse.json(user, { status: 201 });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
import { auth } from "@/auth";
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
@@ -51,8 +57,9 @@ export async function GET(request: NextRequest) {
);
}
// Build where clause
const where: Prisma.DecisionPointWhereInput = {};
const where: Prisma.DecisionPointWhereInput = {
trace: { userId: session.user.id },
};
if (type) {
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId: session.user.id, revoked: false },
select: { id: true },
});
if (!apiKey) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await prisma.apiKey.update({
where: { id: apiKey.id },
data: { revoked: true },
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error revoking API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const keys = await prisma.apiKey.findMany({
where: { userId: session.user.id, revoked: false },
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(keys, { status: 200 });
} catch (error) {
console.error("Error listing API keys:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json().catch(() => ({}));
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: "Default";
const rawHex = randomBytes(24).toString("hex");
const fullKey = `al_${rawHex}`;
const keyPrefix = fullKey.slice(0, 10);
const keyHash = createHash("sha256").update(fullKey).digest("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId: session.user.id,
name,
keyHash,
keyPrefix,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
},
});
return NextResponse.json(
{ ...apiKey, key: fullKey },
{ status: 201 }
);
} catch (error) {
console.error("Error creating API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
email: true,
name: true,
createdAt: true,
subscription: {
select: {
tier: true,
status: true,
sessionsUsed: true,
sessionsLimit: true,
currentPeriodStart: true,
currentPeriodEnd: true,
stripeCustomerId: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Don't expose raw Stripe customer ID to the client
const { subscription, ...rest } = user;
const safeSubscription = subscription
? {
tier: subscription.tier,
status: subscription.status,
sessionsUsed: subscription.sessionsUsed,
sessionsLimit: subscription.sessionsLimit,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
hasStripeSubscription: !!subscription.stripeCustomerId,
}
: null;
return NextResponse.json({ ...rest, subscription: safeSubscription }, { status: 200 });
} catch (error) {
console.error("Error fetching account:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { trace: { userId } };
await prisma.$transaction([
prisma.event.deleteMany(),
prisma.decisionPoint.deleteMany(),
prisma.span.deleteMany(),
prisma.trace.deleteMany(),
prisma.event.deleteMany({ where: traceFilter }),
prisma.decisionPoint.deleteMany({ where: traceFilter }),
prisma.span.deleteMany({ where: traceFilter }),
prisma.trace.deleteMany({ where: { userId } }),
]);
return NextResponse.json({ success: true }, { status: 200 });

View File

@@ -1,14 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { userId };
const childFilter = { trace: { userId } };
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
await Promise.all([
prisma.trace.count(),
prisma.span.count(),
prisma.decisionPoint.count(),
prisma.event.count(),
prisma.trace.count({ where: traceFilter }),
prisma.span.count({ where: childFilter }),
prisma.decisionPoint.count({ where: childFilter }),
prisma.event.count({ where: childFilter }),
]);
return NextResponse.json(

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { priceId, tierKey } = body as {
priceId?: string;
tierKey?: string;
};
let resolvedPriceId = priceId;
if (!resolvedPriceId && tierKey) {
const tierConfig =
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
if (tierConfig && "priceId" in tierConfig) {
resolvedPriceId = tierConfig.priceId;
}
}
if (!resolvedPriceId) {
return NextResponse.json(
{ error: "priceId or tierKey is required" },
{ status: 400 }
);
}
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
if (!validPriceIds.includes(resolvedPriceId)) {
return NextResponse.json(
{ error: "Invalid priceId" },
{ status: 400 }
);
}
const userId = session.user.id;
let subscription = await prisma.subscription.findUnique({
where: { userId },
});
let stripeCustomerId = subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await getStripe().customers.create({
email: session.user.email,
name: session.user.name ?? undefined,
metadata: { userId },
});
stripeCustomerId = customer.id;
if (subscription) {
await prisma.subscription.update({
where: { userId },
data: { stripeCustomerId },
});
} else {
subscription = await prisma.subscription.create({
data: {
userId,
stripeCustomerId,
},
});
}
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [{ price: resolvedPriceId, quantity: 1 }],
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/dashboard/settings`,
metadata: { userId },
});
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const subscription = await prisma.subscription.findUnique({
where: { userId: session.user.id },
select: { stripeCustomerId: true },
});
if (!subscription?.stripeCustomerId) {
return NextResponse.json(
{ error: "No active subscription to manage" },
{ status: 400 }
);
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: portalSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating portal session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export const runtime = "nodejs";
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
return "FREE";
}
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
return TIER_CONFIG[tier].sessionsLimit;
}
async function handleCheckoutCompleted(
checkoutSession: Stripe.Checkout.Session
) {
const userId = checkoutSession.metadata?.userId;
if (!userId) return;
const subscriptionId = checkoutSession.subscription as string;
const customerId = checkoutSession.customer as string;
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: new Date();
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: new Date();
await prisma.subscription.upsert({
where: { userId },
update: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
create: {
userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
});
}
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
active: "ACTIVE",
past_due: "PAST_DUE",
canceled: "CANCELED",
unpaid: "UNPAID",
};
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: undefined;
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: undefined;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
tier,
stripePriceId: priceId,
sessionsLimit: sessionsLimitForTier(tier),
status: dbStatus,
...(periodStart && { currentPeriodStart: periodStart }),
...(periodEnd && { currentPeriodEnd: periodEnd }),
},
});
}
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
status: "CANCELED",
tier: "FREE",
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subDetail = invoice.parent?.subscription_details?.subscription;
const subscriptionId =
typeof subDetail === "string" ? subDetail : subDetail?.id;
if (!subscriptionId) return;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: { sessionsUsed: 0 },
});
}
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = getStripe().webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
}
} catch (error) {
console.error(`Error handling ${event.type}:`, error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true }, { status: 200 });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
type RouteParams = { params: Promise<{ id: string }> };
@@ -23,14 +24,19 @@ export async function GET(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
include: {
decisionPoints: {
orderBy: {
@@ -106,14 +112,19 @@ export async function DELETE(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
select: { id: true },
});

View File

@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
import { validateApiKey } from "@/lib/api-key";
import { auth } from "@/auth";
// Types
interface DecisionPointPayload {
@@ -90,11 +92,55 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
}
const apiKey = authHeader.slice(7);
if (!apiKey) {
const rawApiKey = authHeader.slice(7);
if (!rawApiKey) {
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
}
const keyValidation = await validateApiKey(rawApiKey);
if (!keyValidation) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const { userId, subscription } = keyValidation;
if (!subscription) {
return NextResponse.json({ error: "No subscription found for this user" }, { status: 403 });
}
const tier = subscription.tier;
const sessionsLimit = subscription.sessionsLimit;
if (tier === "FREE") {
const startOfToday = new Date();
startOfToday.setUTCHours(0, 0, 0, 0);
const dailyCount = await prisma.trace.count({
where: {
userId,
createdAt: { gte: startOfToday },
},
});
if (dailyCount >= sessionsLimit) {
return NextResponse.json(
{
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/day. Upgrade at /settings/billing`,
},
{ status: 429 }
);
}
} else {
if (subscription.sessionsUsed >= sessionsLimit) {
return NextResponse.json(
{
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/month. Upgrade at /settings/billing`,
},
{ status: 429 }
);
}
}
// Parse and validate request body
const body: BatchTracesRequest = await request.json();
if (!body.traces || !Array.isArray(body.traces)) {
@@ -184,17 +230,21 @@ 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[] = [];
let newTraceCount = 0;
for (const trace of body.traces) {
// 1. Create the trace record (no nested relations)
await tx.trace.create({
data: {
id: trace.id,
const existing = await tx.trace.findUnique({
where: { id: trace.id },
select: { id: true },
});
const traceData = {
name: trace.name,
sessionId: trace.sessionId,
status: trace.status,
@@ -205,12 +255,26 @@ export async function POST(request: NextRequest) {
totalDuration: trace.totalDuration,
startedAt: new Date(trace.startedAt),
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
},
};
await tx.trace.upsert({
where: { id: trace.id },
create: { id: trace.id, userId, ...traceData },
update: traceData,
});
// 2. Create spans FIRST (decision points may reference them via parentSpanId)
if (!existing) {
newTraceCount++;
}
// 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 +299,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 +320,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 +335,17 @@ export async function POST(request: NextRequest) {
});
}
created.push(trace.id);
upserted.push(trace.id);
}
return created;
if (newTraceCount > 0 && tier !== "FREE") {
await tx.subscription.update({
where: { id: subscription.id },
data: { sessionsUsed: { increment: newTraceCount } },
});
}
return upserted;
});
return NextResponse.json({ success: true, count: result.length }, { status: 200 });
@@ -284,11 +354,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 });
}
@@ -297,6 +362,11 @@ export async function POST(request: NextRequest) {
// GET /api/traces — List traces with pagination
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
@@ -336,8 +406,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
}
// Build where clause
const where: Record<string, unknown> = {};
const where: Record<string, unknown> = { userId: session.user.id };
if (status) {
where.status = status;
}

View File

@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export const dynamic = "force-dynamic";
@@ -22,6 +23,13 @@ interface TraceUpdateData {
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const currentUserId = session.user.id;
const headers = new Headers();
headers.set("Content-Type", "text/event-stream");
headers.set("Cache-Control", "no-cache");
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
try {
const newTraces = await prisma.trace.findMany({
where: {
userId: currentUserId,
OR: [
{ createdAt: { gt: lastCheck } },
{ updatedAt: { gt: lastCheck } },

View File

@@ -0,0 +1,340 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Key,
Plus,
Copy,
Check,
Trash2,
AlertTriangle,
RefreshCw,
Shield,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface ApiKey {
id: string;
name: string;
keyPrefix: string;
createdAt: string;
lastUsedAt: string | null;
}
interface NewKeyResponse extends ApiKey {
key: string;
}
export default function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newlyCreatedKey, setNewlyCreatedKey] = useState<NewKeyResponse | null>(
null
);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const fetchKeys = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch("/api/keys", { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setKeys(data);
}
} catch (error) {
console.error("Failed to fetch API keys:", error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchKeys();
}, [fetchKeys]);
const copyToClipboard = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
} catch {
console.error("Failed to copy");
}
};
const handleCreate = async () => {
setIsCreating(true);
try {
const res = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newKeyName.trim() || undefined }),
});
if (res.ok) {
const data: NewKeyResponse = await res.json();
setNewlyCreatedKey(data);
setShowCreateForm(false);
setNewKeyName("");
fetchKeys();
}
} catch (error) {
console.error("Failed to create API key:", error);
} finally {
setIsCreating(false);
}
};
const handleRevoke = async (id: string) => {
setRevokingId(id);
try {
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
if (res.ok) {
setConfirmRevokeId(null);
fetchKeys();
}
} catch (error) {
console.error("Failed to revoke API key:", error);
} finally {
setRevokingId(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div className="space-y-8 max-w-3xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-100">API Keys</h1>
<p className="text-neutral-400 mt-1">
Manage API keys for SDK authentication
</p>
</div>
<button
onClick={() => {
setShowCreateForm(true);
setNewlyCreatedKey(null);
}}
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg text-sm font-semibold transition-colors"
>
<Plus className="w-4 h-4" />
Create New Key
</button>
</div>
{newlyCreatedKey && (
<div className="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0">
<Key className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-emerald-300">
API Key Created
</h3>
<p className="text-xs text-emerald-400/60 mt-0.5">
{newlyCreatedKey.name}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-4 py-3 bg-neutral-950 border border-neutral-800 rounded-lg font-mono text-sm text-neutral-200 truncate select-all">
{newlyCreatedKey.key}
</div>
<button
onClick={() =>
copyToClipboard(newlyCreatedKey.key, "new-key")
}
className={cn(
"p-3 rounded-lg border transition-all shrink-0",
copiedField === "new-key"
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200"
)}
>
{copiedField === "new-key" ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<p className="text-xs text-amber-300/80">
This key won&apos;t be shown again. Copy it now and store it
securely.
</p>
</div>
<button
onClick={() => setNewlyCreatedKey(null)}
className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
Dismiss
</button>
</div>
)}
{showCreateForm && !newlyCreatedKey && (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Plus className="w-5 h-5 text-emerald-400" />
<h2 className="text-sm font-semibold">Create New API Key</h2>
</div>
<div>
<label className="text-xs text-neutral-500 font-medium block mb-1.5">
Key Name (optional)
</label>
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="e.g. Production, Staging, CI/CD"
className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-emerald-500/40 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
autoFocus
/>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg text-sm font-semibold disabled:opacity-50 transition-colors"
>
{isCreating ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Key className="w-4 h-4" />
)}
Generate Key
</button>
<button
onClick={() => {
setShowCreateForm(false);
setNewKeyName("");
}}
className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Shield className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">Active Keys</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-6 space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="w-8 h-8 bg-neutral-800 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="h-4 w-32 bg-neutral-800 rounded" />
<div className="h-3 w-48 bg-neutral-800 rounded" />
</div>
<div className="h-8 w-20 bg-neutral-800 rounded" />
</div>
))}
</div>
) : keys.length === 0 ? (
<div className="p-12 text-center">
<div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4">
<Key className="w-6 h-6 text-neutral-600" />
</div>
<p className="text-sm text-neutral-400 font-medium">
No API keys yet
</p>
<p className="text-xs text-neutral-600 mt-1">
Create one to authenticate your SDK
</p>
</div>
) : (
<div className="divide-y divide-neutral-800">
{keys.map((apiKey) => (
<div
key={apiKey.id}
className="flex items-center gap-4 px-6 py-4 group"
>
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0">
<Key className="w-4 h-4 text-neutral-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-200 truncate">
{apiKey.name}
</p>
<div className="flex items-center gap-3 mt-0.5">
<code className="text-xs font-mono text-neutral-500">
{apiKey.keyPrefix}
</code>
<span className="text-xs text-neutral-600">
Created {formatDate(apiKey.createdAt)}
</span>
{apiKey.lastUsedAt && (
<span className="text-xs text-neutral-600">
Last used {formatDate(apiKey.lastUsedAt)}
</span>
)}
</div>
</div>
{confirmRevokeId === apiKey.id ? (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setConfirmRevokeId(null)}
className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleRevoke(apiKey.id)}
disabled={revokingId === apiKey.id}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors"
>
{revokingId === apiKey.id ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
Confirm
</button>
</div>
) : (
<button
onClick={() => setConfirmRevokeId(apiKey.id)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0"
>
<Trash2 className="w-3 h-3" />
Revoke
</button>
)}
</div>
))}
</div>
)}
</div>
</section>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import {
Activity,
GitBranch,
Key,
Settings,
Menu,
ChevronRight,
@@ -22,6 +23,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Traces", icon: Activity },
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
];

View File

@@ -11,6 +11,13 @@ import {
Database,
Trash2,
AlertTriangle,
CreditCard,
Crown,
Zap,
ArrowUpRight,
User,
Calendar,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -21,12 +28,71 @@ interface Stats {
totalEvents: number;
}
interface AccountData {
id: string;
email: string;
name: string | null;
createdAt: string;
subscription: {
tier: "FREE" | "STARTER" | "PRO";
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
sessionsUsed: number;
sessionsLimit: number;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
hasStripeSubscription: boolean;
} | null;
}
const TIERS = [
{
key: "FREE" as const,
name: "Free",
price: 0,
period: "day",
sessions: 20,
description: "For getting started",
features: ["20 sessions per day", "Basic trace viewing", "Community support"],
},
{
key: "STARTER" as const,
name: "Starter",
price: 5,
period: "month",
sessions: 1000,
description: "For small teams",
features: [
"1,000 sessions per month",
"Advanced analytics",
"Priority support",
],
},
{
key: "PRO" as const,
name: "Pro",
price: 20,
period: "month",
sessions: 100000,
description: "For production workloads",
features: [
"100,000 sessions per month",
"Full analytics suite",
"Dedicated support",
"Custom retention",
],
},
];
export default function SettingsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [account, setAccount] = useState<AccountData | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [isPurging, setIsPurging] = useState(false);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
const fetchStats = useCallback(async () => {
setIsLoadingStats(true);
@@ -43,9 +109,25 @@ export default function SettingsPage() {
}
}, []);
const fetchAccount = useCallback(async () => {
setIsLoadingAccount(true);
try {
const res = await fetch("/api/settings/account", { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setAccount(data);
}
} catch (error) {
console.error("Failed to fetch account:", error);
} finally {
setIsLoadingAccount(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
fetchAccount();
}, [fetchStats, fetchAccount]);
const copyToClipboard = async (text: string, field: string) => {
try {
@@ -72,6 +154,48 @@ export default function SettingsPage() {
}
};
const handleUpgrade = async (tierKey: string) => {
setUpgradingTier(tierKey);
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tierKey }),
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch (error) {
console.error("Failed to create checkout:", error);
} finally {
setUpgradingTier(null);
}
};
const handleManageSubscription = async () => {
setIsOpeningPortal(true);
try {
const res = await fetch("/api/stripe/portal", { method: "POST" });
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch (error) {
console.error("Failed to open portal:", error);
} finally {
setIsOpeningPortal(false);
}
};
const currentTier = account?.subscription?.tier ?? "FREE";
const sessionsUsed = account?.subscription?.sessionsUsed ?? 0;
const sessionsLimit = account?.subscription?.sessionsLimit ?? 20;
const usagePercent =
sessionsLimit > 0
? Math.min((sessionsUsed / sessionsLimit) * 100, 100)
: 0;
const endpointUrl =
typeof window !== "undefined"
? `${window.location.origin}/api/traces`
@@ -82,10 +206,225 @@ export default function SettingsPage() {
<div>
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
<p className="text-neutral-400 mt-1">
Configuration and SDK connection details
Account, billing, and configuration
</p>
</div>
{/* Account */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<User className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">Account</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
{isLoadingAccount ? (
<div className="flex items-center gap-3 text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Loading account...</span>
</div>
) : account ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div>
<p className="text-xs text-neutral-500 font-medium mb-1">
Email
</p>
<p className="text-sm text-neutral-200 font-medium">
{account.email}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 font-medium mb-1">
Name
</p>
<p className="text-sm text-neutral-200 font-medium">
{account.name ?? "\u2014"}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 font-medium mb-1">
Member since
</p>
<div className="flex items-center gap-1.5 text-sm text-neutral-200 font-medium">
<Calendar className="w-3.5 h-3.5 text-neutral-500" />
{new Date(account.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</div>
</div>
</div>
) : (
<p className="text-sm text-neutral-500">
Unable to load account info
</p>
)}
</div>
</section>
{/* Subscription & Billing */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<CreditCard className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">Subscription & Billing</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-400">Current plan</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
{currentTier === "PRO" && <Crown className="w-3 h-3" />}
{currentTier === "STARTER" && <Zap className="w-3 h-3" />}
{currentTier}
</span>
</div>
{currentTier !== "FREE" &&
account?.subscription?.hasStripeSubscription && (
<button
onClick={handleManageSubscription}
disabled={isOpeningPortal}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 bg-neutral-800 border border-neutral-700 rounded-lg hover:text-neutral-200 hover:border-neutral-600 transition-colors disabled:opacity-50"
>
{isOpeningPortal ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpRight className="w-3 h-3" />
)}
Manage Subscription
</button>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-400">
{sessionsUsed.toLocaleString()} of{" "}
{sessionsLimit.toLocaleString()} sessions used
</span>
<span className="text-neutral-500 text-xs">
{currentTier === "FREE"
? "20 sessions/day"
: "This billing period"}
</span>
</div>
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
usagePercent > 90 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
{currentTier !== "FREE" &&
account?.subscription?.currentPeriodStart &&
account?.subscription?.currentPeriodEnd && (
<p className="text-xs text-neutral-600">
Period:{" "}
{new Date(
account.subscription.currentPeriodStart
).toLocaleDateString()}{" "}
\u2014{" "}
{new Date(
account.subscription.currentPeriodEnd
).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{TIERS.map((tier) => {
const isCurrent = currentTier === tier.key;
const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 };
const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier];
const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier];
return (
<div
key={tier.key}
className={cn(
"relative bg-neutral-900 border rounded-xl p-5 flex flex-col transition-colors",
isCurrent
? "border-emerald-500/40 shadow-[0_0_24px_-6px_rgba(16,185,129,0.12)]"
: "border-neutral-800 hover:border-neutral-700"
)}
>
{isCurrent && (
<div className="absolute -top-2.5 left-4">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500 text-neutral-950">
Current
</span>
</div>
)}
<div className="mb-4">
<h3 className="text-base font-semibold text-neutral-100">
{tier.name}
</h3>
<p className="text-xs text-neutral-500 mt-0.5">
{tier.description}
</p>
</div>
<div className="mb-4">
<span className="text-2xl font-bold text-neutral-100">
${tier.price}
</span>
<span className="text-sm text-neutral-500">
/{tier.period}
</span>
</div>
<ul className="space-y-2 mb-5 flex-1">
{tier.features.map((feature) => (
<li
key={feature}
className="flex items-start gap-2 text-xs text-neutral-400"
>
<Check className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
{feature}
</li>
))}
</ul>
{isCurrent ? (
<div className="py-2 text-center text-xs font-medium text-emerald-400 bg-emerald-500/5 border border-emerald-500/10 rounded-lg">
Active plan
</div>
) : isUpgrade ? (
<button
onClick={() => handleUpgrade(tier.key)}
disabled={upgradingTier === tier.key}
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg transition-colors disabled:opacity-50"
>
{upgradingTier === tier.key ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
Upgrade
</button>
) : isDowngrade ? (
<button
onClick={handleManageSubscription}
disabled={isOpeningPortal}
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg hover:text-neutral-100 transition-colors disabled:opacity-50"
>
{isOpeningPortal && (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
)}
Downgrade
</button>
) : null}
</div>
);
})}
</div>
</section>
{/* SDK Connection */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
@@ -102,25 +441,17 @@ export default function SettingsPage() {
onCopy={copyToClipboard}
/>
<SettingField
label="API Key"
value="any-value-accepted"
hint="Authentication is not enforced yet. Use any non-empty string as your Bearer token."
copiedField={copiedField}
fieldKey="apikey"
onCopy={copyToClipboard}
/>
<div className="pt-4 border-t border-neutral-800">
<p className="text-xs text-neutral-500 mb-3">Quick start</p>
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<pre>{`from agentlens import init
init(
api_key="your-api-key",
endpoint="${endpointUrl.replace("/api/traces", "")}",
)`}</pre>
</div>
<p className="text-xs text-neutral-500 mb-1.5">API Key</p>
<p className="text-xs text-neutral-600">
Manage your API keys from the{" "}
<a
href="/dashboard/keys"
className="text-emerald-400 hover:text-emerald-300 transition-colors underline underline-offset-2"
>
API Keys page
</a>
</p>
</div>
</div>
</section>
@@ -275,9 +606,7 @@ function SettingField({
)}
</button>
</div>
{hint && (
<p className="text-xs text-neutral-600 mt-1.5">{hint}</p>
)}
{hint && <p className="text-xs text-neutral-600 mt-1.5">{hint}</p>}
</div>
);
}

View File

@@ -12,6 +12,7 @@ interface TraceData {
metadata: Record<string, unknown>;
costUsd: number | null;
totalCost: number | null;
totalTokens: number | null;
decisionPoints: Array<{
id: string;
type: string;
@@ -96,6 +97,8 @@ export default async function TraceDetailPage({ params }: TraceDetailPageProps)
tags: trace.tags,
metadata: trace.metadata,
costUsd: trace.costUsd ?? trace.totalCost,
totalTokens: trace.totalTokens ?? null,
totalCost: trace.totalCost ?? null,
}}
decisionPoints={trace.decisionPoints}
spans={trace.spans}

View File

@@ -40,7 +40,26 @@ export default function GettingStartedPage() {
</a>
)
</li>
<li>An API key for authentication</li>
<li>
An AgentLens account {" "}
<a
href="/register"
className="text-emerald-400 hover:underline"
>
sign up here
</a>{" "}
if you haven{"'"}t already
</li>
<li>
An API key (create one in{" "}
<a
href="/dashboard/keys"
className="text-emerald-400 hover:underline"
>
Dashboard &rarr; API Keys
</a>
)
</li>
</ul>
</section>
@@ -62,6 +81,23 @@ export default function GettingStartedPage() {
<h2 className="text-2xl font-semibold mb-4">
Step 2: Initialize AgentLens
</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
Sign up at{" "}
<a
href="https://agentlens.vectry.tech/register"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech
</a>
, then go to{" "}
<a
href="/dashboard/keys"
className="text-emerald-400 hover:underline"
>
Dashboard &rarr; API Keys
</a>{" "}
to create your key. Pass it to the SDK during initialization:
</p>
<h3 className="text-lg font-medium text-neutral-200 mb-2">Python</h3>
<CodeBlock title="main.py" language="python">{`import agentlens

View File

@@ -50,7 +50,7 @@ export default function PythonSdkPage() {
<ApiSection
name="init()"
signature="agentlens.init(api_key, endpoint, *, flush_interval=5.0, max_batch_size=100, enabled=True)"
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup."
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
>
<h4 className="text-sm font-medium text-neutral-300 mb-2">
Parameters
@@ -70,7 +70,7 @@ export default function PythonSdkPage() {
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">api_key</td>
<td className="py-2 pr-4 text-neutral-500">str</td>
<td className="py-2 pr-4 text-neutral-500">required</td>
<td className="py-2">Your AgentLens API key</td>
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard &rarr; API Keys</a>)</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>

View File

@@ -52,7 +52,7 @@ export default function TypeScriptSdkPage() {
<ApiSection
name="init()"
signature='init({ apiKey, endpoint, flushInterval?, maxBatchSize?, enabled? })'
description="Initialize the SDK. Must be called once before creating any traces."
description="Initialize the SDK. Must be called once before creating any traces. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
>
<h4 className="text-sm font-medium text-neutral-300 mb-2">
Options
@@ -72,7 +72,7 @@ export default function TypeScriptSdkPage() {
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">apiKey</td>
<td className="py-2 pr-4 text-neutral-500">string</td>
<td className="py-2 pr-4 text-neutral-500">required</td>
<td className="py-2">Your AgentLens API key</td>
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard &rarr; API Keys</a>)</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>

View File

@@ -1,5 +1,6 @@
import { Inter } from "next/font/google";
import type { Metadata } from "next";
import { SessionProvider } from "next-auth/react";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
@@ -72,7 +73,7 @@ export default function RootLayout({
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}>
{children}
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);

View File

@@ -0,0 +1,9 @@
import type { NextAuthConfig } from "next-auth";
export default {
providers: [],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
} satisfies NextAuthConfig;

72
apps/web/src/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import authConfig from "./auth.config";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
};
}
}
declare module "@auth/core/jwt" {
interface JWT {
id: string;
}
}
const loginSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) return null;
const isValid = await compare(password, user.passwordHash);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
}
return token;
},
session({ session, token }) {
session.user.id = token.id;
return session;
},
},
});

View File

@@ -49,6 +49,8 @@ interface Trace {
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
totalTokens: number | null;
totalCost: number | null;
}
interface TraceAnalyticsProps {
@@ -112,8 +114,14 @@ function ExecutionTimeline({ trace, spans }: { trace: Trace; spans: Span[] }) {
}
const traceStartTime = new Date(trace.startedAt).getTime();
const lastSpanEnd = spans.reduce((max, s) => {
const end = s.endedAt ? new Date(s.endedAt).getTime() : 0;
return end > max ? end : max;
}, 0);
const traceEndTime = trace.endedAt
? new Date(trace.endedAt).getTime()
: trace.status === "COMPLETED" || trace.status === "ERROR"
? lastSpanEnd || traceStartTime
: Date.now();
const totalDuration = traceEndTime - traceStartTime;
@@ -310,7 +318,7 @@ function CostBreakdown({
(sum, d) => sum + (d.cost || 0),
0
);
const totalCostValue = trace.costUsd ?? totalSpanCost + totalDecisionCost;
const totalCostValue = trace.costUsd ?? trace.totalCost ?? totalSpanCost + totalDecisionCost;
const hasData =
totalCostValue > 0 || spanCostsData.length > 0 || decisionCostsData.length > 0;
@@ -462,6 +470,7 @@ function CostBreakdown({
function TokenUsageGauge({ trace }: { trace: Trace }) {
const tokenData = useMemo(() => {
const totalTokens =
trace.totalTokens ??
(trace.metadata?.totalTokens as number | null | undefined) ??
(trace.metadata?.tokenCount as number | null | undefined) ??
null;

View File

@@ -77,6 +77,8 @@ interface Trace {
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
totalTokens: number | null;
totalCost: number | null;
}
interface TraceDetailProps {
@@ -530,19 +532,77 @@ function SpanItem({ span, maxDuration }: { span: Span; maxDuration: number }) {
</div>
<div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Output</h5>
{span.output === null || span.output === undefined || span.output === "" ? (
<p className="text-sm text-neutral-500 italic">No output recorded</p>
) : (
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.output, null, 2)}
</pre>
)}
</div>
</div>
{Object.keys(span.metadata).length > 0 && (
{Object.entries(span.metadata).some(([, v]) => v !== null && v !== undefined) && (
<div>
<h5 className="text-xs font-medium text-neutral-500 mb-2">Metadata</h5>
<pre className="p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300">
{JSON.stringify(span.metadata, null, 2)}
{JSON.stringify(
Object.fromEntries(Object.entries(span.metadata).filter(([, v]) => v !== null && v !== undefined)),
null,
2
)}
</pre>
</div>
)}
<details className="group">
<summary className="flex items-center gap-2 text-sm text-neutral-400 hover:text-neutral-200 cursor-pointer transition-colors">
<FileJson className="w-4 h-4" />
<span>Raw JSON</span>
<ChevronRight className="w-4 h-4 group-open:rotate-90 transition-transform" />
</summary>
<pre className="mt-3 p-4 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300 max-h-96 overflow-y-auto">
{JSON.stringify(span, null, 2)}
</pre>
</details>
</div>
)}
</div>
);
}
function EventItem({ event }: { event: Event }) {
const [expanded, setExpanded] = useState(false);
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
const hasMetadata = event.metadata && Object.entries(event.metadata).some(([, v]) => v !== null && v !== undefined);
return (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-4 p-4"
>
<div className={cn("p-2 rounded-lg bg-neutral-800", color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 text-left">
<h4 className="font-medium text-neutral-100">{event.name}</h4>
<p className="text-xs text-neutral-500">{event.type}</p>
</div>
<span className="text-sm text-neutral-400">
{formatRelativeTime(event.timestamp)}
</span>
{hasMetadata && (
expanded ? (
<ChevronDown className="w-5 h-5 text-neutral-500" />
) : (
<ChevronRight className="w-5 h-5 text-neutral-500" />
)
)}
</button>
{expanded && hasMetadata && (
<div className="px-4 pb-4 pt-0 border-t border-neutral-800">
<pre className="mt-3 p-3 bg-neutral-950 rounded-lg overflow-x-auto text-xs text-neutral-300 max-h-64 overflow-y-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
</div>
)}
</div>
@@ -560,26 +620,9 @@ function EventsTab({ events }: { events: Event[] }) {
return (
<div className="space-y-2">
{events.map((event) => {
const { icon: Icon, color } = eventTypeColors[event.type] || eventTypeColors.DEFAULT;
return (
<div
key={event.id}
className="flex items-center gap-4 p-4 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-neutral-700 transition-colors"
>
<div className={cn("p-2 rounded-lg bg-neutral-800", color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1">
<h4 className="font-medium text-neutral-100">{event.name}</h4>
<p className="text-xs text-neutral-500">{event.type}</p>
</div>
<span className="text-sm text-neutral-400">
{formatRelativeTime(event.timestamp)}
</span>
</div>
);
})}
{events.map((event) => (
<EventItem key={event.id} event={event} />
))}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
export async function validateApiKey(bearerToken: string) {
const keyHash = createHash("sha256").update(bearerToken).digest("hex");
const apiKey = await prisma.apiKey.findFirst({
where: { keyHash, revoked: false },
include: {
user: {
include: {
subscription: true,
},
},
},
});
if (!apiKey) return null;
prisma.apiKey
.update({
where: { id: apiKey.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
return {
userId: apiKey.userId,
user: apiKey.user,
subscription: apiKey.user.subscription,
apiKey,
};
}

View File

@@ -0,0 +1,35 @@
import Stripe from "stripe";
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (!_stripe) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
_stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" });
}
return _stripe;
}
export const TIER_CONFIG = {
FREE: {
name: "Free",
sessionsLimit: 20,
period: "day",
price: 0,
},
STARTER: {
name: "Starter",
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
sessionsLimit: 1000,
period: "month",
price: 5,
},
PRO: {
name: "Pro",
priceId: process.env.STRIPE_PRO_PRICE_ID!,
sessionsLimit: 100000,
period: "month",
price: 20,
},
} as const;

View File

@@ -0,0 +1,50 @@
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import authConfig from "./auth.config";
const { auth } = NextAuth(authConfig);
const publicPaths = [
"/",
"/docs",
"/api/auth",
"/api/traces",
"/api/health",
];
function isPublicPath(pathname: string): boolean {
return publicPaths.some(
(p) => pathname === p || pathname.startsWith(`${p}/`)
);
}
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
if (isPublicPath(pathname)) {
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
return NextResponse.next();
}
if (pathname === "/login" || pathname === "/register") {
if (isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
return NextResponse.next();
}
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
const loginUrl = new URL("/login", req.nextUrl.origin);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
};

View File

@@ -9,6 +9,12 @@ services:
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
- AUTH_SECRET=Ge0Gh6bObko0Gdrzv+l0qKHgvut3M7Av8mDFQG9fYzs=
- AUTH_TRUST_HOST=true
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
depends_on:
redis:
condition: service_started
@@ -63,7 +69,7 @@ services:
build:
context: .
target: builder
command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma
command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate
environment:
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
depends_on:

26
launch/linkedin.md Normal file
View 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
View 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
View 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

159
package-lock.json generated
View File

@@ -24,14 +24,19 @@
"@agentlens/database": "*",
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.469.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.30",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.22.0"
"shiki": "^3.22.0",
"stripe": "^20.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
@@ -41,6 +46,15 @@
"typescript": "^5.7"
}
},
"apps/web/node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@agentlens/database": {
"resolved": "packages/database",
"link": true
@@ -62,6 +76,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
"integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@dagrejs/dagre": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
@@ -1197,6 +1240,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@prisma/client": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
@@ -1986,6 +2038,13 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -2071,7 +2130,7 @@
"version": "22.19.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
"integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2165,6 +2224,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@@ -2777,6 +2845,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -3328,6 +3405,33 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.30",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
"integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.0"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -3388,6 +3492,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz",
"integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -3553,6 +3666,25 @@
}
}
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prisma": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
@@ -3854,6 +3986,23 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stripe": {
"version": "20.3.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -4131,7 +4280,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unist-util-is": {
@@ -4300,7 +4449,7 @@
},
"packages/opencode-plugin": {
"name": "opencode-agentlens",
"version": "0.1.0",
"version": "0.1.6",
"license": "MIT",
"dependencies": {
"agentlens-sdk": "*"
@@ -4376,7 +4525,7 @@
},
"packages/sdk-ts": {
"name": "agentlens-sdk",
"version": "0.1.0",
"version": "0.1.3",
"license": "MIT",
"devDependencies": {
"tsup": "^8.3.0",

View File

@@ -7,6 +7,82 @@ generator client {
provider = "prisma-client-js"
}
// ─── Auth & Billing ────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription Subscription?
apiKeys ApiKey[]
traces Trace[]
@@index([email])
}
model ApiKey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @default("Default")
keyHash String @unique // SHA-256 hash of the actual key
keyPrefix String // First 8 chars for display: "al_xxxx..."
lastUsedAt DateTime?
revoked Boolean @default(false)
createdAt DateTime @default(now())
@@index([keyHash])
@@index([userId])
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tier SubscriptionTier @default(FREE)
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
// Usage tracking for the current billing period
sessionsUsed Int @default(0)
sessionsLimit Int @default(20) // Free tier: 20/day, paid: per month
status SubscriptionStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([stripeCustomerId])
@@index([stripeSubscriptionId])
}
enum SubscriptionTier {
FREE // 20 sessions/day
STARTER // $5/mo — 1,000 sessions/mo
PRO // $20/mo — 100,000 sessions/mo
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELED
UNPAID
}
// ─── Observability ─────────────────────────────────────────────
model Trace {
id String @id @default(cuid())
sessionId String?
@@ -15,6 +91,10 @@ model Trace {
tags String[] @default([])
metadata Json?
// Owner — nullable for backward compat with existing unowned traces
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
totalCost Float?
totalTokens Int?
totalDuration Int?
@@ -32,6 +112,7 @@ model Trace {
@@index([status])
@@index([createdAt])
@@index([name])
@@index([userId])
}
model DecisionPoint {

View File

@@ -1,6 +1,6 @@
{
"name": "opencode-agentlens",
"version": "0.1.1",
"version": "0.1.6",
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
"type": "module",
"main": "./dist/index.cjs",

View File

@@ -22,11 +22,10 @@ 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 {};
}
console.log(`[agentlens] Plugin enabled — endpoint: ${config.endpoint}`);
init({
apiKey: config.apiKey,
@@ -62,7 +61,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
worktree,
title: info?.["title"] as string | undefined,
});
console.log(`[agentlens] Session started: ${sessionId}`);
}
}
@@ -107,7 +106,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
if (sessionId) {
state.endSession(sessionId);
await flush();
console.log(`[agentlens] Session ended and flushed: ${sessionId}`);
}
}
@@ -157,9 +156,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
directory,
worktree,
});
console.log(
`[agentlens] Auto-created session from tool call: ${input.sessionID}`,
);
}
state.startToolCall(
input.callID,
@@ -173,7 +170,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
state.endToolCall(
input.callID,
truncate(output.output ?? "", config.maxOutputLength),
output.title ?? input.tool,
output.title ?? input.tool ?? "unknown-tool",
output.metadata as unknown,
);
},
@@ -186,9 +183,7 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
directory,
worktree,
});
console.log(
`[agentlens] Auto-created session from chat.message: ${input.sessionID}`,
);
}
if (input.model) {
state.recordLLMCall(input.sessionID, {
@@ -207,8 +202,8 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
name: "chat.params",
metadata: safeJsonValue({
agent: input.agent,
model: input.model.id,
provider: input.provider.info.id,
model: input.model?.id,
provider: input.provider?.info?.id,
temperature: output.temperature,
topP: output.topP,
topK: output.topK,

View File

@@ -99,7 +99,7 @@ export class SessionState {
const toolMeta = extractToolMetadata(call.tool, call.args);
trace.addSpan({
name: title,
name: title || call.tool || "unknown-tool",
type: SpanType.TOOL_CALL,
parentSpanId: rootSpanId,
input: safeJsonValue(call.args),
@@ -111,11 +111,16 @@ export class SessionState {
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
});
const reasoningText =
title !== call.tool && title
? `Selected ${call.tool}: ${title}`
: `Selected tool: ${call.tool}`;
trace.addDecision({
type: DecisionType.TOOL_SELECTION,
chosen: call.tool as JsonValue,
alternatives: [],
reasoning: title,
reasoning: reasoningText,
durationMs,
parentSpanId: rootSpanId,
});
@@ -186,17 +191,29 @@ export class SessionState {
return Array.from(this.traces.keys());
}
/**
* Send the current trace state without ending the session.
* This creates a snapshot so data isn't lost if the process exits unexpectedly.
*/
flushSession(sessionId: string): 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: SpanStatus.COMPLETED,
endedAt: nowISO(),
});
}
trace.end({ status: "COMPLETED" });
const transport = getClient();
if (transport) {
transport.add(trace.toPayload());
}
this.traces.delete(sessionId);
this.rootSpans.delete(sessionId);
}
}

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{
"name": "agentlens-sdk",
"version": "0.1.0",
"version": "0.1.3",
"description": "AgentLens TypeScript SDK — Agent observability that traces decisions, not just API calls.",
"type": "module",
"main": "./dist/index.cjs",

View File

@@ -27,7 +27,12 @@ export class BatchTransport {
}
add(trace: TracePayload): void {
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();
}
@@ -63,15 +68,9 @@ export class BatchTransport {
});
if (!response.ok) {
const text = await response.text().catch(() => "");
console.warn(
`AgentLens: Failed to send traces (HTTP ${response.status}): ${text.slice(0, 200)}`,
);
await response.text().catch(() => "");
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : String(error);
console.warn(`AgentLens: Failed to send traces: ${message}`);
} catch {
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB