refactor(ui): inject Lens backends as services instead of a fake HTTP client (#44369)

The interactive Lens demo used to build a fetch shim that encoded in-memory
fixtures as HTTP responses so the shared ApiClient could decode them again,
and every trace view branched on demo vs live to pick a URL. Lens and the
trace views now depend on two small service interfaces, LensApi and
TracesApi, with named operations. The live layer wraps the existing HTTP
calls, the demo layer reads fixtures directly, and a React context provides
whichever one the session runs on. Without a provider the hooks fall back to
the live implementation, so the live app and existing tests are unchanged.

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-03 09:11:08 +00:00 • committed by GitHub
parent 09313bcf1b
commit e92bd50de1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 423 additions and 346 deletions

View file

@ -1,10 +1,8 @@
"use client";
import { createContext, useContext } from "react";
import type { ApiClient } from "@/lib/http/client";
export interface LensDemo {
client: ApiClient;
copyTrace: (traceId: string, spanId?: string) => string;
}

View file

@ -8,6 +8,7 @@ import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
import { DemoNotice } from "@/components/shared/DemoNotice";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LensDemoContext, useLensDemo } from "./LensDemoContext";
import { LensServicesContext } from "./services";
import { LensPreviewTarget } from "./LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
@ -37,11 +38,13 @@ function DemoSession({ initialTab, onExit }: { initialTab: Tab; onExit: () => vo
[client],
);
return (
<LensDemoContext.Provider value={demo}>
<QueryClientProvider client={client}>
<LensContent accessToken="lens-demo" userRole="" readOnly initialTab={initialTab} onExit={onExit} />
</QueryClientProvider>
</LensDemoContext.Provider>
<LensServicesContext.Provider value={demo.services}>
<LensDemoContext.Provider value={demo}>
<QueryClientProvider client={client}>
<LensContent accessToken="lens-demo" userRole="" readOnly initialTab={initialTab} onExit={onExit} />
</QueryClientProvider>
</LensDemoContext.Provider>
</LensServicesContext.Provider>
);
}

View file

@ -3,54 +3,35 @@
import { validateWorkerAddress, analysisAccessSchema, type AnalysisAccess } from "../setup/worker/workerSchema";
import { useMutation } from "@tanstack/react-query";
import type { ApiClient } from "@/lib/http/client";
import type { Lens, Settings, WorkerCreated, LensList } from "../model/types";
import type { Lens, Settings, WorkerCreated } from "../model/types";
import { useLensApi } from "./useLensApi";
import type { LensApi } from "./service";
import { useLensApi } from "../services";
export type LensWrite = (api: LensApi) => Promise<unknown>;
export function useLensUpdate(accessToken: string) {
const apiClient = useLensApi();
return useMutation({
retry: false,
mutationFn: ({ path, body, method = "post" }: { path: string; body: unknown; method?: "post" | "put" | "patch" }) =>
apiClient[method]<unknown>(path, { accessToken, body }),
});
const api = useLensApi(accessToken);
return useMutation({ retry: false, mutationFn: (write: LensWrite) => write(api) });
}
export function useSaveLens(accessToken: string) {
const apiClient = useLensApi();
const api = useLensApi(accessToken);
return useMutation({
retry: false,
mutationFn: ({ id, settings }: { id?: string; settings: Settings }) =>
apiClient.request<Lens>(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }),
mutationFn: ({ id, settings }: { id?: string; settings: Settings }): Promise<Lens> => api.saveLens(id, settings),
});
}
export function useRevokeWorker(accessToken: string) {
const apiClient = useLensApi();
return useMutation({
retry: false,
mutationFn: (id: string) => apiClient.delete<unknown>(`/lens/workers/${id}`, { accessToken }),
});
const api = useLensApi(accessToken);
return useMutation({ retry: false, mutationFn: (id: string) => api.revokeWorker(id) });
}
export async function createAnalysisKey(
apiClient: ApiClient,
accessToken: string,
access: AnalysisAccess,
): Promise<string> {
export async function createAnalysisKey(api: LensApi, access: AnalysisAccess): Promise<string> {
const parsed = analysisAccessSchema.safeParse(access);
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
const result = await apiClient.post<{ token_id?: string }>("/key/generate", {
accessToken,
body: {
key_alias: "Lens analysis",
models: [parsed.data.model],
max_budget: parsed.data.budget,
budget_duration: "1mo",
metadata: { purpose: "lens" },
},
});
const result = await api.generateAnalysisKey(parsed.data);
if (!result.token_id) throw new Error("The proxy did not return the new key's ID");
return result.token_id;
}
@ -59,7 +40,7 @@ export function usePrepareWorker(
accessToken: string,
{ onChanged, onPrepared }: { onChanged: () => void; onPrepared: (created: WorkerCreated | null) => void },
) {
const apiClient = useLensApi();
const api = useLensApi(accessToken);
return useMutation({
retry: false,
mutationFn: async ({
@ -78,21 +59,15 @@ export function usePrepareWorker(
let newKey: string | null = null;
try {
validateWorkerAddress(address);
const keyId = useExisting ? analysisKey : await createAnalysisKey(apiClient, accessToken, access);
const keyId = useExisting ? analysisKey : await createAnalysisKey(api, access);
if (!useExisting) newKey = keyId;
if (editingWorker) {
await apiClient.put<unknown>(`/lens/workers/${editingWorker}/billing-key`, {
accessToken,
body: { analysis_key_id: keyId },
});
await api.setWorkerBillingKey(editingWorker, keyId);
onPrepared(null);
onChanged();
return null;
}
const created = await apiClient.post<WorkerCreated>("/lens/workers/register", {
accessToken,
body: { name: "Lens worker", analysis_key_id: keyId },
});
const created = await api.registerWorker(keyId);
onPrepared(created);
onChanged();
return created;
@ -100,9 +75,9 @@ export function usePrepareWorker(
const message = e instanceof Error ? e.message : "Could not create credential";
if (newKey) {
try {
const current = await apiClient.get<LensList>("/lens", { accessToken });
const current = await api.lenses();
if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) {
await apiClient.post<unknown>("/key/delete", { accessToken, body: { keys: [newKey] } });
await api.deleteKeys([newKey]);
}
onChanged();
} catch {

View file

@ -1,41 +1,39 @@
import { infiniteQueryOptions, queryOptions, type Query } from "@tanstack/react-query";
import { z } from "zod";
import type { ApiClient } from "@/lib/http/client";
import type { components } from "@/lib/http/schema";
import { type LensList, type Job, type Sample, type Settings, type ActivitySelection } from "../model/types";
import type { AnalysisModelInfo } from "../setup/fields/analysisModels";
import type { LensList, Settings, ActivitySelection } from "../model/types";
import type { KeyPage, LensApi } from "./service";
export type { Key } from "./service";
export const lensKeys = {
all: ["lens"] as const,
lists: () => [...lensKeys.all, "list"] as const,
list: (accessToken: string) => [...lensKeys.lists(), { accessToken }] as const,
list: (scope: string) => [...lensKeys.lists(), { scope }] as const,
histories: () => [...lensKeys.all, "history"] as const,
history: (accessToken: string, lensId: string | undefined, offset: number) =>
[...lensKeys.histories(), { accessToken, lensId, offset }] as const,
history: (scope: string, lensId: string | undefined, offset: number) =>
[...lensKeys.histories(), { scope, lensId, offset }] as const,
runs: () => [...lensKeys.all, "run"] as const,
run: (accessToken: string, lensId: string | undefined, batchId: string) =>
[...lensKeys.runs(), { accessToken, lensId, batchId }] as const,
evidence: (accessToken: string, lensId: string | undefined, evidenceId: string | undefined, offset: number) =>
[...lensKeys.all, "evidence", { accessToken, lensId, evidenceId, offset }] as const,
models: (accessToken: string) => [...lensKeys.all, "models", { accessToken }] as const,
modelDetails: (accessToken: string) => [...lensKeys.all, "model-details", { accessToken }] as const,
activity: (accessToken: string) => [...lensKeys.all, "activity-available", { accessToken }] as const,
discovery: (accessToken: string, source: Settings["source"], hours: number | undefined, asOf: string) =>
[...lensKeys.all, "discovery", { accessToken, source, hours, asOf }] as const,
preview: (accessToken: string, scope: ActivitySelection, offset: number, asOf: string) =>
[...lensKeys.all, "preview", { accessToken, scope, offset, asOf }] as const,
agents: (accessToken: string, asOf: string) => [...lensKeys.all, "agents", { accessToken, asOf }] as const,
analysisKeys: (accessToken: string, query: string) =>
[...lensKeys.all, "analysis-keys", { accessToken, query }] as const,
analysisKeyInfo: (accessToken: string, keyId: string | undefined) =>
[...lensKeys.all, "analysis-key-info", { accessToken, keyId }] as const,
run: (scope: string, lensId: string | undefined, batchId: string) =>
[...lensKeys.runs(), { scope, lensId, batchId }] as const,
evidence: (scope: string, lensId: string | undefined, evidenceId: string | undefined, offset: number) =>
[...lensKeys.all, "evidence", { scope, lensId, evidenceId, offset }] as const,
models: (scope: string) => [...lensKeys.all, "models", { scope }] as const,
modelDetails: (scope: string) => [...lensKeys.all, "model-details", { scope }] as const,
activity: (scope: string) => [...lensKeys.all, "activity-available", { scope }] as const,
discovery: (scope: string, source: Settings["source"], hours: number | undefined, asOf: string) =>
[...lensKeys.all, "discovery", { scope, source, hours, asOf }] as const,
preview: (scope: string, selection: ActivitySelection, offset: number, asOf: string) =>
[...lensKeys.all, "preview", { scope, selection, offset, asOf }] as const,
agents: (scope: string, asOf: string) => [...lensKeys.all, "agents", { scope, asOf }] as const,
analysisKeys: (scope: string, query: string) => [...lensKeys.all, "analysis-keys", { scope, query }] as const,
analysisKeyInfo: (scope: string, keyId: string | undefined) =>
[...lensKeys.all, "analysis-key-info", { scope, keyId }] as const,
};
export const lensQueries = {
list(apiClient: ApiClient, accessToken: string, demo: boolean, workerSetup: boolean) {
list(api: LensApi, demo: boolean, workerSetup: boolean) {
const options = {
queryKey: lensKeys.list(accessToken),
queryFn: () => apiClient.get<LensList>("/lens", { accessToken }),
queryKey: lensKeys.list(api.scope),
queryFn: () => api.lenses(),
refetchInterval: (current: Query<LensList>): number | false => {
if (demo) return false;
const running = current.state.data?.lenses.some((item) =>
@ -46,53 +44,43 @@ export const lensQueries = {
};
return queryOptions(options);
},
models(apiClient: ApiClient, accessToken: string) {
const options = {
queryKey: lensKeys.models(accessToken),
queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }),
};
return queryOptions(options);
models(api: LensApi) {
return queryOptions({ queryKey: lensKeys.models(api.scope), queryFn: () => api.models() });
},
modelDetails(apiClient: ApiClient, accessToken: string) {
const options = {
queryKey: lensKeys.modelDetails(accessToken),
queryFn: () => apiClient.get<{ data: AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
};
return queryOptions(options);
modelDetails(api: LensApi) {
return queryOptions({ queryKey: lensKeys.modelDetails(api.scope), queryFn: () => api.modelDetails() });
},
activity(apiClient: ApiClient, accessToken: string, loaded: boolean, demo: boolean) {
activity(api: LensApi, loaded: boolean, demo: boolean) {
const options = {
queryKey: lensKeys.activity(accessToken),
queryFn: () => apiClient.get<{ traces: boolean; requests: boolean }>("/lens/activity/available", { accessToken }),
queryKey: lensKeys.activity(api.scope),
queryFn: () => api.activity(),
enabled: loaded,
refetchInterval: demo ? (false as const) : 5000,
};
return queryOptions(options);
},
history(
apiClient: ApiClient,
accessToken: string,
api: LensApi,
{ lensId, historyOffset, demo }: { lensId: string | undefined; historyOffset: number; demo: boolean },
) {
const options = {
queryKey: lensKeys.history(accessToken, lensId, historyOffset),
queryKey: lensKeys.history(api.scope, lensId, historyOffset),
enabled: !!lensId,
queryFn: () => apiClient.get<Job[]>(`/lens/${lensId}/runs`, { accessToken, query: { offset: historyOffset } }),
queryFn: () => api.runs(lensId as string, historyOffset),
refetchInterval: demo ? (false as const) : 10000,
};
return queryOptions(options);
},
run(apiClient: ApiClient, accessToken: string, lensId: string | undefined, batchId: string) {
run(api: LensApi, lensId: string | undefined, batchId: string) {
const options = {
queryKey: lensKeys.run(accessToken, lensId, batchId),
queryKey: lensKeys.run(api.scope, lensId, batchId),
enabled: !!lensId && !["latest", "all"].includes(batchId),
queryFn: () => apiClient.get<Job>(`/lens/${lensId}/runs/${batchId}`, { accessToken }),
queryFn: () => api.run(lensId as string, batchId),
};
return queryOptions(options);
},
evidence(
apiClient: ApiClient,
accessToken: string,
api: LensApi,
{
lensId,
evidenceId,
@ -106,78 +94,38 @@ export const lensQueries = {
},
) {
const options = {
queryKey: lensKeys.evidence(accessToken, lensId, evidenceId, requestOffset),
queryKey: lensKeys.evidence(api.scope, lensId, evidenceId, requestOffset),
enabled: !!lensId && source === "requests",
queryFn: () =>
apiClient.get<components["schemas"]["ExecutionContent"]>(
`/lens/${lensId}/executions/${encodeURIComponent(evidenceId ?? "")}`,
{ accessToken, query: { offset: requestOffset } },
),
queryFn: () => api.execution(lensId as string, evidenceId ?? "", requestOffset),
};
return queryOptions(options);
},
sample(
apiClient: ApiClient,
accessToken: string,
{ selection, pageOffset, asOf }: { selection: ActivitySelection; pageOffset: number; asOf: string },
) {
const { lookback_hours, ...selectionSettings } = selection;
return apiClient.post<Sample>("/lens/preview/sample", {
accessToken,
body: {
offset: pageOffset,
as_of: asOf,
settings: {
...selectionSettings,
execution_ids: [],
name: "Preview",
model: "preview",
checks: [{ id: "preview", instruction: "Preview recorded activity" }],
},
lookback_hours: lookback_hours ?? 24,
},
});
},
discovery(
apiClient: ApiClient,
accessToken: string,
{ value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean },
) {
discovery(api: LensApi, { value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean }) {
const unfiltered = { source: value.source, service: "", filters: [], lookback_hours: value.lookback_hours };
const options = {
queryKey: lensKeys.discovery(accessToken, value.source, value.lookback_hours, asOf),
queryFn: () =>
lensQueries.sample(apiClient, accessToken, {
selection: {
source: value.source,
service: "",
filters: [],
lookback_hours: value.lookback_hours,
},
pageOffset: 0,
asOf,
}),
queryKey: lensKeys.discovery(api.scope, value.source, value.lookback_hours, asOf),
queryFn: () => api.sample(unfiltered, 0, asOf),
staleTime: 60000,
enabled,
};
return queryOptions(options);
},
preview(
apiClient: ApiClient,
accessToken: string,
api: LensApi,
{ scope, offset, asOf, enabled }: { scope: ActivitySelection; offset: number; asOf: string; enabled: boolean },
) {
const options = {
queryKey: lensKeys.preview(accessToken, scope, offset, asOf),
queryFn: () => lensQueries.sample(apiClient, accessToken, { selection: scope, pageOffset: offset, asOf }),
queryKey: lensKeys.preview(api.scope, scope, offset, asOf),
queryFn: () => api.sample(scope, offset, asOf),
enabled,
staleTime: 30000,
};
return queryOptions(options);
},
agents(apiClient: ApiClient, accessToken: string, asOf: string, source: Settings["source"]) {
agents(api: LensApi, asOf: string, source: Settings["source"]) {
const options = {
queryKey: lensKeys.agents(accessToken, asOf),
queryFn: () => apiClient.get<string[]>("/lens/agents", { accessToken }),
queryKey: lensKeys.agents(api.scope, asOf),
queryFn: () => api.agents(),
enabled: source !== "requests",
staleTime: 60000,
};
@ -185,55 +133,22 @@ export const lensQueries = {
},
};
const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() });
const pageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() });
export type Key = z.infer<typeof keySchema>;
export function analysisKeysQuery(apiClient: ApiClient, accessToken: string, query: string) {
export function analysisKeysQuery(api: LensApi, query: string) {
const options = {
queryKey: lensKeys.analysisKeys(accessToken, query),
queryKey: lensKeys.analysisKeys(api.scope, query),
initialPageParam: 1,
queryFn: async ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) =>
pageSchema.parse(
await apiClient.get("/key/list", {
accessToken,
signal,
query: {
page: String(pageParam),
size: "25",
return_full_object: "true",
key_alias: query || undefined,
substring_matching: "true",
include_team_keys: "true",
include_created_by_keys: "true",
status: "active",
},
}),
),
getNextPageParam: (lastPage: z.infer<typeof pageSchema>, pages: z.infer<typeof pageSchema>[]) =>
queryFn: ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) => api.keys(query, pageParam, signal),
getNextPageParam: (lastPage: KeyPage, pages: KeyPage[]) =>
pages.length < lastPage.total_pages ? pages.length + 1 : undefined,
};
return infiniteQueryOptions(options);
}
const keyInfoFields = {
key_alias: z.string().nullable().optional(),
models: z.array(z.string()),
max_budget: z.number().nullable(),
budget_duration: z.string().nullable().optional(),
rpm_limit: z.number().nullable().optional(),
tpm_limit: z.number().nullable().optional(),
expires: z.string().nullable().optional(),
status: z.string().optional(),
};
const keyInfoSchema = z.object({ info: z.object(keyInfoFields) });
export function analysisKeyInfoQuery(apiClient: ApiClient, accessToken: string, keyId?: string) {
export function analysisKeyInfoQuery(api: LensApi, keyId?: string) {
const options = {
queryKey: lensKeys.analysisKeyInfo(accessToken, keyId),
queryKey: lensKeys.analysisKeyInfo(api.scope, keyId),
enabled: !!keyId,
queryFn: async () =>
keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info,
queryFn: () => api.keyInfo(keyId as string),
};
return queryOptions(options);
}

View file

@ -0,0 +1,137 @@
import { z } from "zod";
import type { ApiClient } from "@/lib/http/client";
import type { components } from "@/lib/http/schema";
import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types";
import type { AnalysisModelInfo } from "../setup/fields/analysisModels";
export type ExecutionContent = components["schemas"]["ExecutionContent"];
export type FindingStatus = components["schemas"]["FindingUpdate"]["status"];
const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() });
const keyPageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() });
const keyInfoFields = {
key_alias: z.string().nullable().optional(),
models: z.array(z.string()),
max_budget: z.number().nullable(),
budget_duration: z.string().nullable().optional(),
rpm_limit: z.number().nullable().optional(),
tpm_limit: z.number().nullable().optional(),
expires: z.string().nullable().optional(),
status: z.string().optional(),
};
const keyInfoSchema = z.object({ info: z.object(keyInfoFields) });
export type Key = z.infer<typeof keySchema>;
export type KeyPage = z.infer<typeof keyPageSchema>;
export type KeyInfo = z.infer<typeof keyInfoSchema>["info"];
export interface AnalysisKeyRequest {
readonly model: string;
readonly budget: number;
}
export interface LensApi {
/** Partitions query caches between backends (one token, or the demo). */
readonly scope: string;
lenses(): Promise<LensList>;
activity(): Promise<{ traces: boolean; requests: boolean }>;
runs(lensId: string, offset: number): Promise<Job[]>;
run(lensId: string, jobId: string): Promise<Job>;
execution(lensId: string, executionId: string, offset: number): Promise<ExecutionContent>;
sample(selection: ActivitySelection, offset: number, asOf: string): Promise<Sample>;
agents(): Promise<string[]>;
models(): Promise<{ data: { id: string }[] }>;
modelDetails(): Promise<{ data: AnalysisModelInfo[] }>;
keys(alias: string, page: number, signal: AbortSignal): Promise<KeyPage>;
keyInfo(keyId: string): Promise<KeyInfo>;
saveLens(id: string | undefined, settings: Settings): Promise<Lens>;
startRun(lensId: string): Promise<void>;
cancelRun(lensId: string): Promise<void>;
reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise<void>;
registerWorker(analysisKeyId: string | null): Promise<WorkerCreated>;
setWorkerBillingKey(workerId: string, analysisKeyId: string | null): Promise<void>;
revokeWorker(workerId: string): Promise<void>;
generateAnalysisKey(request: AnalysisKeyRequest): Promise<{ token_id?: string }>;
deleteKeys(keys: readonly string[]): Promise<void>;
}
export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi {
const encode = encodeURIComponent;
return {
scope: accessToken,
lenses: () => apiClient.get<LensList>("/lens", { accessToken }),
activity: () => apiClient.get("/lens/activity/available", { accessToken }),
runs: (lensId, offset) => apiClient.get<Job[]>(`/lens/${lensId}/runs`, { accessToken, query: { offset } }),
run: (lensId, jobId) => apiClient.get<Job>(`/lens/${lensId}/runs/${jobId}`, { accessToken }),
execution: (lensId, executionId, offset) =>
apiClient.get<ExecutionContent>(`/lens/${lensId}/executions/${encode(executionId)}`, {
accessToken,
query: { offset },
}),
sample: (selection, offset, asOf) => {
const { lookback_hours, ...selectionSettings } = selection;
return apiClient.post<Sample>("/lens/preview/sample", {
accessToken,
body: {
offset,
as_of: asOf,
settings: {
...selectionSettings,
execution_ids: [],
name: "Preview",
model: "preview",
checks: [{ id: "preview", instruction: "Preview recorded activity" }],
},
lookback_hours: lookback_hours ?? 24,
},
});
},
agents: () => apiClient.get<string[]>("/lens/agents", { accessToken }),
models: () => apiClient.get("/models", { accessToken }),
modelDetails: () => apiClient.get("/model_group/info", { accessToken }),
keys: async (alias, page, signal) =>
keyPageSchema.parse(
await apiClient.get("/key/list", {
accessToken,
signal,
query: {
page: String(page),
size: "25",
return_full_object: "true",
key_alias: alias || undefined,
substring_matching: "true",
include_team_keys: "true",
include_created_by_keys: "true",
status: "active",
},
}),
),
keyInfo: async (keyId) =>
keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info,
saveLens: (id, settings) =>
apiClient.request<Lens>(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }),
startRun: (lensId) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: {} }),
cancelRun: (lensId) => apiClient.post(`/lens/${lensId}/cancel`, { accessToken, body: {} }),
reviewFinding: (lensId, findingId, status, reason) =>
apiClient.patch(`/lens/${lensId}/findings/${findingId}`, { accessToken, body: { status, reason } }),
registerWorker: (analysisKeyId) =>
apiClient.post<WorkerCreated>("/lens/workers/register", {
accessToken,
body: { name: "Lens worker", analysis_key_id: analysisKeyId },
}),
setWorkerBillingKey: (workerId, analysisKeyId) =>
apiClient.put(`/lens/workers/${workerId}/billing-key`, { accessToken, body: { analysis_key_id: analysisKeyId } }),
revokeWorker: (workerId) => apiClient.delete(`/lens/workers/${workerId}`, { accessToken }),
generateAnalysisKey: (request) =>
apiClient.post<{ token_id?: string }>("/key/generate", {
accessToken,
body: {
key_alias: "Lens analysis",
models: [request.model],
max_budget: request.budget,
budget_duration: "1mo",
metadata: { purpose: "lens" },
},
}),
deleteKeys: (keys) => apiClient.post("/key/delete", { accessToken, body: { keys } }),
};
}

View file

@ -1,8 +0,0 @@
"use client";
import { apiClient } from "@/components/networking";
import { useLensDemo } from "../LensDemoContext";
export function useLensApi() {
return useLensDemo()?.client ?? apiClient;
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { createLensDemo, createLensDemoData } from "./createLensDemo";
import type { TracePage } from "@/components/view_logs/TraceView/traceTypes";
import { evidenceTarget } from "../model/findings";
describe("Lens demo data", () => {
@ -56,14 +55,16 @@ describe("Lens demo data", () => {
it("filters time windows locally and rejects writes or unknown reads without network access", async () => {
const network = vi.spyOn(globalThis, "fetch");
const now = Date.now();
const { client } = createLensDemo(now);
const all = await client.get<TracePage>("/v1/traces");
const recent = await client.get<TracePage>("/v1/traces", { query: { start_ms: now - 3600_000, end_ms: now } });
const { services } = createLensDemo(now);
const all = await services.traces.list({ startMs: 0, endMs: now });
const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now });
expect(recent.data.length).toBeGreaterThan(0);
expect(recent.data.length).toBeLessThan(all.data.length);
expect(recent.data.every((trace) => Date.parse(trace.start_time) >= now - 3600_000)).toBe(true);
await expect(client.post("/lens", { body: {} })).rejects.toMatchObject({ status: 403 });
await expect(client.get("/lens/real-investigation")).rejects.toMatchObject({ status: 404 });
const settings = services.lens.lenses().then((list) => list.lenses[0].settings);
await expect(services.lens.saveLens(undefined, await settings)).rejects.toMatchObject({ status: 403 });
await expect(services.lens.run("real-investigation", "job")).rejects.toMatchObject({ status: 404 });
await expect(services.traces.trace("missing")).rejects.toMatchObject({ status: 404 });
expect(network).not.toHaveBeenCalled();
network.mockRestore();
});

View file

@ -1,5 +1,8 @@
import { createApiClient } from "@/lib/http/client";
import { ApiError } from "@/lib/http/client";
import type { TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import type { LensDemo } from "../LensDemoContext";
import type { LensServices } from "../services";
import type { LensApi } from "../api/service";
import type { Trace, Span, SpanDetail } from "@/components/view_logs/TraceView/traceTypes";
import type { Lens, Finding, Job, Settings } from "../model/types";
import { withReleaseCases } from "./lensDemoLongTrace";
@ -109,6 +112,8 @@ function makeTrace(scene: Scenario, index: number, now: number) {
return { trace, details };
}
export type LensDemoData = ReturnType<typeof createLensDemoData>;
export function createLensDemoData(now = Date.now()) {
const runs = scenarios.map((scene, index) => {
const run = makeTrace(scene, index, now);
@ -331,57 +336,73 @@ export function createLensDemoData(now = Date.now()) {
return { runs, lenses };
}
function readDemoTrace(data: ReturnType<typeof createLensDemoData>, url: URL): unknown {
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] !== "v1" || parts[1] !== "traces") return undefined;
if (!parts[2]) {
const start = Number(url.searchParams.get("start_ms") ?? 0);
const end = Number(url.searchParams.get("end_ms") ?? Infinity);
return {
data: data.runs
.map((run) => run.trace.summary)
.filter((trace) => Date.parse(trace.start_time) >= start && Date.parse(trace.start_time) <= end),
next_cursor: null,
};
}
const run = data.runs.find(({ trace }) => trace.summary.trace_id === parts[2]);
if (!parts[3]) return run?.trace;
if (parts[3] === "spans" && !parts[5]) return run?.details.find((span) => span.span_id === parts[4]);
if (parts[3] === "spans" && parts[5] === "error") {
const span = run?.trace.spans.find((item) => item.span_id === parts[4]);
return span
? { span_id: span.span_id, message: span.error ?? "", total_chars: span.error?.length ?? 0, next_cursor: null }
: undefined;
}
return undefined;
const notInDemo = (): Promise<never> =>
Promise.reject(new ApiError("This item is not in the demo", 404, { detail: "This item is not in the demo" }));
const readOnly = (): Promise<never> =>
Promise.reject(new ApiError("Demo data is read-only", 403, { detail: "Demo data is read-only" }));
const found = <T>(value: T | undefined): Promise<T> => (value === undefined ? notInDemo() : Promise.resolve(value));
function demoLensApi(data: LensDemoData): LensApi {
const jobs = (lensId: string) => data.lenses.find((lens) => lens.id === lensId)?.jobs;
return {
scope: "demo",
lenses: async () => ({ lenses: data.lenses, workers: [], tracing_enabled: true }),
activity: async () => ({ traces: true, requests: false }),
runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)),
run: (lensId, jobId) => found(jobs(lensId)?.find((job) => job.id === jobId)),
execution: notInDemo,
sample: notInDemo,
agents: notInDemo,
models: async () => ({ data: [] }),
modelDetails: async () => ({ data: [] }),
keys: notInDemo,
keyInfo: notInDemo,
saveLens: readOnly,
startRun: readOnly,
cancelRun: readOnly,
reviewFinding: readOnly,
registerWorker: readOnly,
setWorkerBillingKey: readOnly,
revokeWorker: readOnly,
generateAnalysisKey: readOnly,
deleteKeys: readOnly,
};
}
export function createLensDemo(now = Date.now()): LensDemo {
const data = createLensDemoData(now);
const read = (url: URL): unknown => {
const parts = url.pathname.split("/").filter(Boolean);
if (url.pathname === "/lens") return { lenses: data.lenses, workers: [], tracing_enabled: true };
if (url.pathname === "/lens/activity/available") return { traces: true, requests: false };
if (url.pathname === "/models" || url.pathname === "/model_group/info") return { data: [] };
if (parts[0] === "lens" && parts[2] === "runs") {
const jobs = data.lenses.find((lens) => lens.id === parts[1])?.jobs;
return parts[3]
? jobs?.find((job) => job.id === parts[3])
: jobs?.slice(Number(url.searchParams.get("offset") ?? 0));
}
return readDemoTrace(data, url);
};
function demoTracesApi(data: LensDemoData): TracesApi {
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
return {
client: createApiClient({
getBaseUrl: () => "https://lens-demo.invalid",
fetchImpl: async (input, init) => {
if (init?.method !== "GET") return Response.json({ detail: "Demo data is read-only" }, { status: 403 });
const result = read(new URL(String(input)));
return result === undefined
? Response.json({ detail: "This item is not in the demo" }, { status: 404 })
: Response.json(result);
},
list: async ({ startMs, endMs }) => ({
data: data.runs
.map((item) => item.trace.summary)
.filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs),
next_cursor: null,
}),
anyRecorded: async () => data.runs.length > 0,
trace: (traceId) => found(run(traceId)?.trace),
span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)),
spanError: async (traceId, spanId) => {
const span = run(traceId)?.trace.spans.find((item) => item.span_id === spanId);
return found(
span && {
span_id: span.span_id,
message: span.error ?? "",
total_chars: span.error?.length ?? 0,
next_cursor: null,
},
);
},
};
}
export interface LensDemoSession extends LensDemo {
readonly services: LensServices;
}
export function createLensDemo(now = Date.now()): LensDemoSession {
const data = createLensDemoData(now);
return {
services: { lens: demoLensApi(data), traces: demoTracesApi(data) },
copyTrace: (traceId, spanId) => {
const run = data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
return JSON.stringify(spanId ? run?.details.find((span) => span.span_id === spanId) : run, null, 2);

View file

@ -4,9 +4,9 @@ import { InvestigationMissing, InvestigationsLoading, InvestigationError } from
import { InvestigationNavigation } from "./InvestigationNavigation";
import { useInvestigationResults } from "./useInvestigationResults";
import { useLensUpdate, useSaveLens } from "../api/mutations";
import { useLensUpdate, useSaveLens, type LensWrite } from "../api/mutations";
import { lensKeys, lensQueries } from "../api/queries";
import { useLensApi } from "../api/useLensApi";
import { useLensApi } from "../services";
import { InvestigationDetail } from "./detail/InvestigationDetail";
import { ReadinessBanner } from "./ReadinessBanner";
@ -40,16 +40,16 @@ export function InvestigationsView({
onDemo?: () => void;
}) {
const demo = useLensDemo();
const apiClient = useLensApi();
const api = useLensApi(accessToken);
const client = useQueryClient();
const updateLens = useLensUpdate(accessToken);
const saveLens = useSaveLens(accessToken);
const [workerSetup, setWorkerSetup] = useState(false);
const [monitoring, setMonitoring] = useState(false);
const now = useNow(2000);
const query = useQuery(lensQueries.list(apiClient, accessToken, !!demo, workerSetup));
const models = useQuery(lensQueries.models(apiClient, accessToken));
const modelDetails = useQuery(lensQueries.modelDetails(apiClient, accessToken));
const query = useQuery(lensQueries.list(api, !!demo, workerSetup));
const models = useQuery(lensQueries.models(api));
const modelDetails = useQuery(lensQueries.modelDetails(api));
const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
const [demoSelected, setDemoSelected] = useState<string | null>(null);
const selected = demo ? demoSelected : liveSelected;
@ -96,7 +96,7 @@ export function InvestigationsView({
const defaultKeyId = activeWorkers.length === 1 ? activeWorkers[0].analysis_key_id : undefined;
const analysisAccess = useAnalysisKeyInfo(accessToken, defaultKeyId ?? undefined);
const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined;
const activity = useQuery(lensQueries.activity(apiClient, accessToken, loaded, !!demo));
const activity = useQuery(lensQueries.activity(api, loaded, !!demo));
const { tracesReady, requestsReady, activityReady, ready } = readiness(
activity.data,
activity.error,
@ -110,15 +110,15 @@ export function InvestigationsView({
return lens?.settings;
};
const refresh = () => {
void client.invalidateQueries({ queryKey: lensKeys.list(accessToken) });
void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
void client.invalidateQueries({ queryKey: lensKeys.histories() });
};
const update = async (path: string, body: unknown, method: "post" | "put" | "patch" = "post") => {
const update = async (write: LensWrite) => {
setBusy(true);
setError("");
try {
await updateLens.mutateAsync({ path, body, method });
await client.invalidateQueries({ queryKey: lensKeys.list(accessToken) });
await updateLens.mutateAsync(write);
await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
} catch (e) {
setError(e instanceof Error ? e.message : "Could not update lens");
} finally {
@ -137,7 +137,7 @@ export function InvestigationsView({
};
const changeFinding = async (status: Finding["status"], reason: string) => {
if (!lens || !finding) return;
await update(`/lens/${lens.id}/findings/${finding.id}`, { status, reason }, "patch");
await update((current) => current.reviewFinding(lens.id, finding.id, status, reason));
};
return (

View file

@ -9,6 +9,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { type Lens, type Job } from "../../model/types";
import type { LensWrite } from "../../api/mutations";
export function InvestigationActions({
lens,
@ -25,7 +26,7 @@ export function InvestigationActions({
active: Job | undefined;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise<void>;
update: (write: LensWrite) => Promise<void>;
}) {
return (
<div className="flex flex-wrap gap-2">
@ -45,7 +46,7 @@ export function InvestigationActions({
{lens.settings.enabled ? (
<DropdownMenuItem
disabled={busy}
onClick={() => update(`/lens/${lens.id}`, { ...lens.settings, enabled: false }, "put")}
onClick={() => update((api) => api.saveLens(lens.id, { ...lens.settings, enabled: false }))}
>
<Pause />
Pause monitoring
@ -58,7 +59,7 @@ export function InvestigationActions({
)}
</DropdownMenuContent>
</DropdownMenu>
<Button disabled={busy || !!active || !ready} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
<Button disabled={busy || !!active || !ready} onClick={() => update((api) => api.startRun(lens.id))}>
<Play className="size-3" />
Run now
</Button>

View file

@ -10,6 +10,7 @@ import { InvestigationSummary } from "./InvestigationSummary";
import { InvestigationFailure } from "./InvestigationFailure";
import { scopeLabel, sourceLabels } from "../../model/format";
import { type Lens } from "../../model/types";
import type { LensWrite } from "../../api/mutations";
import { InvestigationActions } from "./InvestigationActions";
import { RunPicker } from "./RunPicker";
@ -33,7 +34,7 @@ export function InvestigationDetail({
busy: boolean;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise<void>;
update: (write: LensWrite) => Promise<void>;
connected: boolean;
results: ReturnType<typeof useInvestigationResults>;
}) {
@ -96,7 +97,7 @@ export function InvestigationDetail({
readOnly
? undefined
: () => {
void update(`/lens/${lens.id}/cancel`, {});
void update((api) => api.cancelRun(lens.id));
}
}
/>

View file

@ -3,13 +3,13 @@
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLensDemo } from "../LensDemoContext";
import { useLensApi } from "../api/useLensApi";
import { useLensApi } from "../services";
import { lensQueries } from "../api/queries";
import { evidenceTarget, mergeFeedback, sortedFindings } from "../model/findings";
import type { Job, Lens } from "../model/types";
export function useInvestigationResults(accessToken: string, lens: Lens | undefined) {
const apiClient = useLensApi();
const api = useLensApi(accessToken);
const demo = useLensDemo();
const [batchId, setBatchId] = useState("latest");
const [historyOffset, setHistoryOffset] = useState(0);
@ -18,10 +18,8 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi
const [filter, setFilter] = useState("open");
const [kind, setKind] = useState<"issue" | "pattern">("issue");
const [evidence, setEvidence] = useState<{ id: string; span: string } | null>(null);
const history = useQuery(
lensQueries.history(apiClient, accessToken, { lensId: lens?.id, historyOffset, demo: !!demo }),
);
const historical = useQuery(lensQueries.run(apiClient, accessToken, lens?.id, batchId));
const history = useQuery(lensQueries.history(api, { lensId: lens?.id, historyOffset, demo: !!demo }));
const historical = useQuery(lensQueries.run(api, lens?.id, batchId));
const { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings } = runSnapshot(
lens,
batchId,
@ -42,7 +40,7 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi
const target = evidence ? evidenceTarget(evidence.id) : null;
const [requestOffset, setRequestOffset] = useState(0);
const evidenceInput = { lensId: lens?.id, evidenceId: evidence?.id, requestOffset, source: target?.source };
const requestEvidence = useQuery(lensQueries.evidence(apiClient, accessToken, evidenceInput));
const requestEvidence = useQuery(lensQueries.evidence(api, evidenceInput));
const reset = useCallback(() => {
setBatchId("latest");
setHistoryOffset(0);

View file

@ -0,0 +1,26 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { apiClient } from "@/components/networking";
import { liveTracesApi, type TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import { liveLensApi, type LensApi } from "./api/service";
/** Without a provider, hooks fall back to the live HTTP implementations for the caller's token. */
export interface LensServices {
readonly lens: LensApi;
readonly traces: TracesApi;
}
export const LensServicesContext = createContext<LensServices | null>(null);
export function useLensApi(accessToken: string): LensApi {
const provided = useContext(LensServicesContext)?.lens;
const live = useMemo(() => liveLensApi(apiClient, accessToken), [accessToken]);
return provided ?? live;
}
export function useTracesApi(accessToken: string): TracesApi {
const provided = useContext(LensServicesContext)?.traces;
const live = useMemo(() => liveTracesApi(accessToken), [accessToken]);
return provided ?? live;
}

View file

@ -10,7 +10,7 @@ import { lensQueries } from "../api/queries";
import { useEffect, useId, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLensApi } from "../api/useLensApi";
import { useLensApi } from "../services";
import { Button } from "@/components/ui/button";
import { TraceSheet } from "../investigations/TraceSheet";
@ -34,7 +34,7 @@ export function MatchingActivity({
}) {
const { control, setValue } = useFormContext<InvestigationInput>();
const selection = useWatch({ control, name: "selection" });
const apiClient = useLensApi();
const api = useLensApi(accessToken);
const id = useId();
const [offset, setOffset] = useState(0);
const [scope, setScope] = useState(selection);
@ -57,12 +57,10 @@ export function MatchingActivity({
const validSampling = percent > 0 && percent <= 100 && validCap;
const validFilters = (scope.filters ?? []).every((f) => f.key.trim() && f.value.trim());
const valid = validWindow && validSampling && validFilters;
const discovery = useQuery(
lensQueries.discovery(apiClient, accessToken, { value: selection, asOf, enabled: validWindow }),
);
const agents = useQuery(lensQueries.agents(apiClient, accessToken, asOf, selection.source));
const discovery = useQuery(lensQueries.discovery(api, { value: selection, asOf, enabled: validWindow }));
const agents = useQuery(lensQueries.agents(api, asOf, selection.source));
const previewInput = { scope, offset, asOf, enabled: valid };
const preview = useQuery(lensQueries.preview(apiClient, accessToken, previewInput));
const preview = useQuery(lensQueries.preview(api, previewInput));
const empty = preview.data?.eligible === 0;
useEffect(() => {
if (!empty || !valid) return;

View file

@ -4,7 +4,7 @@ import { Controller, useFormContext } from "react-hook-form";
import { lensQueries } from "../../api/queries";
import { useQuery } from "@tanstack/react-query";
import { useLensApi } from "../../api/useLensApi";
import { useLensApi } from "../../services";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Input } from "@/components/ui/input";
import type { WorkerFormInput } from "./workerSchema";
@ -15,8 +15,8 @@ export function AnalysisAccessFields({ accessToken }: { accessToken: string }) {
register,
formState: { errors },
} = useFormContext<WorkerFormInput>();
const apiClient = useLensApi();
const models = useQuery(lensQueries.models(apiClient, accessToken));
const api = useLensApi(accessToken);
const models = useQuery(lensQueries.models(api));
return (
<div className="space-y-5">
<div className="space-y-2">

View file

@ -3,7 +3,7 @@
import { analysisKeyInfoQuery } from "../../api/queries";
import { useQuery } from "@tanstack/react-query";
import { useLensApi } from "../../api/useLensApi";
import { useLensApi } from "../../services";
import { Button } from "@/components/ui/button";
import { runTime } from "../../model/format";
@ -26,8 +26,8 @@ function budgetLabel(amount: number | null, duration?: string | null): string {
}
export function useAnalysisKeyInfo(accessToken: string, keyId?: string) {
const apiClient = useLensApi();
return useQuery(analysisKeyInfoQuery(apiClient, accessToken, keyId));
const api = useLensApi(accessToken);
return useQuery(analysisKeyInfoQuery(api, keyId));
}
export function AnalysisKeyDetails({

View file

@ -5,7 +5,7 @@ import { analysisKeysQuery, type Key } from "../../api/queries";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLensApi } from "../../api/useLensApi";
import { useLensApi } from "../../services";
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
import type { WorkerFormInput } from "./workerSchema";
import {
@ -20,11 +20,11 @@ import {
export function AnalysisKeyPicker({ accessToken }: { accessToken: string }) {
const { control } = useFormContext<WorkerFormInput>();
const value = useWatch({ control, name: "analysisKey" });
const apiClient = useLensApi();
const api = useLensApi(accessToken);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Key | null>(value ? { token: value } : null);
const keyPages = useInfiniteQuery(analysisKeysQuery(apiClient, accessToken, query));
const keyPages = useInfiniteQuery(analysisKeysQuery(api, query));
const keys = keyPages.data?.pages.flatMap((page) => page.keys) ?? [];
const choice = keys.find((key) => key.token === value) ?? (selected?.token === value ? selected : null);
const loading = keyPages.isFetching;

View file

@ -1,5 +1,5 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesApi } from "@/components/lens/services";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { useState } from "react";
@ -8,7 +8,6 @@ import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { agentTraceSpanCall, agentTraceSpanErrorCall } from "../../networking";
import { type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
import { Card, MessageCard, Section, ToolResultCard } from "./MessageCard";
import type { ErrorSource } from "./traceTree";
@ -30,15 +29,10 @@ const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(he
/** Shared lazy fetch of one span's full input / output / attributes. */
export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null, traceRef?: string) {
const demo = useLensDemo();
const traces = useTracesApi(accessToken);
const queryOptions: UseQueryOptions<SpanDetail, Error> = {
queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken],
queryFn: () =>
demo
? demo.client.get<SpanDetail>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId as string)}`,
)
: agentTraceSpanCall(accessToken, traceId, spanId as string, traceRef),
queryFn: () => traces.span(traceId, spanId as string, traceRef),
enabled: spanId !== null,
staleTime: Infinity,
};
@ -153,17 +147,12 @@ interface DetailContentProps {
}
function DiagnosticContent({ accessToken, traceId, traceRef, span }: DetailContentProps) {
const demo = useLensDemo();
const traces = useTracesApi(accessToken);
const [opened, setOpened] = useState(false);
const [cursor, setCursor] = useState<string | null>(null);
const queryOptions: UseQueryOptions<SpanErrorPage, Error> = {
queryKey: ["agentTraceSpanError", traceId, traceRef, span.span_id, accessToken, cursor],
queryFn: () =>
demo
? demo.client.get<SpanErrorPage>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(span.span_id)}/error`,
)
: agentTraceSpanErrorCall(accessToken, traceId, span.span_id, { traceRef, cursor }),
queryFn: () => traces.spanError(traceId, span.span_id, { traceRef, cursor }),
enabled: opened,
staleTime: Infinity,
gcTime: 0,

View file

@ -5,9 +5,8 @@ import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { CopyButton } from "./CopyButton";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesApi } from "@/components/lens/services";
import { Button } from "@/components/ui/button";
import { agentTraceSpanCall } from "../../networking";
import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation";
import { ErrorBlock } from "./DetailContent";
import { Markdown, ToolCallBlock } from "./MessageCard";
@ -23,7 +22,7 @@ export function TraceConversation({
accessToken: string;
onOpenStep: (id: string) => void;
}) {
const demo = useLensDemo();
const traces = useTracesApi(accessToken);
const [limit, setLimit] = useState(CONVERSATION_PAGE_SIZE);
const steps = conversationSteps(trace.spans);
const visible = steps.slice(0, limit);
@ -31,12 +30,7 @@ export function TraceConversation({
const queries = useQueries({
queries: visible.map((span) => ({
queryKey: ["agentTraceSpan", traceId, traceRef, span.span_id, accessToken],
queryFn: (): Promise<SpanDetail> =>
demo
? demo.client.get<SpanDetail>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(span.span_id)}`,
)
: agentTraceSpanCall(accessToken, traceId, span.span_id, traceRef),
queryFn: (): Promise<SpanDetail> => traces.span(traceId, span.span_id, traceRef),
staleTime: Infinity,
retry: false,
})),

View file

@ -1,5 +1,6 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesApi } from "@/components/lens/services";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy } from "lucide-react";
@ -11,7 +12,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { agentTraceCall, getProxyBaseUrl } from "../../networking";
import { getProxyBaseUrl } from "../../networking";
import { DetailPane } from "./DetailPane";
import { IdChip } from "./IdChip";
import { formatCost } from "./AgentTracesTable";
@ -369,14 +370,11 @@ interface RunViewProps {
/** One agent run: header with totals and "Copy for agent", span tree on the left, span details on the right. */
export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack, embedded = false }: RunViewProps) {
const demo = useLensDemo();
const traces = useTracesApi(accessToken);
const [view, setView] = useState<TraceView>("steps");
const traceQuery = useQuery({
queryKey: ["agentTrace", traceId, traceRef, accessToken],
queryFn: () =>
demo
? demo.client.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`)
: agentTraceCall(accessToken, traceId, traceRef),
queryFn: () => traces.trace(traceId, traceRef),
staleTime: 30_000,
});
const trace = traceQuery.data;

View file

@ -0,0 +1,39 @@
import {
agentTraceCall,
agentTraceListCall,
agentTraceSpanCall,
agentTraceSpanErrorCall,
apiClient,
} from "../../networking";
import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./traceTypes";
export interface TraceWindow {
readonly startMs: number;
readonly endMs: number;
readonly cursor?: string | null;
}
export interface TracesApi {
list(window: TraceWindow): Promise<TracePage>;
anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string): Promise<Trace>;
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;
spanError(
traceId: string,
spanId: string,
options: { readonly traceRef?: string; readonly cursor?: string | null },
): Promise<SpanErrorPage>;
}
export function liveTracesApi(accessToken: string): TracesApi {
return {
list: (window) => agentTraceListCall({ accessToken, ...window }),
anyRecorded: async () => {
const page = await apiClient.get<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } });
return page.data.length > 0;
},
trace: (traceId, traceRef) => agentTraceCall(accessToken, traceId, traceRef),
span: (traceId, spanId, traceRef) => agentTraceSpanCall(accessToken, traceId, spanId, traceRef),
spanError: (traceId, spanId, options) => agentTraceSpanErrorCall(accessToken, traceId, spanId, options),
};
}

View file

@ -1,11 +1,10 @@
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesApi } from "@/components/lens/services";
import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import moment from "moment";
import { useMemo } from "react";
import { ApiError } from "@/lib/http/client";
import { agentTraceListCall, apiClient } from "../../networking";
import { LIVE_TAIL_INTERVAL_MS } from "../log_filter_logic";
import type { TracePage, TraceSummary } from "./traceTypes";
@ -66,20 +65,14 @@ export function useAgentTraces({
isLiveTail,
enabled,
}: UseAgentTracesOptions): AgentTracesResult {
const demo = useLensDemo();
const traces = useTracesApi(accessToken);
const fetchPage = (pageParam: unknown): Promise<TracePage> => {
const nowMs = Date.now();
const listOptions: Parameters<typeof agentTraceListCall>[0] = {
accessToken,
return traces.list({
startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs),
endMs: isCustomDate ? moment(endTime).valueOf() : nowMs,
cursor: pageParam as string | null,
};
return demo
? demo.client.get<TracePage>("/v1/traces", {
query: { start_ms: listOptions.startMs, end_ms: listOptions.endMs, cursor: listOptions.cursor },
})
: agentTraceListCall(listOptions);
});
};
const queryOptions: Parameters<typeof useInfiniteQuery<TracePage, Error>>[0] = {
queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate],
@ -96,11 +89,11 @@ export function useAgentTraces({
};
const query = useInfiniteQuery<TracePage, Error>(queryOptions);
const traces = useMemo(() => query.data?.pages.flatMap((page) => page.data) ?? [], [query.data]);
const loaded = useMemo(() => query.data?.pages.flatMap((page) => page.data) ?? [], [query.data]);
const notEnabled = isTracingNotEnabled(query.error);
return {
traces,
traces: loaded,
isLoading: query.isLoading,
isFetching: query.isFetching,
notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null,
@ -112,16 +105,14 @@ export function useAgentTraces({
}
export function useTraceAvailability(accessToken: string, enabled: boolean) {
const demo = useLensDemo();
const client = demo?.client ?? apiClient;
const options: UseQueryOptions<TracePage, Error, boolean> = {
const traces = useTracesApi(accessToken);
const options: UseQueryOptions<boolean, Error> = {
queryKey: ["trace-availability", accessToken],
queryFn: () => client.get<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } }),
select: (page: TracePage) => page.data.length > 0,
queryFn: () => traces.anyRecorded(),
enabled,
retry: false,
refetchInterval: (query) =>
query.state.data?.data.length || requiresUserAction(query.state.error) ? false : LIVE_TAIL_INTERVAL_MS,
query.state.data || requiresUserAction(query.state.error) ? false : LIVE_TAIL_INTERVAL_MS,
refetchOnWindowFocus: (query) => !requiresUserAction(query.state.error),
refetchOnReconnect: (query) => !requiresUserAction(query.state.error),
refetchIntervalInBackground: false,