feat: improve Lens dev seeding and live UI (#44468)

* feat: seed Lens dev with configurable load profiles

* feat: run Lens UI live through the dev launcher

* fix: verify Lens UI startup before seeding

* fix(ui): render run timestamps on one compact line

The agent runs table printed the long locale form with timezone, which
wrapped to two lines per row. Use a fixed-width 24h form with
milliseconds that matches the timeline axis, keep the long form in the
hover title, and show the timezone once in the column header

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ui): use the root query client for the Lens demo

Drop the demo's nested QueryClient. Cache keys are already partitioned by
scope, and the root client now skips retries on 4xx ApiErrors, which covers
the demo's not-in-demo and read-only rejections and live 4xx alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop unused synthetic_spend reference from query help

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(traces): drop the deeplite fixtures and map every SDK to a logo

The deeplite captures predate the example repository and carried
synthetic spend rows, which leaked a fixture-only column into the
query help SQL and pinned tests to its shape. Replace them with the
SDK captures in the ClickHouse round trip and query API tests, and
derive the seed tenant lookup from the capture metadata

The runs table only knew the two Anthropic framework slugs. Register
the slugs the normalizer emits for LangChain, LangGraph, Deep Agents,
CrewAI, Google ADK, LlamaIndex, OpenAI Agents, Pydantic AI, Strands,
Vercel AI SDK, Codex, Cursor and Copilot, and fall back to the generic
agent glyph when a run has no known framework

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ui): inject the Lens sample through data sources instead of demo checks

Components no longer ask whether they are in the demo. The traces source
carries live and handoff, navigation state comes from a URL or memory
route, and the preview action comes from context instead of onDemo props

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): infinite scroll for the agent runs list

Replace the Load more button with a sentinel that fetches the next
cursor page as the list nears its end. Placeholder rows hold the tail
while more runs exist, and a failed page stops auto-loading until Retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): keep the whole Lens view in the URL

Lens navigation now lives entirely in query params through nuqs: the
sample session (demo=true, with a Demo data switch in the header), the
open run (trace, trace_ref), the selected step, view and detail section
(span, view, span_tab) and the list filters and range (q, agent, status,
hours). Any Lens view is a shareable link and the back button walks runs

RunView takes its selection injected: the drawer feeds it URL state and
the investigations evidence sheet keeps a local one, so a finding's
original run never writes step ids into the URL. Leaving the sample
session clears every Lens key except the tab so sample ids never point
at live data

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* style(traces): cargo fmt captures tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-04 01:03:11 +00:00 • committed by GitHub
parent 8520626e7a
commit 62fb808d4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 2518 additions and 12644 deletions

View file

@ -58,7 +58,7 @@ help:
@echo " make test-unit-helm - Run helm unit tests"
@echo " make test-rust-extension - Build the Rust extension and run its public Python tests"
@echo " make rust-sqlx-prepare - Refresh litellm-rust/crates/db/.sqlx against a migrated Postgres container"
@echo " make lens-dev - Run proxy + Lens worker + hot-reload dashboard (LENS_DEV_PROXY_PORT, LENS_DEV_UI_PORT)"
@echo " make lens-dev - Run proxy + Lens worker + hot-reload dashboard (ARGS=\"--seed large\", LENS_DEV_PROXY_PORT, LENS_DEV_UI_PORT)"
@echo ""
@echo "Heavy targets (check, lint) queue for LITELLM_GATE_SLOTS machine-wide"
@echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine."
@ -313,7 +313,7 @@ rust-sqlx-prepare:
cd litellm-rust && cargo run -p litellm-db-testing --bin sqlx-prepare
lens-dev:
./scripts/lens_dev.sh
./scripts/lens_dev.sh $(ARGS)
test: install-test-deps
$(UV_RUN) pytest tests/

View file

@ -160,6 +160,38 @@ curl "$LITELLM_URL/lens/$LENS_ID/runs/$BATCH_ID" -H "Authorization: Bearer $LITE
Creation queues the first batch. Posting to `/lens/{id}/runs` queues another, or returns the existing active batch. The run response contains its ID under `jobs[0].id`. Poll the batch URL for status, findings and assessments. List responses omit large result payloads; request a batch to retrieve them. Supply an optional complete `settings` object on the runs POST for a one-off override; the saved lens stays unchanged. Selection accepts `team_id`, exact `filters`, and opaque `execution_ids` returned by `/lens/preview/sample`. Preview accepts `offset` and `as_of` to keep the time window fixed while paging. Feedback uses `PATCH /lens/{id}/findings/{finding_id}` with `status` and `reason`
## Local development
`make lens-dev ARGS=--seed` starts the full dev stack. The live dashboard is at `http://localhost:3000/ui/lens/`, with login at `http://localhost:3000/ui/login/`. Next.js forwards API requests to the proxy on port 4000, so login and navigation stay in the live UI and edits hot-reload
The default is Next.js dev with no production build (`LENS_DEV_BUILD_UI=0`). Set `LENS_DEV_BUILD_UI=1` when you also want a fresh static dashboard at `http://localhost:4000/ui/`. Build output goes to `.lens-dev/logs/ui-build.log`; a failed build stops startup. Both modes keep the live dashboard on port 3000. Startup checks the live login route before seeding and fails with the UI log path if Next.js exits. `LENS_DEV_STARTUP_TIMEOUT_SECONDS` controls startup readiness retries (default 300; `LENS_DEV_READINESS_REQUEST_TIMEOUT_SECONDS` caps each HTTP probe, default 5)
For local fixture data, run `make lens-dev ARGS=--seed`. Use `make lens-dev ARGS="--seed large"` for 2,000 fixture copies, over one million spans and linked request logs. To seed a running stack without restarting it, use `make lens-dev ARGS="--seed-only --seed large --copies 100"`. The default profile replays one copy of every checked-in capture through authenticated `/v1/traces`, including failures, retries, streaming and multiple agent frameworks. Large seeds use the same parser and compressed ClickHouse writer in batches of four copies, and write matching request logs to PostgreSQL. The first and last batches verify linked spend totals through the proxy
Seeds append fresh IDs on every invocation and spread copies over recent timestamps. Restarts without `SEED` do not add data. Lens excludes activity received in the last two minutes, so wait two minutes after seeding before checking investigation previews. `LENS_DEV_SEED_COPIES` overrides total copies, and `LENS_DEV_SEED_BATCH_COPIES` overrides copies per bulk insert (default 4, about 2,000 spans). Start with four or fewer on a constrained machine. Larger batches still respect the existing ClickHouse insert size limit; each capture is decoded separately within the OTLP safety budget. Large seeds test data volume and pagination, rather than concurrent ingestion throughput or review accuracy. They can use substantial disk space; adjust `--copies` for your machine. Seeding expects the generated local tracing configuration. The old `run_tracing_proxy_local.sh --seed` command forwards to Lens dev, using its ports and saved master key
Local ingestion limits are explicit and configurable. Set OTLP and ClickHouse variables before starting the proxy and seeder so both processes use the same settings. Invalid, zero and negative values fail instead of silently falling back. Changing these limits does not require rebuilding Rust
| Environment variable | Default | Controls |
| --- | --- | --- |
| `LENS_DEV_SEED_COPIES` | 1 default, 2000 large | Total fixture copies |
| `LENS_DEV_SEED_BATCH_COPIES` | 4 | Copies per bulk insert |
| `LENS_DEV_SEED_TIMEOUT_SECONDS` | 120 | Seeder HTTP timeout |
| `OTLP_MAX_BODY_BYTES` | 16777216 | HTTP body and decompressed payload bytes |
| `OTLP_MAX_CONCURRENT_INGESTS` | 2 | Concurrent proxy ingestion requests |
| `OTLP_MAX_ATTRIBUTE_VALUE_BYTES` | 65536 | Stored attribute/content bytes |
| `OTLP_MAX_DECODE_DEPTH` | 32 | Nested decode depth |
| `OTLP_MAX_DECODE_NODES` | 65536 | JSON values or protobuf fields per export |
| `OTLP_MAX_SPANS` | 4096 | Spans per export |
| `OTLP_MAX_ATTRIBUTES` | 256 | Attributes per resource, scope, span, event or link |
| `OTLP_MAX_EVENTS` | 256 | Events per span |
| `OTLP_MAX_LINKS` | 256 | Links per span |
| `OTLP_MAX_DECODED_SPAN_BYTES` | 16777216 | Decoded span allocation budget |
| `CLICKHOUSE_TRACE_MAX_INSERT_BYTES` | 67108864 | Encoded trace or spend insert bytes |
| `CLICKHOUSE_INSERT_TIMEOUT_SECONDS` | 30 | ClickHouse insert HTTP timeout |
The wire parsers also enforce their library recursion limits (128 levels for JSON, 100 for protobuf). Raising the configured depth does not remove those parser limits. Bulk seeding parses each capture separately, keeping the per-export limits distinct from the bulk insert limit. Use smaller batches if an insert exceeds its byte budget. For example, `LENS_DEV_SEED_COPIES=100 LENS_DEV_SEED_BATCH_COPIES=2 make lens-dev ARGS="--seed large"`
## Quality evaluation
Run the checked-in cases against a configured real model. Expected labels are used only for scoring, never passed to the model. Dev and held-out cases include missing outcomes, failed tools, recovery, handoffs, unsupported claims, repeated work, long evidence and prompt injection. The background option adds clean arithmetic traces to test rare-issue discovery at scale; those repeated synthetic cases do not establish accuracy on every production workload

View file

@ -39,6 +39,7 @@ fn map_error_ref(error: &Error) -> PyErr {
| Error::InsertTooLarge
| Error::ReadTooLarge => PyOverflowError::new_err(error.to_string()),
Error::InvalidRow
| Error::InvalidLimit(_)
| Error::InvalidTable
| Error::InvalidCursor(_)
| Error::AmbiguousTrace
@ -59,6 +60,7 @@ fn map_error_ref(error: &Error) -> PyErr {
Error::Cached(source) => map_error_ref(source),
Error::Storage(source) => match source {
StorageError::InvalidRow
| StorageError::InvalidLimit(_)
| StorageError::InvalidTable
| StorageError::InvalidSchema
| StorageError::EmptySql
@ -433,6 +435,13 @@ mod tests {
#[rstest]
#[case::row(Error::InvalidRow, "ValueError")]
#[case::insert_limit(Error::InvalidLimit("CLICKHOUSE_TRACE_MAX_INSERT_BYTES"), "ValueError")]
#[case::insert_timeout(
Error::Storage(litellm_storage_clickhouse::Error::InvalidLimit(
"CLICKHOUSE_INSERT_TIMEOUT_SECONDS"
)),
"ValueError"
)]
#[case::insert_budget(Error::InsertTooLarge, "OverflowError")]
#[case::scope(Error::InvalidScope, "ValueError")]
#[case::schema(Error::SchemaFailed(503), "RuntimeError")]
@ -482,6 +491,10 @@ mod tests {
#[rstest]
#[case::decode_budget(Error::Decode(litellm_traces::Error::TooLarge), "OverflowError")]
#[case::invalid_export(Error::Decode(litellm_traces::Error::InvalidPayload), "ValueError")]
#[case::invalid_decode_limit(
Error::Decode(litellm_traces::Error::InvalidLimit("OTLP_MAX_SPANS")),
"ValueError"
)]
#[case::cursor(Error::InvalidCursor("trace"), "ValueError")]
#[case::ambiguous(Error::AmbiguousTrace, "ValueError")]
#[case::changed_snapshot(Error::TraceChanged, "ValueError")]

View file

@ -2,6 +2,8 @@
pub enum Error {
#[error("invalid ClickHouse insert row")]
InvalidRow,
#[error("{0} must be a positive integer")]
InvalidLimit(&'static str),
#[error("invalid ClickHouse insert table")]
InvalidTable,
#[error("invalid ClickHouse HTTP URL")]

View file

@ -5,7 +5,19 @@ use litellm_http::Client;
use crate::{Connection, Error, valid_identifier};
const INSERT_TIMEOUT: Duration = Duration::from_secs(30);
fn insert_timeout() -> Result<Duration, Error> {
let name = "CLICKHOUSE_INSERT_TIMEOUT_SECONDS";
match std::env::var(name) {
Ok(value) => value
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.map(Duration::from_secs)
.ok_or(Error::InvalidLimit(name)),
Err(std::env::VarError::NotPresent) => Ok(Duration::from_secs(30)),
Err(_) => Err(Error::InvalidLimit(name)),
}
}
pub async fn insert_encoded_rows(
client: &Client,
@ -74,7 +86,7 @@ pub async fn insert_compressed_rows(
.append_pair("date_time_input_format", "best_effort");
let response = client
.post(url)
.timeout(INSERT_TIMEOUT)
.timeout(insert_timeout()?)
.header("Content-Encoding", "gzip")
.body(body)
.send()

View file

@ -144,3 +144,50 @@ async fn server_result_limits_allow_smaller_pages_without_retrying_other_failure
assert!(matches!(error, Error::QueryFailed(500)));
}
}
#[test]
fn insert_timeout_environment_controls_transport() {
for value in ["1", "3", "0", "invalid"] {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "insert_timeout_environment_child"])
.env("LITELLM_TEST_INSERT_TIMEOUT", value)
.env("CLICKHOUSE_INSERT_TIMEOUT_SECONDS", value)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
}
}
#[tokio::test]
async fn insert_timeout_environment_child() {
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
let Ok(value) = std::env::var("LITELLM_TEST_INSERT_TIMEOUT") else {
return;
};
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(1500)))
.mount(&server)
.await;
let result = insert_encoded_rows(
&Client::no_redirect_for_test(),
&Connection::parse(&server.uri()).unwrap(),
"traces",
"otel_traces",
"token",
"{}",
)
.await;
match value.as_str() {
"1" => assert!(matches!(result, Err(Error::Transport))),
"3" => assert!(result.is_ok()),
_ => assert!(matches!(
result,
Err(Error::InvalidLimit("CLICKHOUSE_INSERT_TIMEOUT_SECONDS"))
)),
}
}

View file

@ -1,7 +1,6 @@
SELECT
request_id, response_id, trace_id, span_id, model, spend,
prompt_tokens, completion_tokens, status,
JSONExtractBool(metadata, 'synthetic_spend') AS synthetic_spend
prompt_tokens, completion_tokens, status
FROM spend_logs FINAL
WHERE start_time >= now() - INTERVAL 1 DAY
ORDER BY start_time DESC, request_id

View file

@ -2,6 +2,8 @@
pub enum Error {
#[error("invalid ClickHouse insert row")]
InvalidRow,
#[error("{0} must be a positive integer")]
InvalidLimit(&'static str),
#[error("invalid ClickHouse insert table")]
InvalidTable,
#[error("database must be a nonempty SQL identifier and retention must be positive")]

View file

@ -15,7 +15,18 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{Connection, Error};
use litellm_traces::Shared;
const MAX_INSERT_BYTES: usize = 64 * 1024 * 1024;
fn max_insert_bytes() -> Result<usize, Error> {
let name = "CLICKHOUSE_TRACE_MAX_INSERT_BYTES";
match std::env::var(name) {
Ok(value) => value
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or(Error::InvalidLimit(name)),
Err(std::env::VarError::NotPresent) => Ok(64 * 1024 * 1024),
Err(_) => Err(Error::InvalidLimit(name)),
}
}
pub type InsertRow = BTreeMap<String, Shared<Value>>;
@ -62,7 +73,7 @@ pub async fn insert_shared_rows(
return Ok(());
}
let received_ms = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000) as u64;
let (token, body) = prepare_insert(&rows, received_ms, MAX_INSERT_BYTES)?;
let (token, body) = prepare_insert(&rows, received_ms, max_insert_bytes()?)?;
litellm_storage_clickhouse::insert_compressed_rows(
client,
connection,

View file

@ -184,7 +184,7 @@ Duration is nanoseconds; Timestamp has nanosecond precision, spend start_time ha
{%- endblock %}
{% block missing_spend -%}
Token usage does not establish billed spend. OTLP exports without companion spend_logs rows have unknown cost; synthetic fixture spend is marked by metadata.synthetic_spend
Token usage does not establish billed spend. OTLP exports without companion spend_logs rows have unknown cost
{%- endblock %}
{% block partial_spend -%}

View file

@ -4,13 +4,11 @@ Run `cargo test -p litellm-traces-clickhouse --test queries --locked -- --test-t
Raw OTLP exports live in `crates/traces/tests/fixtures/query_*.json`. The seeded fixture decodes and normalizes them through `litellm_traces::decode_otlp` at test startup, then projects the decoded fields into ClickHouse columns. Team and key identities come from fixture setup rather than exporter claims. Root and child exports are inserted separately through the public insert API so materialized views process multiple blocks
`crates/traces/tests/fixtures/deeplite_auth_error.json` and `deeplite_swarm.json` were captured from Deeplite runs against the local proxy on 2026-10-02. The first contains a failed model call. The second contains successful model calls, searches, handoff attempts, and virtual filesystem writes. Credentials, workspace identifiers, and local user paths were redacted, and the protobuf exports were converted to OTLP JSON. Their round-trip tests check span identities, parent links, timestamps, durations, token counts, and statuses without pinning the provider's error wording
The ClickHouse round-trip test replays the `google_adk_billed_failure`, `pydantic_ai_retry`, and `deepagents_swarm` exports and checks span identities, parent links, timestamps, durations, token counts, and statuses without pinning the provider's error wording. Exported ERROR and UNSET statuses are diagnostic and do not establish a failed execution, so the test preserves incoming statuses and checks root status separately from the count of error spans, deriving both from the decoded export. Framework-specific interpretation of control-flow exceptions belongs in the instrumentation integration
The swarm capture has handoff spans marked ERROR with `ParentCommand` exception events and a root with UNSET status. These are exported diagnostic statuses, which do not establish a failed execution. The tests preserve incoming statuses and check root status separately from the count of error spans, deriving both from the decoded export. They do not infer an execution outcome from exception text, framework names, successful model calls, or output presence. Framework-specific interpretation of control-flow exceptions belongs in the instrumentation integration
For a local dashboard with linked requests and traces, run `make lens-dev ARGS=--seed` from the repository root and open `http://localhost:3000/ui/lens/`. Log in as `admin` with the master key saved in `.lens-dev/master_key`. The launcher keeps the stack running until Ctrl-C and leaves the database volumes intact
For a local dashboard with linked requests and traces, run `bash scripts/run_tracing_proxy_local.sh --seed` from the repository root and open `http://127.0.0.1:4002/ui/`. Log in as `admin` with password `sk-1234`, matching the UI E2E harness. The launcher keeps the proxy running until Ctrl-C and leaves the database volumes intact
`deeplite_swarm_spend_logs.jsonl` pairs every LLM span in the swarm export with a ClickHouse spend row. Response IDs, trace and span IDs, token counts, input, output, and timestamps come from the export. Messages and responses use the chat completion format supported by the request viewer. Spend is synthetic, set to $0.01 per request and marked in metadata, because the export does not include actual billed costs. These rows are stored here because `traces-clickhouse` owns the spend row schema
Spend rows are stored here because `traces-clickhouse` owns the spend row schema
The simple and swarm exports for all twelve SDK examples were captured on 2026-10-03 against port 4002 using `openai/gpt-6-luna`. Each export has a matching `<name>_spend_logs.jsonl` with actual proxy spend, usage, request and response IDs, messages, and timestamps. Authorization headers, provider cookies, organization and project identifiers, and local paths were redacted. OTLP identifiers and enums use their canonical JSON encodings. `metadata.fixture_capture` identifies the associated export and whether model spans contain sufficient identity to join spend

File diff suppressed because one or more lines are too long

View file

@ -138,3 +138,56 @@ fn insert_encoding_preserves_timestamp_precision_and_other_fields(
fn insert_encoding_rejects_invalid_span_timestamps(#[case] timestamp: Value) {
assert!(encode_rows(vec![BTreeMap::from([("Timestamp".into(), timestamp)])]).is_err());
}
#[test]
fn insert_byte_limit_environment_controls_transport() {
for value in ["1", "1024", "0", "invalid"] {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "insert_byte_limit_environment_child"])
.env("LITELLM_TEST_INSERT_LIMIT", value)
.env("CLICKHOUSE_TRACE_MAX_INSERT_BYTES", value)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
}
}
#[tokio::test]
async fn insert_byte_limit_environment_child() {
let Ok(value) = std::env::var("LITELLM_TEST_INSERT_LIMIT") else {
return;
};
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let connection = Connection::parse(&server.uri()).unwrap();
let result = insert_shared_rows(
&Client::no_redirect_for_test(),
&connection,
"traces",
InsertTable::OtelTraces,
vec![BTreeMap::from([(
"SpanId".into(),
Shared::new(json!("test")),
)])],
)
.await;
match value.as_str() {
"1" => assert!(matches!(result, Err(Error::InsertTooLarge))),
"1024" => assert!(result.is_ok()),
_ => assert!(matches!(
result,
Err(Error::InvalidLimit("CLICKHOUSE_TRACE_MAX_INSERT_BYTES"))
)),
}
assert_eq!(
server.received_requests().await.unwrap().len(),
usize::from(value == "1024")
);
}

View file

@ -244,10 +244,11 @@ async fn typed_trace_cursor_returns_the_next_fixture_trace(
}
#[rstest]
#[case::authentication_error(include_bytes!("../../traces/tests/fixtures/deeplite_auth_error.json"))]
#[case::swarm(include_bytes!("../../traces/tests/fixtures/deeplite_swarm.json"))]
#[case::billed_failure(include_bytes!("../../traces/tests/fixtures/google_adk_billed_failure.json"))]
#[case::retry(include_bytes!("../../traces/tests/fixtures/pydantic_ai_retry.json"))]
#[case::swarm(include_bytes!("../../traces/tests/fixtures/deepagents_swarm.json"))]
#[tokio::test]
async fn captured_deeplite_exports_round_trip_through_clickhouse(
async fn captured_sdk_exports_round_trip_through_clickhouse(
#[future(awt)] migrated_database: TestResult<SeededDatabase>,
admin_access: TestResult<contracts::ReadAccessParams>,
#[case] export: &[u8],

View file

@ -2,6 +2,8 @@
pub enum Error {
#[error("invalid OTLP trace payload")]
InvalidPayload,
#[error("{0} must be a positive integer")]
InvalidLimit(&'static str),
#[error("OTLP trace payload exceeds the decoding budget")]
TooLarge,
#[error("OTLP token count is outside the storage range")]

View file

@ -30,7 +30,7 @@ pub use normalize::{
AgentMetadata, AgentType, CallEvidence, CallEvidenceKind, CallKey, Integration, NormalizedSpan,
ObservationType,
};
pub use otlp::{DecodedEvent, DecodedSpan, decode_otlp};
pub use otlp::{DecodeLimits, DecodedEvent, DecodedSpan, decode_otlp, decode_otlp_with_limits};
pub use query::ReadQuery;
pub use query_access::QueryScope;
pub use resolve::{SpendLookup, iso_time, listed_summary, resolve_trace};

View file

@ -8,7 +8,7 @@ use serde::{
ser::{SerializeMap, SerializeSeq},
};
use super::limits::{Budget, MAX_ATTRIBUTES};
use super::limits::Budget;
use crate::Error;
struct AttributeWriter<'a> {
@ -33,7 +33,7 @@ pub(super) fn attributes(
values: Vec<KeyValue>,
budget: &mut Budget,
) -> Result<BTreeMap<String, String>, Error> {
if values.len() > MAX_ATTRIBUTES {
if values.len() > budget.limits.attributes {
return Err(Error::TooLarge);
}
values

View file

@ -5,14 +5,62 @@ use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor};
use crate::{Error, Shared};
pub(super) const MAX_DEPTH: usize = 32;
pub(super) const MAX_NODES: usize = 65_536;
pub(super) const MAX_SPANS: usize = 4_096;
pub(super) const MAX_ATTRIBUTES: usize = 256;
pub(super) const MAX_EVENTS: usize = 256;
pub(super) const MAX_DECODED_SPAN_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Copy, Debug)]
pub struct DecodeLimits {
pub depth: usize,
pub nodes: usize,
pub spans: usize,
pub attributes: usize,
pub events: usize,
pub links: usize,
pub decoded_span_bytes: usize,
}
pub(super) fn json_preflight(payload: &[u8]) -> Result<(), Error> {
impl Default for DecodeLimits {
fn default() -> Self {
Self {
depth: 32,
nodes: 65_536,
spans: 4_096,
attributes: 256,
events: 256,
links: 256,
decoded_span_bytes: 16 * 1024 * 1024,
}
}
}
impl DecodeLimits {
pub fn from_env() -> Result<Self, Error> {
let defaults = Self::default();
Ok(Self {
depth: env_limit("OTLP_MAX_DECODE_DEPTH", defaults.depth)?,
nodes: env_limit("OTLP_MAX_DECODE_NODES", defaults.nodes)?,
spans: env_limit("OTLP_MAX_SPANS", defaults.spans)?,
attributes: env_limit("OTLP_MAX_ATTRIBUTES", defaults.attributes)?,
events: env_limit("OTLP_MAX_EVENTS", defaults.events)?,
links: env_limit("OTLP_MAX_LINKS", defaults.links)?,
decoded_span_bytes: env_limit(
"OTLP_MAX_DECODED_SPAN_BYTES",
defaults.decoded_span_bytes,
)?,
})
}
}
fn env_limit(name: &'static str, default: usize) -> Result<usize, Error> {
match std::env::var(name) {
Ok(value) => value
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or(Error::InvalidLimit(name)),
Err(std::env::VarError::NotPresent) => Ok(default),
Err(_) => Err(Error::InvalidLimit(name)),
}
}
pub(super) fn json_preflight(payload: &[u8], limits: &DecodeLimits) -> Result<(), Error> {
let mut nodes = 0;
let mut exceeded = false;
let mut decoder = serde_json::Deserializer::from_slice(payload);
@ -20,6 +68,7 @@ pub(super) fn json_preflight(payload: &[u8]) -> Result<(), Error> {
nodes: &mut nodes,
exceeded: &mut exceeded,
depth: 0,
limits,
}
.deserialize(&mut decoder)
.and_then(|()| decoder.end());
@ -33,6 +82,7 @@ struct JsonBudget<'a> {
nodes: &'a mut usize,
exceeded: &'a mut bool,
depth: usize,
limits: &'a DecodeLimits,
}
impl<'de> DeserializeSeed<'de> for JsonBudget<'_> {
@ -40,7 +90,7 @@ impl<'de> DeserializeSeed<'de> for JsonBudget<'_> {
fn deserialize<D: serde::Deserializer<'de>>(self, decoder: D) -> Result<(), D::Error> {
*self.nodes += 1;
if *self.nodes > MAX_NODES || self.depth > MAX_DEPTH {
if *self.nodes > self.limits.nodes || self.depth > self.limits.depth {
*self.exceeded = true;
return Err(serde::de::Error::custom("OTLP structure exceeds budget"));
}
@ -79,6 +129,7 @@ impl<'de> Visitor<'de> for JsonBudget<'_> {
nodes: self.nodes,
exceeded: self.exceeded,
depth: self.depth + 1,
limits: self.limits,
})?
.is_some()
{}
@ -91,6 +142,7 @@ impl<'de> Visitor<'de> for JsonBudget<'_> {
nodes: self.nodes,
exceeded: self.exceeded,
depth: self.depth + 1,
limits: self.limits,
})?
.is_some()
{
@ -98,6 +150,7 @@ impl<'de> Visitor<'de> for JsonBudget<'_> {
nodes: self.nodes,
exceeded: self.exceeded,
depth: self.depth + 1,
limits: self.limits,
})?;
}
Ok(())
@ -146,8 +199,8 @@ impl MessageKind {
}
}
pub(super) fn protobuf_preflight(payload: &[u8]) -> Result<(), Error> {
scan_message(payload, MessageKind::Export, 0, &mut 0)
pub(super) fn protobuf_preflight(payload: &[u8], limits: &DecodeLimits) -> Result<(), Error> {
scan_message(payload, MessageKind::Export, 0, &mut 0, limits)
}
fn scan_message(
@ -155,13 +208,14 @@ fn scan_message(
kind: MessageKind,
depth: usize,
nodes: &mut usize,
limits: &DecodeLimits,
) -> Result<(), Error> {
if depth > MAX_DEPTH {
if depth > limits.depth {
return Err(Error::TooLarge);
}
while !payload.is_empty() {
*nodes += 1;
if *nodes > MAX_NODES {
if *nodes > limits.nodes {
return Err(Error::TooLarge);
}
let (tag, wire) = decode_key(&mut payload).map_err(|_| Error::InvalidPayload)?;
@ -171,7 +225,7 @@ fn scan_message(
let (message, rest) = payload
.split_at_checked(length)
.ok_or(Error::InvalidPayload)?;
scan_message(message, child, depth + 1, nodes)?;
scan_message(message, child, depth + 1, nodes, limits)?;
payload = rest;
} else {
skip_field(wire, tag, &mut payload, DecodeContext::default())
@ -183,11 +237,15 @@ fn scan_message(
pub(super) struct Budget {
remaining: usize,
pub(super) limits: DecodeLimits,
}
impl Budget {
pub(super) fn new(remaining: usize) -> Self {
Self { remaining }
pub(super) fn new(limits: DecodeLimits) -> Self {
Self {
remaining: limits.decoded_span_bytes,
limits,
}
}
pub(super) fn clone_shared<T: Clone>(

View file

@ -3,6 +3,8 @@ mod limits;
mod span;
mod wire;
pub use limits::DecodeLimits;
use serde::Serialize;
use std::collections::BTreeMap;
@ -36,6 +38,14 @@ pub struct DecodedSpan {
}
pub fn decode_otlp(body: &[u8], content_type: Option<&str>) -> Result<Vec<DecodedSpan>, Error> {
let request = wire::decode(body, content_type)?;
span::flatten(request)
decode_otlp_with_limits(body, content_type, DecodeLimits::from_env()?)
}
pub fn decode_otlp_with_limits(
body: &[u8],
content_type: Option<&str>,
limits: DecodeLimits,
) -> Result<Vec<DecodedSpan>, Error> {
let request = wire::decode(body, content_type, &limits)?;
span::flatten(request, limits)
}

View file

@ -8,15 +8,18 @@ use opentelemetry_proto::tonic::{
use super::{
DecodedEvent, DecodedSpan,
attributes::attributes,
limits::{Budget, MAX_ATTRIBUTES, MAX_DECODED_SPAN_BYTES, MAX_EVENTS, MAX_SPANS},
limits::{Budget, DecodeLimits},
};
use crate::{
Error, Shared,
normalize::{SpanContext, normalize},
};
pub(super) fn flatten(request: ExportTraceServiceRequest) -> Result<Vec<DecodedSpan>, Error> {
let mut budget = Budget::new(MAX_DECODED_SPAN_BYTES);
pub(super) fn flatten(
request: ExportTraceServiceRequest,
limits: DecodeLimits,
) -> Result<Vec<DecodedSpan>, Error> {
let mut budget = Budget::new(limits);
let mut spans = Vec::new();
for resource in request.resource_spans {
append_resource(resource, &mut budget, &mut spans)?;
@ -49,17 +52,17 @@ fn append_scope(
spans: &mut Vec<DecodedSpan>,
) -> Result<(), Error> {
let scope = scope_spans.scope.unwrap_or_default();
if scope.attributes.len() > MAX_ATTRIBUTES {
if scope.attributes.len() > budget.limits.attributes {
return Err(Error::TooLarge);
}
budget.consume(scope.name.len() + scope.version.len())?;
let scope_name: Shared<String> = scope.name.into();
let scope_version: Shared<String> = scope.version.into();
for span in scope_spans.spans {
if spans.len() >= MAX_SPANS {
if spans.len() >= budget.limits.spans {
return Err(Error::TooLarge);
}
validate_span(&span)?;
validate_span(&span, &budget.limits)?;
budget.consume(
span.name.len()
+ span.trace_state.len()
@ -85,7 +88,7 @@ fn valid_id(value: &[u8], length: usize) -> bool {
value.len() == length && value.iter().any(|byte| *byte != 0)
}
fn validate_span(span: &Span) -> Result<(), Error> {
fn validate_span(span: &Span, limits: &DecodeLimits) -> Result<(), Error> {
if !valid_id(&span.trace_id, 16)
|| !valid_id(&span.span_id, 8)
|| (!span.parent_span_id.is_empty() && !valid_id(&span.parent_span_id, 8))
@ -99,17 +102,17 @@ fn validate_span(span: &Span) -> Result<(), Error> {
{
return Err(Error::InvalidPayload);
}
if span.events.len() > MAX_EVENTS
|| span.links.len() > MAX_EVENTS
|| span.attributes.len() > MAX_ATTRIBUTES
if span.events.len() > limits.events
|| span.links.len() > limits.links
|| span.attributes.len() > limits.attributes
|| span
.links
.iter()
.any(|link| link.attributes.len() > MAX_ATTRIBUTES)
.any(|link| link.attributes.len() > limits.attributes)
|| span
.events
.iter()
.any(|event| event.attributes.len() > MAX_ATTRIBUTES)
.any(|event| event.attributes.len() > limits.attributes)
{
return Err(Error::TooLarge);
}

View file

@ -1,7 +1,7 @@
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
use prost::Message;
use super::limits::{json_preflight, protobuf_preflight};
use super::limits::{DecodeLimits, json_preflight, protobuf_preflight};
use crate::Error;
#[derive(strum::EnumString)]
@ -19,6 +19,7 @@ enum OtlpMediaType {
pub(super) fn decode(
body: &[u8],
content_type: Option<&str>,
limits: &DecodeLimits,
) -> Result<ExportTraceServiceRequest, Error> {
let media_type = content_type
.unwrap_or("application/x-protobuf")
@ -31,11 +32,11 @@ pub(super) fn decode(
let request = match media_type {
OtlpMediaType::Json => {
json_preflight(body)?;
json_preflight(body, limits)?;
serde_json::from_slice(body).map_err(|_| Error::InvalidPayload)?
}
OtlpMediaType::Protobuf => {
protobuf_preflight(body)?;
protobuf_preflight(body, limits)?;
ExportTraceServiceRequest::decode(body).map_err(|_| Error::InvalidPayload)?
}
};

View file

@ -330,9 +330,7 @@ fn append_response_id(document: &mut Value, trace_id: &str, span_id: &str, respo
#[rstest]
fn captured_trace_cost_matches_spend_logs(
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")]
#[exclude("deeplite_swarm")]
spend_logs: PathBuf,
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")] spend_logs: PathBuf,
) {
let name = capture_name(&spend_logs);
let (_, capture, rows, spends) = fixture(&spend_logs);
@ -347,9 +345,7 @@ fn captured_trace_cost_matches_spend_logs(
#[rstest]
fn unrelated_sibling_transport_leaves_cost_unchanged(
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")]
#[exclude("deeplite_swarm")]
spend_logs: PathBuf,
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")] spend_logs: PathBuf,
) {
let name = capture_name(&spend_logs);
let (_, capture, rows, spends) = fixture(&spend_logs);
@ -392,9 +388,7 @@ fn unrelated_sibling_transport_leaves_cost_unchanged(
#[rstest]
fn redundant_genai_response_id_keeps_call_evidence(
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")]
#[exclude("deeplite_swarm")]
spend_logs: PathBuf,
#[files("../traces-clickhouse/tests/fixtures/*_spend_logs.jsonl")] spend_logs: PathBuf,
) {
let (data, capture, _, _) = fixture(&spend_logs);
let name = capture.name;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -120,8 +120,6 @@ fn array<'a>(value: &'a Value, key: &str) -> &'a [Value] {
#[case::crewai_swarm(include_bytes!("fixtures/crewai_swarm.json"))]
#[case::deepagents_simple(include_bytes!("fixtures/deepagents_simple.json"))]
#[case::deepagents_swarm(include_bytes!("fixtures/deepagents_swarm.json"))]
#[case::deeplite_auth_error(include_bytes!("fixtures/deeplite_auth_error.json"))]
#[case::deeplite_swarm(include_bytes!("fixtures/deeplite_swarm.json"))]
#[case::google_adk_simple(include_bytes!("fixtures/google_adk_simple.json"))]
#[case::google_adk_swarm(include_bytes!("fixtures/google_adk_swarm.json"))]
#[case::langchain_simple(include_bytes!("fixtures/langchain_simple.json"))]

View file

@ -1333,3 +1333,98 @@ fn resource_identity_preserves_explicit_names_and_sdk_fallbacks(
expected
);
}
#[rstest]
#[case::depth(litellm_traces::DecodeLimits { depth: 1, ..Default::default() })]
#[case::nodes(litellm_traces::DecodeLimits { nodes: 1, ..Default::default() })]
#[case::spans(litellm_traces::DecodeLimits { spans: 1, ..Default::default() })]
#[case::attributes(litellm_traces::DecodeLimits { attributes: 1, ..Default::default() })]
#[case::events(litellm_traces::DecodeLimits { events: 1, ..Default::default() })]
#[case::links(litellm_traces::DecodeLimits { links: 1, ..Default::default() })]
#[case::decoded_bytes(litellm_traces::DecodeLimits { decoded_span_bytes: 1, ..Default::default() })]
fn configurable_decode_limits_apply_to_both_wire_formats(
mut span: Span,
#[case] limits: litellm_traces::DecodeLimits,
) {
use opentelemetry_proto::tonic::{
common::v1::KeyValue,
trace::v1::span::{Event, Link},
};
use prost::Message;
span.attributes = vec![
KeyValue {
key: "a".into(),
..Default::default()
},
KeyValue {
key: "b".into(),
..Default::default()
},
];
span.events = vec![Event::default(), Event::default()];
span.links = vec![
Link {
trace_id: vec![1; 16],
span_id: vec![2; 8],
..Default::default()
};
2
];
let mut request = request_with(span.clone());
request.resource_spans[0].scope_spans[0].spans.push(span);
for (body, content_type) in [
(serde_json::to_vec(&request).unwrap(), "application/json"),
(request.encode_to_vec(), "application/x-protobuf"),
] {
assert!(matches!(
litellm_traces::decode_otlp_with_limits(&body, Some(content_type), limits),
Err(litellm_traces::Error::TooLarge)
));
assert_eq!(
litellm_traces::decode_otlp_with_limits(
&body,
Some(content_type),
litellm_traces::DecodeLimits::default()
)
.unwrap()
.len(),
2
);
}
}
#[test]
fn environment_decode_limits_are_used_and_invalid_values_fail() {
for value in ["2", "4", "0", "invalid"] {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "environment_decode_limits_child"])
.env("LITELLM_TEST_DECODE_LIMIT", value)
.env("OTLP_MAX_SPANS", value)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stdout)
);
}
}
#[test]
fn environment_decode_limits_child() {
let Ok(value) = std::env::var("LITELLM_TEST_DECODE_LIMIT") else {
return;
};
let result = decode_otlp(
include_bytes!("fixtures/opentelemetry_simple.json"),
Some("application/json"),
);
match value.as_str() {
"2" => assert!(matches!(result, Err(litellm_traces::Error::TooLarge))),
"4" => assert_eq!(result.unwrap().len(), 3),
_ => assert!(matches!(
result,
Err(litellm_traces::Error::InvalidLimit("OTLP_MAX_SPANS"))
)),
}
}

View file

@ -25,6 +25,8 @@ py="${LENS_DEV_PYTHON:-$repo_root/.venv/bin/python}"
database_url="${LENS_DEV_DATABASE_URL:-postgresql://litellm:litellm@127.0.0.1:15432/litellm}"
clickhouse_url=http://default:local-tracing@127.0.0.1:18123
master_key=""
startup_timeout="${LENS_DEV_STARTUP_TIMEOUT_SECONDS:-300}"
readiness_request_timeout="${LENS_DEV_READINESS_REQUEST_TIMEOUT_SECONDS:-5}"
pids=()
die() { echo "lens-dev: $*" >&2; exit 1; }
@ -121,6 +123,7 @@ proxy_env() {
export CLICKHOUSE_DATABASE=litellm
export LITELLM_LOCAL_MODEL_COST_MAP=True
export PROXY_BASE_URL="$proxy_url"
export LITELLM_UI_PATH="$repo_root/ui/litellm-dashboard/out"
export UI_USERNAME=admin
export UI_PASSWORD="$master_key"
}
@ -161,12 +164,26 @@ ensure_worker_token() {
wait_for_proxy() {
local proxy_pid="$1"
echo "lens-dev: waiting for the proxy (log: $log_dir/proxy.log)"
for _ in $(seq 1 300); do
for _ in $(seq 1 "$startup_timeout"); do
kill -0 "$proxy_pid" 2>/dev/null || die "proxy exited; see $log_dir/proxy.log"
curl -fsS "$proxy_url/health/readiness" -H "Authorization: Bearer $master_key" >/dev/null 2>&1 && return
curl -fsS --max-time "$readiness_request_timeout" "$proxy_url/health/readiness" -H "Authorization: Bearer $master_key" >/dev/null 2>&1 && return
sleep 1
done
die "proxy not ready after 300s; see $log_dir/proxy.log"
die "proxy not ready after ${startup_timeout}s; see $log_dir/proxy.log"
}
wait_for_ui() {
local ui_pid="$1"
echo "lens-dev: waiting for the UI (log: $log_dir/ui.log)"
for _ in $(seq 1 "$startup_timeout"); do
kill -0 "$ui_pid" 2>/dev/null || die "UI exited; see $log_dir/ui.log"
if curl -fsS --max-time "$readiness_request_timeout" "http://localhost:$ui_port/ui/login/" >/dev/null 2>&1; then
kill -0 "$ui_pid" 2>/dev/null || die "UI exited; see $log_dir/ui.log"
return
fi
sleep 1
done
die "UI not ready after ${startup_timeout}s; see $log_dir/ui.log"
}
# Children run in their own process groups (set -m), so killing -pid takes their trees too.
@ -185,14 +202,71 @@ cleanup() {
for pid in "${pids[@]}"; do kill -KILL -- "-$pid" 2>/dev/null || true; done
}
build_dashboard() {
local dashboard_dir="$repo_root/ui/litellm-dashboard"
case "${LENS_DEV_BUILD_UI:-0}" in
0) return ;;
1) ;;
*) die "LENS_DEV_BUILD_UI must be 0 or 1" ;;
esac
echo "lens-dev: building the proxy dashboard (log: $log_dir/ui-build.log)"
(
cd "$dashboard_dir"
NEXT_PUBLIC_BASE_URL="" LENS_DEV_PROXY_URL="" "$repo_root/scripts/with_dashboard_node.sh" npm run build
) > "$log_dir/ui-build.log" 2>&1 || die "UI build failed; see $log_dir/ui-build.log"
}
seed_data() {
(
proxy_env ""
"$py" -m scripts.seed_tracing_fixtures --profile "$seed_profile" ${seed_options[@]+"${seed_options[@]}"}
)
}
parse_args() {
seed_profile="${LENS_DEV_SEED:-}"
seed_only=0
seed_options=()
while [ "$#" -gt 0 ]; do
case "$1" in
--seed)
seed_profile=default
if [ "${2:-}" = default ] || [ "${2:-}" = large ]; then seed_profile="$2"; shift; fi
;;
--copies)
[ "$#" -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]*$ ]] || die "--copies requires a positive integer"
seed_options=(--copies "$2"); shift ;;
--seed-only) seed_only=1 ;;
--help)
echo "Usage: $0 [--seed [default|large]] [--copies N] [--seed-only]"
exit 0 ;;
*) die "unknown argument: $1 (use --help)" ;;
esac
shift
done
if [ "$seed_only" = 1 ] && [ -z "$seed_profile" ]; then seed_profile=default; fi
case "$seed_profile" in ""|default|large) ;; *) die "seed profile must be default or large" ;; esac
[ "${#seed_options[@]}" = 0 ] || [ -n "$seed_profile" ] || die "--copies requires --seed"
}
main() {
local config_file exports proxy_pid pid key_hint
local config_file exports proxy_pid ui_pid pid key_hint
parse_args "$@"
if [ -n "${LENS_DEV_CONFIG:-}" ]; then
[ -f "$LENS_DEV_CONFIG" ] || die "LENS_DEV_CONFIG not found: $LENS_DEV_CONFIG"
config_file="$(cd "$(dirname "$LENS_DEV_CONFIG")" && pwd)/$(basename "$LENS_DEV_CONFIG")"
fi
cd "$repo_root"
if [ "$seed_only" = 1 ]; then
[ -s "$key_file" ] || [ -n "${LENS_DEV_MASTER_KEY:-}" ] || die "start make lens-dev before --seed-only"
load_master_key
seed_data
return
fi
[[ "$startup_timeout" =~ ^[1-9][0-9]*$ ]] || die "LENS_DEV_STARTUP_TIMEOUT_SECONDS must be a positive integer"
[[ "$readiness_request_timeout" =~ ^[1-9][0-9]*$ ]] || die "LENS_DEV_READINESS_REQUEST_TIMEOUT_SECONDS must be a positive integer"
listening "$proxy_port" && die "port $proxy_port is in use; set LENS_DEV_PROXY_PORT"
listening "$ui_port" && die "port $ui_port is in use; set LENS_DEV_UI_PORT"
[ "$proxy_port" != "$ui_port" ] || die "proxy and UI ports must differ"
@ -213,6 +287,8 @@ main() {
(cd ui/litellm-dashboard && "$repo_root/scripts/with_dashboard_node.sh" npm ci)
fi
build_dashboard
if [ -z "${config_file:-}" ]; then
config_file="$state_dir/config.yaml"
write_default_config "$config_file"
@ -225,6 +301,7 @@ main() {
(
proxy_env "$exports"
export PROXY_BASE_URL="http://localhost:$ui_port"
exec "$py" litellm/proxy/proxy_cli.py --config "$config_file" --host 127.0.0.1 --port "$proxy_port"
) < /dev/null > "$log_dir/proxy.log" 2>&1 &
proxy_pid=$!
@ -232,12 +309,15 @@ main() {
(
cd ui/litellm-dashboard
NEXT_PUBLIC_BASE_URL="$proxy_url" exec "$repo_root/scripts/with_dashboard_node.sh" npx next dev -p "$ui_port"
NEXT_PUBLIC_BASE_URL="" LENS_DEV_PROXY_URL="$proxy_url" exec "$repo_root/scripts/with_dashboard_node.sh" npx next dev -p "$ui_port"
) < /dev/null > "$log_dir/ui.log" 2>&1 &
pids+=("$!")
ui_pid=$!
pids+=("$ui_pid")
wait_for_ui "$ui_pid"
wait_for_proxy "$proxy_pid"
ensure_worker_token
if [ -n "$seed_profile" ]; then seed_data; fi
LITELLM_RELEASE_TAG="$source_release_tag" \
LITELLM_MODE=PRODUCTION LITELLM_URL="$proxy_url" LENS_WORKER_TOKEN="$(cat "$token_file")" \
@ -250,12 +330,13 @@ main() {
cat <<EOF
Lens dev is up. Ctrl-C stops everything.
Log in: $proxy_url/ui/login (admin / $key_hint)
Lens: http://localhost:$ui_port/lens
Log in: http://localhost:$ui_port/ui/login/ (admin / $key_hint)
Lens: http://localhost:$ui_port/ui/lens/ (hot-reloads)
API: $proxy_url
Logs: $log_dir/proxy.log
$log_dir/worker.log
$log_dir/ui.log
Restart (Ctrl-C, make lens-dev) to pick up backend or worker edits; the UI hot-reloads.
Restart for backend or worker edits; UI edits hot-reload.
EOF
while :; do

View file

@ -2,88 +2,5 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
seed_fixtures=0
case "${1:-}" in
--seed) seed_fixtures=1 ;;
"") ;;
*) echo "Usage: $0 [--seed]" >&2; exit 2 ;;
esac
if lsof -nP -iTCP:4002 -sTCP:LISTEN >/dev/null 2>&1; then
echo "Port 4002 is already in use. Stop the existing proxy before starting this stack" >&2
exit 1
fi
docker compose -f docker/docker-compose.tracing.yml up -d --wait db clickhouse
uv sync --inexact --frozen --extra proxy --group proxy-dev --no-install-project
"$repo_root/.venv/bin/python" scripts/prisma_generate_if_needed.py
VIRTUAL_ENV="$repo_root/.venv" uvx --from maturin==1.15.0 maturin develop \
--release --manifest-path litellm-rust/crates/python-bridge/Cargo.toml --features extension-module
config_file="$(mktemp "${TMPDIR:-/tmp}/litellm-tracing-local.XXXXXX")"
proxy_pid=""
cleanup() {
if [ -n "$proxy_pid" ]; then
kill "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
fi
rm -f "$config_file"
}
trap cleanup EXIT
trap 'exit 130' INT TERM
cat > "$config_file" <<'EOF'
model_list:
- model_name: openai/gpt-6-luna
litellm_params:
model: openai/gpt-6-luna
api_key: os.environ/OPENAI_API_KEY
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
store_prompts_in_spend_logs: true
tracing:
store:
type: clickhouse
url: os.environ/CLICKHOUSE_URL
retention_days: 14
EOF
export LITELLM_MASTER_KEY=sk-1234
export LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true
export LITELLM_SALT_KEY=sk-local-tracing-salt-key
export DATABASE_URL=postgresql://litellm:litellm@127.0.0.1:15432/litellm
export STORE_MODEL_IN_DB=True
export CLICKHOUSE_URL=http://default:local-tracing@127.0.0.1:18123
export CLICKHOUSE_DATABASE=litellm
export LITELLM_LOCAL_MODEL_COST_MAP=True
export PROXY_BASE_URL=http://127.0.0.1:4002
(
cd "$repo_root/ui/litellm-dashboard"
"$repo_root/scripts/with_dashboard_node.sh" npm ci
NEXT_PUBLIC_BASE_URL= "$repo_root/scripts/with_dashboard_node.sh" npm run build
)
export LITELLM_UI_PATH="$repo_root/ui/litellm-dashboard/out"
printf 'Dashboard: http://127.0.0.1:4002/ui/\nProxy: http://127.0.0.1:4002\nMaster key: %s\n' "$LITELLM_MASTER_KEY"
"$repo_root/.venv/bin/python" litellm/proxy/proxy_cli.py \
--config "$config_file" --host 127.0.0.1 --port 4002 &
proxy_pid=$!
if [ "$seed_fixtures" = "1" ]; then
ready=0
for attempt in $(seq 1 180); do
kill -0 "$proxy_pid" 2>/dev/null || { echo "Proxy exited before seeding" >&2; exit 1; }
if curl --fail --silent "$PROXY_BASE_URL/health/readiness" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" >/dev/null; then
ready=1
break
fi
sleep 1
done
[ "$ready" = "1" ] || { echo "Proxy did not become ready within 180 seconds" >&2; exit 1; }
"$repo_root/.venv/bin/python" -m scripts.seed_tracing_fixtures
fi
wait "$proxy_pid"
echo "run_tracing_proxy_local.sh is deprecated; use make lens-dev" >&2
exec "$repo_root/scripts/lens_dev.sh" "$@"

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import argparse
import asyncio
import base64
import binascii
@ -10,32 +11,31 @@ import os
import re
import sys
import time
from collections.abc import Iterator
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import cache
from itertools import chain
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, Literal
from uuid import uuid4
import httpx
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
from litellm.rust_bridge.trace.generated.types import AllQueryScope, Trace
from litellm.rust_bridge.trace.storage import ClickHouseStorage
from litellm.rust_bridge.trace.storage import ClickHouseStorage, Tenant, span_rows
from litellm.tracing.config import trace_storage_config
from litellm.tracing.types import SpendLogRecord
if TYPE_CHECKING:
from prisma import Prisma
from prisma.types import LiteLLM_SpendLogsCreateWithoutRelationsInput
REPO_ROOT: Final = Path(__file__).resolve().parents[1]
TRACE_FIXTURES: Final = REPO_ROOT / "litellm-rust/crates/traces/tests/fixtures"
SPEND_FIXTURE: Final = (
REPO_ROOT / "litellm-rust/crates/traces-clickhouse/tests/fixtures/deeplite_swarm_spend_logs.jsonl"
)
SPEND_FIXTURES: Final = SPEND_FIXTURE.parent
SPEND_FIXTURES: Final = REPO_ROOT / "litellm-rust/crates/traces-clickhouse/tests/fixtures"
JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
SPEND_ROWS: Final = TypeAdapter(tuple[SpendLogRecord, ...])
@ -103,7 +103,9 @@ def response_ids(rows: tuple[SpendLogRecord, ...]) -> Iterator[str]:
def response_pattern(rows: tuple[SpendLogRecord, ...]) -> re.Pattern[str]:
identities: Final = sorted(
frozenset(filter(None, chain(response_ids(rows), (row["litellm_call_id"] for row in rows)))),
frozenset(
identity for identity in chain(response_ids(rows), (row["litellm_call_id"] for row in rows)) if identity
),
key=len,
reverse=True,
)
@ -118,12 +120,15 @@ def rebased_response(value: str, namespace: str, pattern: re.Pattern[str]) -> st
return "resp_" + base64.b64encode(payload.encode()).decode()
@cache
def fixture_exports(directory: Path) -> tuple[tuple[str, JsonValue], ...]:
return tuple((path.stem, JSON.validate_json(path.read_bytes())) for path in sorted(directory.glob("*.json")))
def fixture_replays(
directory: Path, now_ms: int, namespace: str, response_pattern: re.Pattern[str]
) -> tuple[FixtureReplay, ...]:
exports: Final = tuple(
(path.stem, JSON.validate_json(path.read_bytes())) for path in sorted(directory.glob("*.json"))
)
exports: Final = fixture_exports(directory)
query_latest: Final = max(
(max(timestamps(export)) for name, export in exports if name.startswith("query_")), default=0
)
@ -236,15 +241,51 @@ def postgres_row(row: SpendLogRecord) -> LiteLLM_SpendLogsCreateWithoutRelations
)
async def seed() -> int:
from prisma import Prisma
class SeedOptions(BaseModel):
model_config = ConfigDict(frozen=True)
profile: Literal["default", "large"]
copies: int | None
batch_copies: int = 4
timeout_seconds: float = 120
fixtures: Final = spend_fixtures()
spends: Final = tuple(chain.from_iterable(rows for _, rows in fixtures))
def seed_arguments(argv: Sequence[str] | None = None) -> SeedOptions:
parser: Final = argparse.ArgumentParser(description="Replay tracing fixtures into a running local Lens stack")
parser.add_argument("--profile", choices=("default", "large"), default="default")
parser.add_argument(
"--copies",
type=int,
default=os.environ.get("LENS_DEV_SEED_COPIES"),
help="Override fixture copies (default: 1, large: 2000; env: LENS_DEV_SEED_COPIES)",
)
parser.add_argument(
"--batch-copies",
type=int,
default=os.environ.get("LENS_DEV_SEED_BATCH_COPIES", "4"),
help="Copies per bulk insert (default: 4; env: LENS_DEV_SEED_BATCH_COPIES)",
)
parser.add_argument("--timeout-seconds", type=float, default=os.environ.get("LENS_DEV_SEED_TIMEOUT_SECONDS", "120"))
arguments: Final = SeedOptions.model_validate(vars(parser.parse_args(argv)))
if arguments.copies is not None and arguments.copies < 1:
parser.error("--copies must be positive")
if arguments.batch_copies < 1:
parser.error("--batch-copies must be positive")
if not math.isfinite(arguments.timeout_seconds) or arguments.timeout_seconds <= 0:
parser.error("--timeout-seconds must be finite and positive")
return arguments
async def seed_batch(
client: httpx.AsyncClient,
storage: ClickHouseStorage,
database: Prisma,
replays: tuple[FixtureReplay, ...],
fixtures: tuple[tuple[str, tuple[SpendLogRecord, ...]], ...],
pattern: re.Pattern[str],
tenant: TenantIdentity | None,
verify: bool,
) -> TenantIdentity:
by_name: Final = MappingProxyType(dict(fixtures))
namespace: Final = uuid4().hex
pattern: Final = response_pattern(spends)
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, namespace, pattern)
paired: Final = tuple(
(
replay.name,
@ -254,45 +295,108 @@ async def seed() -> int:
if replay.name in by_name
)
rebased_spends: Final = tuple(chain.from_iterable(rows for _, rows in paired))
master_key: Final = os.environ["LITELLM_MASTER_KEY"]
proxy_url: Final = os.environ.get("PROXY_BASE_URL", "http://127.0.0.1:4002")
async with httpx.AsyncClient(
base_url=proxy_url, headers={"Authorization": f"Bearer {master_key}"}, timeout=60
) as client:
for replay in replays:
(
await client.post(
"/v1/traces", content=json.dumps(replay.export), headers={"Content-Type": "application/json"}
)
).raise_for_status()
storage: Final = ClickHouseStorage(trace_storage_config({}))
trace_id: Final = next(row["trace_id"] for row in rebased_spends if row["trace_id"])
identity: Final = await storage.query_sql(
"SELECT DISTINCT TeamId AS team_id, ApiKeyHash AS api_key, UserId AS user "
f"FROM otel_traces WHERE TraceId = '{trace_id}'",
AllQueryScope(kind="all"),
master_key,
)
tenant: Final = TenantIdentity.model_validate(identity.data[0])
stamped_spends: Final[tuple[SpendLogRecord, ...]] = tuple(
{**row, "team_id": tenant.team_id, "api_key": tenant.api_key, "user": tenant.user} for row in rebased_spends
)
await storage.insert_rows("spend_logs", stamped_spends)
async with Prisma() as database:
await database.litellm_spendlogs.create_many(data=[postgres_row(row) for row in stamped_spends])
resolved_tenant: Final = await ingest_replays(
client, storage, replays, fixture_capture(*next((name, rows[0]) for name, rows in paired)).trace_id, tenant
)
stamped_spends: Final[tuple[SpendLogRecord, ...]] = tuple(
{**row, "team_id": resolved_tenant.team_id, "api_key": resolved_tenant.api_key, "user": resolved_tenant.user}
for row in rebased_spends
)
await storage.insert_rows("spend_logs", stamped_spends)
await database.litellm_spendlogs.create_many(data=[postgres_row(row) for row in stamped_spends])
if verify:
verified: Final = tuple(await asyncio.gather(*(verify_capture(client, name, rows) for name, rows in paired)))
sys.stdout.write(
json.dumps(
{
"trace_fixtures": tuple(replay.name for replay in replays),
"spend_rows": len(stamped_spends),
"captures": verified,
},
indent=2,
)
+ "\n"
sys.stdout.write(json.dumps({"spend_rows": len(stamped_spends), "captures": verified}, indent=2) + "\n")
if not all(capture["verified"] for capture in verified):
raise RuntimeError("Seed spend verification failed")
return resolved_tenant
async def ingest_replays(
client: httpx.AsyncClient,
storage: ClickHouseStorage,
replays: tuple[FixtureReplay, ...],
trace_id: str,
tenant: TenantIdentity | None,
) -> TenantIdentity:
if tenant is not None:
await storage.insert_rows(
"otel_traces", bulk_span_rows(replays, Tenant(tenant.team_id, tenant.api_key, user_id=tenant.user))
)
return 0 if all(capture["verified"] for capture in verified) else 1
return tenant
for replay in replays:
(
await client.post(
"/v1/traces", content=json.dumps(replay.export), headers={"Content-Type": "application/json"}
)
).raise_for_status()
identity: Final = await storage.query_sql(
"SELECT DISTINCT TeamId AS team_id, ApiKeyHash AS api_key, UserId AS user "
f"FROM otel_traces WHERE TraceId = '{trace_id}'",
AllQueryScope(kind="all"),
os.environ["LITELLM_MASTER_KEY"],
)
return TenantIdentity.model_validate(identity.data[0])
def bulk_span_rows(replays: tuple[FixtureReplay, ...], tenant: Tenant) -> tuple[Mapping[str, JsonValue], ...]:
return tuple(
chain.from_iterable(
span_rows(json.dumps(replay.export).encode(), "application/json", tenant) for replay in replays
)
)
def replay_batches(
count: int, now_ms: int, namespace: str, pattern: re.Pattern[str], batch_copies: int = 4
) -> Iterator[tuple[int, tuple[FixtureReplay, ...]]]:
for start, stop in ((start, min(start + batch_copies, count)) for start in range(1, count, batch_copies)):
yield (
stop,
tuple(
chain.from_iterable(
fixture_replays(TRACE_FIXTURES, now_ms - index * 1000, f"{namespace}-{index}", pattern)
for index in range(start, stop)
)
),
)
async def seed(
profile: str = "default", copies: int | None = None, batch_copies: int = 4, timeout_seconds: float = 120
) -> int:
from prisma import Prisma
fixtures: Final = spend_fixtures()
pattern: Final = response_pattern(tuple(chain.from_iterable(rows for _, rows in fixtures)))
count: Final = copies if copies is not None else (2000 if profile == "large" else 1)
namespace: Final = uuid4().hex
now_ms: Final = time.time_ns() // 1_000_000
storage: Final = ClickHouseStorage(trace_storage_config({}))
async with (
httpx.AsyncClient(
base_url=os.environ.get("PROXY_BASE_URL", "http://127.0.0.1:4002"),
headers={"Authorization": f"Bearer {os.environ['LITELLM_MASTER_KEY']}"},
timeout=timeout_seconds,
) as client,
Prisma() as database,
):
tenant: Final = await seed_batch(
client,
storage,
database,
fixture_replays(TRACE_FIXTURES, now_ms, namespace + "-0", pattern),
fixtures,
pattern,
None,
True,
)
for stop, replays in replay_batches(count, now_ms, namespace, pattern, batch_copies):
await seed_batch(client, storage, database, replays, fixtures, pattern, tenant, stop == count)
sys.stdout.write(f"Seeded {stop}/{count} fixture copies\n")
sys.stdout.flush()
sys.stdout.write(f"Seed complete: profile={profile}, copies={count}, namespace={namespace}\n")
return 0
def fixture_capture(name: str, row: SpendLogRecord) -> FixtureCapture:
@ -307,7 +411,7 @@ def fixture_capture(name: str, row: SpendLogRecord) -> FixtureCapture:
async def verify_capture(
client: httpx.AsyncClient, name: str, rows: tuple[SpendLogRecord, ...]
) -> dict[str, JsonValue]:
) -> Mapping[str, JsonValue]:
capture: Final = fixture_capture(name, rows[0])
detail: Final = await client.get(f"/v1/traces/{capture.trace_id}")
detail.raise_for_status()
@ -320,9 +424,14 @@ async def verify_capture(
"spend_rows": len(rows),
"recorded_spend": expected,
"trace_spend": actual,
"verified": math.isclose(actual, expected) if actual is not None else not (capture.spend_linked and capture.spend_complete),
"verified": math.isclose(actual, expected)
if actual is not None
else not (capture.spend_linked and capture.spend_complete),
}
if __name__ == "__main__":
raise SystemExit(asyncio.run(seed()))
arguments: Final = seed_arguments()
raise SystemExit(
asyncio.run(seed(arguments.profile, arguments.copies, arguments.batch_copies, arguments.timeout_seconds))
)

View file

@ -490,10 +490,10 @@ def seeded_trace_api(clickhouse_url: str) -> Iterator[SeededTraceAPI]:
rebase_spend,
)
spends: Final = dict(spend_fixtures())["deeplite_swarm"]
spends: Final = dict(spend_fixtures())["openai_agents_swarm"]
pattern: Final = re.compile("|".join(re.escape(row["response_id"]) for row in spends))
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, "query-api", pattern)
swarm: Final = next(replay for replay in replays if replay.name == "deeplite_swarm")
swarm: Final = next(replay for replay in replays if replay.name == "openai_agents_swarm")
rebased: Final = rebase_spend(spends, swarm.offset_ms, swarm.namespace, pattern)
stamped: Final[tuple[SpendLogRecord, ...]] = tuple(
{**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"} for row in rebased
@ -533,12 +533,11 @@ def test_fixture_backed_help_examples_execute_through_query_api(seeded_trace_api
assert {table.name for table in api.help.tables} == {"otel_traces", "spend_logs", "agent_traces_by_key"}
assert api.help.metadata.error is None
assert api.help.metadata.sampled_rows == len(api.spends)
assert any(field.path == ("synthetic_spend",) for field in api.help.metadata.fields)
assert any(field.path == ("fixture_capture", "name") for field in api.help.metadata.fields)
for example in api.help.examples:
api.query_example(example.name)
records: Final = api.query_example("Recent spend records")
assert {str(row["request_id"]) for row in records} == {row["request_id"] for row in api.spends}
assert all(bool(row["synthetic_spend"]) for row in records)
total: Final = sum(row["spend"] or 0 for row in api.spends)
recorded: Final = api.query_example("Recorded spend by trace")
assert len(recorded) == 1
@ -616,7 +615,7 @@ def captured_trace_api() -> Iterator[SeededTraceAPI]:
yield from _fixture_trace_api(url, replays, stamped)
@pytest.mark.parametrize("name", tuple(name for name, _ in spend_fixtures() if name != "deeplite_swarm"))
@pytest.mark.parametrize("name", tuple(name for name, _ in spend_fixtures()))
def test_captured_sdk_cost_survives_seeding_and_is_queryable(name: str, captured_trace_api: SeededTraceAPI) -> None:
api: Final = captured_trace_api
rows: Final = tuple(row for row in api.spends if fixture_capture("", row).name == name)

View file

@ -163,3 +163,84 @@ def test_cleanup_kills_child_process_trees(tmp_path):
assert proc.returncode == 0, proc.stderr
assert "lens-dev: stopping" in proc.stdout
assert proc.stdout.strip().endswith("CLEAN")
def test_seed_only_uses_local_credentials_and_profile(tmp_path: Path) -> None:
proc = _run(
tmp_path,
"parse_args --seed-only --seed large --copies 7; master_key=sk-local; "
'py() { env; printf "%s\\n" "$@"; }; py=py; seed_data',
)
assert proc.returncode == 0, proc.stderr
assert "LITELLM_MASTER_KEY=sk-local" in proc.stdout
assert "PROXY_BASE_URL=http://localhost:4000" in proc.stdout
assert "CLICKHOUSE_DATABASE=litellm" in proc.stdout
assert "--profile\nlarge\n--copies\n7" in proc.stdout
def test_seed_arguments_reject_invalid_counts_before_startup(tmp_path: Path) -> None:
proc = _run(tmp_path, "parse_args --seed large --copies 0")
assert proc.returncode == 1
assert "positive integer" in proc.stderr
def test_seed_only_defaults_to_small_profile(tmp_path: Path) -> None:
proc = _run(tmp_path, 'parse_args --seed-only; echo "$seed_profile $seed_only"')
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "default 1"
def test_seed_only_with_no_cli_count_preserves_env_controls(tmp_path: Path) -> None:
proc = _run(
tmp_path,
"parse_args --seed-only; master_key=sk-local; py() { "
'printf "%s %s %s\\n" "$LENS_DEV_SEED_COPIES" "$LENS_DEV_SEED_BATCH_COPIES" "$@"; }; py=py; seed_data',
LENS_DEV_SEED_COPIES="3",
LENS_DEV_SEED_BATCH_COPIES="1",
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.startswith("3 1 -m")
def test_proxy_uses_this_checkouts_ui_build(tmp_path: Path) -> None:
proc = _run(tmp_path, 'proxy_env "export LITELLM_UI_PATH=/old/build"; echo "$LITELLM_UI_PATH"')
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == str(ROOT / "ui/litellm-dashboard/out")
def test_dashboard_build_uses_same_origin_and_captures_failures(tmp_path: Path) -> None:
dashboard = tmp_path / "ui/litellm-dashboard"
dashboard.mkdir(parents=True)
scripts = tmp_path / "scripts"
scripts.mkdir()
runner = scripts / "with_dashboard_node.sh"
runner.write_text('#!/bin/sh\nprintf "base=%s args=%s\\n" "$NEXT_PUBLIC_BASE_URL" "$*"\nexit "$BUILD_STATUS"\n')
runner.chmod(0o755)
snippet = f'repo_root="{tmp_path}"; mkdir -p "$log_dir"; build_dashboard'
success = _run(tmp_path, snippet, BUILD_STATUS="0", LENS_DEV_BUILD_UI="1", NEXT_PUBLIC_BASE_URL="http://old-proxy")
assert success.returncode == 0, success.stderr
log = tmp_path / "state/logs/ui-build.log"
assert log.read_text().strip() == "base= args=npm run build"
failure = _run(tmp_path, snippet, BUILD_STATUS="1", LENS_DEV_BUILD_UI="1")
assert failure.returncode == 1
assert "UI build failed" in failure.stderr
def test_skipping_ui_build_needs_no_static_export(tmp_path: Path) -> None:
proc = _run(tmp_path, f'repo_root="{tmp_path}"; build_dashboard', LENS_DEV_BUILD_UI="0")
assert proc.returncode == 0, proc.stderr
assert not (tmp_path / "ui/litellm-dashboard/out").exists()
def test_ui_readiness_uses_live_login_route(tmp_path: Path) -> None:
proc = _run(tmp_path, 'wait_for_ui "$$"', LENS_DEV_UI_PORT="3017")
assert proc.returncode == 0, proc.stderr
assert "http://localhost:3017/ui/login/" in _curl_calls(tmp_path)
def test_ui_exit_fails_before_readiness_request(tmp_path: Path) -> None:
proc = _run(tmp_path, 'true & child=$!; wait "$child"; wait_for_ui "$child"')
assert proc.returncode == 1
assert "UI exited; see" in proc.stderr
assert "ui.log" in proc.stderr
assert _curl_calls(tmp_path) == ""

View file

@ -4,23 +4,30 @@ from datetime import datetime
from itertools import chain
from pathlib import Path
from typing import Final
from unittest.mock import AsyncMock
import httpx
import pytest
from prisma import Json
from prisma import Json, Prisma
from pydantic import InstanceOf, TypeAdapter
from litellm.rust_bridge.trace.storage import span_rows
from litellm.rust_bridge.trace.storage import Tenant, span_rows
from litellm.tracing.types import SpendLogRecord
from scripts.seed_tracing_fixtures import (
JSON,
TRACE_FIXTURES,
TenantIdentity,
bulk_span_rows,
fixture_capture,
fixture_replays,
postgres_row,
rebase,
rebase_spend,
replay_batches,
response_ids,
response_pattern,
seed_arguments,
seed_batch,
seed_id,
spend_fixtures,
timestamps,
@ -76,9 +83,9 @@ def test_all_fixture_replays_are_recent_and_preserve_spans(path: Path) -> None:
@pytest.mark.requires_rust_extension
def test_replay_preserves_trace_topology_usage_and_event_timing() -> None:
export: Final = JSON.validate_json((TRACE_FIXTURES / "deeplite_swarm.json").read_bytes())
export: Final = JSON.validate_json((TRACE_FIXTURES / "deepagents_swarm.json").read_bytes())
original: Final = span_rows(json.dumps(export).encode(), "application/json")
spend_rows: Final = dict(spend_fixtures())["deeplite_swarm"]
spend_rows: Final = dict(spend_fixtures())["deepagents_swarm"]
pattern: Final = re.compile("|".join(re.escape(row["response_id"]) for row in spend_rows))
shifted: Final = rebase(export, 123_000_000, "first-run", pattern)
replayed: Final = span_rows(json.dumps(shifted).encode(), "application/json")
@ -102,36 +109,8 @@ def test_replay_preserves_trace_topology_usage_and_event_timing() -> None:
)
@pytest.mark.requires_rust_extension
def test_paired_fixture_joins_every_successful_llm_span_after_replay() -> None:
export: Final = JSON.validate_json((TRACE_FIXTURES / "deeplite_swarm.json").read_bytes())
spends: Final = dict(spend_fixtures())["deeplite_swarm"]
pattern: Final = re.compile("|".join(re.escape(row["response_id"]) for row in spends))
replays: Final = fixture_replays(TRACE_FIXTURES, max(timestamps(export)) // 1_000_000 + 1123, "paired-run", pattern)
replay: Final = next(item for item in replays if item.name == "deeplite_swarm")
rebased_spends: Final = rebase_spend(spends, replay.offset_ms, replay.namespace, pattern)
spans: Final = span_rows(json.dumps(replay.export).encode(), "application/json")
llm_spans: Final = tuple(span for span in spans if span["ObservationType"] == "llm")
by_response: Final = {row["response_id"]: row for row in rebased_spends}
assert len(by_response) == len(llm_spans) == len(rebased_spends)
assert frozenset(by_response) == frozenset(span["LiteLLMRequestId"] for span in llm_spans)
for span, spend in ((span, by_response[span["LiteLLMRequestId"]]) for span in llm_spans):
assert spend["request_id"] == span["LiteLLMRequestId"]
assert spend["trace_id"] == spend["session_id"] == span["TraceId"]
assert spend["span_id"] == span["SpanId"]
assert spend["start_time"] == span["Timestamp"] // 1_000_000
assert spend["end_time"] == (span["Timestamp"] + span["Duration"]) // 1_000_000
assert spend["prompt_tokens"] == span["InputTokens"]
assert spend["completion_tokens"] == span["OutputTokens"]
assert spend["total_tokens"] == spend["prompt_tokens"] + spend["completion_tokens"]
assert json.loads(spend["response"])["id"] == spend["response_id"]
assert json.loads(spend["response"])["usage"]["total_tokens"] == spend["total_tokens"]
assert json.loads(spend["metadata"])["synthetic_spend"] is True
def test_postgres_rows_preserve_clickhouse_cost_identity_and_payloads() -> None:
spends: Final = dict(spend_fixtures())["deeplite_swarm"]
spends: Final = dict(spend_fixtures())["deepagents_swarm"]
for spend, postgres in ((spend, postgres_row(spend)) for spend in spends):
start_time, end_time = DATETIMES.validate_python((postgres["startTime"], postgres["endTime"]))
@ -143,7 +122,7 @@ def test_postgres_rows_preserve_clickhouse_cost_identity_and_payloads() -> None:
spend["api_key"],
spend["team_id"],
spend["user"],
spend["trace_id"],
spend["session_id"],
)
assert postgres["spend"] == spend["spend"]
assert postgres["total_tokens"] == spend["prompt_tokens"] + spend["completion_tokens"]
@ -156,7 +135,7 @@ def test_postgres_rows_preserve_clickhouse_cost_identity_and_payloads() -> None:
@pytest.mark.requires_rust_extension
@pytest.mark.parametrize("name,spends", tuple(item for item in spend_fixtures() if item[0] != "deeplite_swarm"))
@pytest.mark.parametrize("name,spends", spend_fixtures())
def test_captured_spend_replay_preserves_real_cost_and_call_identity(
name: str, spends: tuple[SpendLogRecord, ...]
) -> None:
@ -184,7 +163,7 @@ def test_captured_spend_replay_preserves_real_cost_and_call_identity(
if before["litellm_call_id"]:
assert after["litellm_call_id"] != before["litellm_call_id"]
identities: Final = frozenset(f"provider_response:{identity}" for identity in response_ids((after,))) | {
f"litellm_request:{after["litellm_call_id"]}"
f"litellm_request:{after['litellm_call_id']}"
}
assert bool(identities & keys) is capture.spend_linked
@ -193,9 +172,81 @@ def test_captured_spend_replay_preserves_real_cost_and_call_identity(
def test_spend_fixture_loading_preserves_gateway_ids_and_defaults_legacy_rows(
tmp_path: Path, call_id: str | None
) -> None:
original: Final = dict(spend_fixtures())["deeplite_swarm"][0]
original: Final = dict(spend_fixtures())["deepagents_swarm"][0]
fields: Final = {key: value for key, value in original.items() if key != "litellm_call_id"}
supplied: Final = fields if call_id is None else {**fields, "litellm_call_id": call_id}
(tmp_path / "example_spend_logs.jsonl").write_text(json.dumps(supplied) + "\n")
loaded: Final = spend_fixtures(tmp_path)
assert loaded == (("example", ({**original, "litellm_call_id": call_id or ""},)),)
@pytest.mark.requires_rust_extension
def test_bulk_export_preserves_all_spans_and_disjoint_copy_ids() -> None:
first: Final = fixture_replays(TRACE_FIXTURES, 1_800_000_000_000, "copy-1", re.compile(r"(?!)"))
second: Final = fixture_replays(TRACE_FIXTURES, 1_800_000_001_000, "copy-2", re.compile(r"(?!)"))
merged: Final = bulk_span_rows(first + second, Tenant("", ""))
separate: Final = tuple(
span for replay in first + second for span in span_rows(json.dumps(replay.export).encode(), "application/json")
)
assert tuple(merged) == separate
first_ids: Final = frozenset(span["TraceId"] for span in bulk_span_rows(first, Tenant("", "")))
second_ids: Final = frozenset(span["TraceId"] for span in bulk_span_rows(second, Tenant("", "")))
assert first_ids.isdisjoint(second_ids)
@pytest.mark.requires_rust_extension
@pytest.mark.asyncio
async def test_bulk_seed_stamps_authenticated_tenant_and_writes_both_stores() -> None:
from litellm.rust_bridge.trace.storage import ClickHouseStorage, Tenant
fixtures: Final = spend_fixtures()
pattern: Final = response_pattern(tuple(chain.from_iterable(rows for _, rows in fixtures)))
replays: Final = fixture_replays(TRACE_FIXTURES, 1_800_000_000_000, "bulk", pattern)
storage: Final = AsyncMock(spec=ClickHouseStorage)
database: Final = AsyncMock(spec=Prisma, litellm_spendlogs=AsyncMock())
tenant: Final = TenantIdentity(team_id="local-team", api_key="local-hash", user="admin")
async with httpx.AsyncClient() as client:
result: Final = await seed_batch(client, storage, database, replays, fixtures, pattern, tenant, False)
assert result == tenant
trace_table, trace_rows = storage.insert_rows.call_args_list[0].args
assert trace_table == "otel_traces"
assert trace_rows == bulk_span_rows(replays, Tenant("local-team", "local-hash", user_id="admin"))
table, rows = storage.insert_rows.call_args_list[1].args
assert table == "spend_logs"
assert len(rows) == sum(len(original) for _, original in fixtures)
assert all((row["team_id"], row["api_key"], row["user"]) == ("local-team", "local-hash", "admin") for row in rows)
saved: Final = database.litellm_spendlogs.create_many.call_args.kwargs["data"]
assert tuple(row["request_id"] for row in saved) == tuple(row["request_id"] for row in rows)
assert tuple(row["spend"] for row in saved) == tuple(row["spend"] for row in rows)
storage.query_sql.assert_not_called()
def test_seed_cli_rejects_nonpositive_copies() -> None:
with pytest.raises(SystemExit) as error:
seed_arguments(["--copies", "0"])
assert error.value.code == 2
assert seed_arguments(["--profile", "large", "--copies", "5"]).copies == 5
def test_bulk_batches_cover_every_copy_including_partial_tail() -> None:
batches: Final = tuple(replay_batches(8, 1_800_000_000_000, "batch", re.compile(r"(?!)"), 3))
assert tuple(stop for stop, _ in batches) == (4, 7, 8)
expected: Final = tuple(
tuple(fixture_replays(TRACE_FIXTURES, 1_800_000_000_000 - index * 1000, f"batch-{index}", re.compile(r"(?!)")))
for index in range(1, 8)
)
assert tuple(chain.from_iterable(replays for _, replays in batches)) == tuple(chain.from_iterable(expected))
def test_seed_cli_rejects_nonpositive_batch_size() -> None:
with pytest.raises(SystemExit) as error:
seed_arguments(["--batch-copies", "0"])
assert error.value.code == 2
assert seed_arguments(["--batch-copies", "2"]).batch_copies == 2
@pytest.mark.parametrize("timeout", ("0", "-1", "inf", "nan"))
def test_seed_cli_rejects_invalid_http_timeouts(timeout: str) -> None:
with pytest.raises(SystemExit) as error:
seed_arguments(["--timeout-seconds", timeout])
assert error.value.code == 2

View file

@ -5,8 +5,20 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const devProxyUrl = process.env.LENS_DEV_PROXY_URL;
const nextConfig = {
output: "export",
...(devProxyUrl
? {
async rewrites() {
return {
beforeFiles: [{ source: "/ui/:path*", destination: "/:path*" }],
fallback: [{ source: "/:path*", destination: `${devProxyUrl}/:path*` }],
};
},
}
: {}),
output: devProxyUrl ? undefined : "export",
typescript: { tsconfigPath: "tsconfig.production.json" },
experimental: {
useTypeScriptCli: false,
@ -21,7 +33,8 @@ const nextConfig = {
},
basePath: "",
assetPrefix: "/litellm-asset-prefix",
trailingSlash: true,
trailingSlash: !devProxyUrl,
skipTrailingSlashRedirect: Boolean(devProxyUrl),
turbopack: {
// Must be absolute; "." is no longer allowed
root: __dirname,

View file

@ -36,6 +36,7 @@
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
@ -10394,6 +10395,21 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-intersection-observer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.1.tgz",
"integrity": "sha512-BIZ1M40GPSKDIT1MJbXK7SB0BwBaTKr84QfZCs5asKer7YMOYLzMi+RI1F4VStbZLGHoosJmd+aOQDDipUV1gA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View file

@ -53,6 +53,7 @@
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",

View file

@ -64,8 +64,6 @@
}
@theme inline {
--animate-slide-left: slide-left 200ms cubic-bezier(0, 0, 0.2, 1) both;
--animate-view-fade-in: view-fade-in 100ms cubic-bezier(0.4, 0, 0.2, 1) both;
--animate-slot-slide-in: slot-slide-in 150ms cubic-bezier(0, 0, 0.2, 1) both;
--animate-trace-drawer-in: trace-drawer-in 200ms cubic-bezier(0.25, 1, 0.5, 1) both;
--animate-trace-drawer-out: trace-drawer-out 200ms cubic-bezier(0.4, 0, 1, 1) both;
@ -91,24 +89,6 @@
}
}
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(2rem) scaleX(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes view-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slot-slide-in {
from {
opacity: 0;
@ -137,8 +117,6 @@
@media (prefers-reduced-motion: reduce) {
.animate-trace-drawer-in,
.animate-trace-drawer-out,
.animate-slide-left,
.animate-view-fade-in,
.animate-slot-slide-in {
animation: none !important;
}

View file

@ -3,11 +3,11 @@
import { useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { LensPreviewTarget } from "../LensPreviewButton";
import { LensPreviewContext } from "./LensPreviewButton";
export function HeaderActions({ children }: { children: ReactNode }) {
const target = useContext(LensPreviewTarget);
const preview = useContext(LensPreviewContext);
const actions = <div className="flex flex-wrap items-center gap-3">{children}</div>;
if (target === null) return null;
return target ? createPortal(actions, target) : actions;
if (preview === undefined) return actions;
return preview.target ? createPortal(actions, preview.target) : null;
}

View file

@ -1,10 +0,0 @@
"use client";
import { createContext, useContext } from "react";
export interface LensDemo {
copyTrace: (traceId: string, spanId?: string) => string;
}
export const LensDemoContext = createContext<LensDemo | null>(null);
export const useLensDemo = () => useContext(LensDemoContext);

View file

@ -32,9 +32,9 @@ describe("Lens navigation", () => {
expect(screen.getByText("Trace polling active")).toBeVisible();
});
it("opens existing lens links on findings", () => {
it("opens existing lens links on investigations", () => {
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Manage investigations")).toBeVisible();
});

View file

@ -4,15 +4,21 @@ import { createContext, useContext } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
export const LensPreviewTarget = createContext<HTMLElement | null | undefined>(undefined);
export function LensPreviewButton({ onClick }: { onClick: () => void }) {
const target = useContext(LensPreviewTarget);
const button = (
<Button variant="outline" onClick={onClick}>
Preview sample
</Button>
);
if (target === null) return null;
return target ? createPortal(button, target) : button;
export interface LensPreview {
/** Header slot for portaled actions; null until it mounts. */
readonly target: HTMLElement | null;
readonly open?: () => void;
}
export const LensPreviewContext = createContext<LensPreview | undefined>(undefined);
export function LensPreviewButton() {
const preview = useContext(LensPreviewContext);
if (!preview?.open || !preview.target) return null;
return createPortal(
<Button variant="outline" onClick={preview.open}>
Preview sample
</Button>,
preview.target,
);
}

View file

@ -0,0 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { TracesApiContext } from "@/components/view_logs/TraceView/tracesApi";
import { LensApiContext, type LensServices } from "./services";
export function LensServicesProvider({ services, children }: { services: LensServices; children: ReactNode }) {
return (
<LensApiContext.Provider value={services.lens}>
<TracesApiContext.Provider value={services.traces}>{children}</TracesApiContext.Provider>
</LensApiContext.Provider>
);
}

View file

@ -1,10 +1,15 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./demo/createLensDemo";
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const expectUrl = (onUrlUpdate: ReturnType<typeof vi.fn>, check: (params: URLSearchParams) => void) =>
waitFor(() => check(lastUrl(onUrlUpdate)));
const network = vi.fn<typeof fetch>();
beforeEach(() => {
testQueryClient.clear();
@ -19,7 +24,7 @@ beforeEach(() => {
});
describe("Lens interactive demo", () => {
it("opens without tracing, filters sample runs, and restores the live view without mixing data", async () => {
it("opens without tracing, keeps the sample session in the URL, and restores the live view without mixing data", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
@ -29,6 +34,8 @@ describe("Lens interactive demo", () => {
await user.click(screen.getByRole("button", { name: "Preview sample" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
network.mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
@ -40,18 +47,84 @@ describe("Lens interactive demo", () => {
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by agent" }), "support_agent");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
await expectUrl(onUrlUpdate, (url) => expect(url.get("agent")).toBe("support_agent"));
await expectUrl(onUrlUpdate, (url) => expect(url.get("status")).toBe("error"));
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(await screen.findByText("Enable tracing")).toBeVisible();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Demo data" })).not.toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect([...url.entries()]).toEqual([["tab", "investigations"]]));
});
it("connects findings and history to their original trace without live requests or URL changes", async () => {
it("opens the sample session from ?demo=true and keeps the open run and step in the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
searchParams: "?demo=true",
onUrlUpdate,
});
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
await user.click(await screen.findByText("Where is order #1042?"));
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
await expectUrl(onUrlUpdate, (url) => expect(url.get("trace")).toBeTruthy());
const openRun = lastUrl(onUrlUpdate);
expect(openRun.get("demo")).toBe("true");
const run = createLensDemoData().runs.find(({ trace }) => trace.summary.trace_id === openRun.get("trace"));
const steps = await within(drawer).findAllByRole("treeitem");
const step = steps.find((row) => run?.trace.spans.some((span) => span.span_id === row.getAttribute("data-row-id")));
await user.click(step!);
await expectUrl(onUrlUpdate, (url) => expect(url.get("span")).toBe(step!.getAttribute("data-row-id")));
expect(lastUrl(onUrlUpdate).get("trace")).toBe(openRun.get("trace"));
await user.click(within(drawer).getByRole("tab", { name: "Attributes" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("span_tab")).toBe("attributes"));
await user.click(within(drawer).getByRole("tab", { name: "Conversation" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("view")).toBe("conversation"));
expect(network).not.toHaveBeenCalled();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText("Enable tracing")).toBeVisible();
await expectUrl(onUrlUpdate, (url) => expect([...url.keys()]).toEqual([]));
});
it("drops the live run selection when entering the sample session", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
network.mockResolvedValue(Response.json({ detail: "Tracing is not enabled" }, { status: 501 }));
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
searchParams: "?tab=traces&trace=live-trace&span=live-span&lens=live-lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("switch", { name: "Demo data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
await expectUrl(onUrlUpdate, (url) =>
expect([...url.entries()]).toEqual([
["tab", "traces"],
["demo", "true"],
]),
);
expect(screen.queryByText(/Could not load trace/)).not.toBeInTheDocument();
});
it("reopens a shared sample link on the same run, step and section", async () => {
const data = createLensDemoData();
const run = data.runs[0].trace;
const step = run.spans[run.spans.length - 1];
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
searchParams: `?demo=true&trace=${run.summary.trace_id}&span=${step.span_id}&span_tab=request`,
});
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(await within(drawer).findByRole("treeitem", { selected: true })).toHaveAttribute(
"data-row-id",
step.span_id,
);
expect(within(drawer).getByRole("tab", { name: "Request" })).toHaveAttribute("aria-selected", "true");
});
it("connects findings and history to their original trace without live requests", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
@ -60,7 +133,6 @@ describe("Lens interactive demo", () => {
});
await screen.findByRole("button", { name: "Preview sample" });
await user.click(screen.getByRole("button", { name: "Preview sample" }));
await user.click(await screen.findByRole("tab", { name: "Findings" }));
network.mockClear();
await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ }));
const finding = screen.getByRole("dialog");
@ -80,10 +152,11 @@ describe("Lens interactive demo", () => {
within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close" }),
);
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
expect(await screen.findByRole("table", { name: "Findings" })).toBeVisible();
expect(await screen.findByRole("table", { name: "Investigations" })).toBeVisible();
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
await expectUrl(onUrlUpdate, (url) => expect(url.has("span")).toBe(false));
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
});

View file

@ -1,129 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { Aperture } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
import { useId, useState } from "react";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Activity, Aperture, Info, ScanSearch } from "lucide-react";
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 { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { LensServicesProvider } from "./LensServicesProvider";
import { LensPreviewContext } from "./LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
import { LENS_TABS, useLensRoute, type LensTab } from "./route";
type Tab = "traces" | "findings" | "investigations";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
export function LensWorkspace(props: WorkspaceProps) {
const [demoTab, setDemoTab] = useState<Tab | null>(null);
return demoTab ? (
<DemoSession initialTab={demoTab} onExit={() => setDemoTab(null)} />
) : (
<LensContent {...props} onDemo={setDemoTab} />
);
const { demo } = useLensRoute();
return demo ? <SampleSession /> : <LensContent {...props} />;
}
function DemoSession({ initialTab, onExit }: { initialTab: Tab; onExit: () => void }) {
const [demo] = useState(createLensDemo);
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }),
);
useEffect(
() => () => {
client.clear();
},
[client],
);
function SampleSession() {
const [services] = useState(() => createLensDemo());
return (
<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>
<LensServicesProvider services={services}>
<LensContent accessToken="lens-demo" userRole="proxy_admin_viewer" readOnly />
</LensServicesProvider>
);
}
function LensContent({
accessToken,
userRole,
readOnly,
initialTab = "traces",
onDemo,
onExit,
}: WorkspaceProps & { initialTab?: Tab; onDemo?: (tab: Tab) => void; onExit?: () => void }) {
const demo = useLensDemo();
const [tab, setTab] = useQueryState(
"tab",
parseAsStringLiteral(["traces", "findings", "investigations"]).withOptions({ history: "push" }),
function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolean) => void }) {
const id = useId();
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{demo && (
<p role="status" className="flex items-center gap-1.5 font-medium text-info">
<Info aria-hidden="true" className="size-3.5 shrink-0" />
You’re viewing demo data
</p>
)}
<label htmlFor={id}>Demo data</label>
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} />
</div>
);
const [lensId] = useQueryState("lens", parseAsString);
const [demoTab, setDemoTab] = useState(initialTab);
}
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
function LensModeSwitch() {
return (
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-8 items-center rounded-full border border-border bg-muted/60 p-0.5"
>
<TabsPrimitive.Indicator className="absolute top-0.5 bottom-0.5 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-200 ease-out motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
className="relative z-raised inline-flex h-full items-center gap-1.5 rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
>
<Icon aria-hidden="true" className="size-3.5" />
{label}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
);
}
function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
const { tab, lensId, demo, setTab, setDemo } = useLensRoute();
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const defaultTab = lensId ? "findings" : "traces";
const activeTab = demo ? demoTab : tab ?? defaultTab;
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
const activeTab = tab ?? (lensId ? "investigations" : "traces");
const preview = (view: LensTab) => ({
target: previewTarget,
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
});
return (
<LensPreviewTarget.Provider value={previewTarget}>
<main className="flex min-h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
{demo && <DemoNotice onExit={onExit} />}
<Tabs
value={activeTab}
onValueChange={(value) => (demo ? setDemoTab(value as Tab) : void setTab(value as Tab))}
className="min-h-0 flex-1 gap-2"
>
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
Lens
</h1>
<TabsList aria-label="Lens" className="h-8">
<TabsTrigger value="traces" className="px-3">
Traces
</TabsTrigger>
<TabsTrigger value="findings" className="px-3">
Findings
</TabsTrigger>
<TabsTrigger value="investigations" className="px-3">
Investigations
</TabsTrigger>
</TabsList>
</div>
<div ref={setPreviewTarget} />
<main className="flex h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-2">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
Lens
</h1>
<LensModeSwitch />
</div>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
<div className="flex items-center gap-3">
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={setDemo} />
</div>
</div>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={!demo && isProxyAdminRole(userRole)}
onDemo={activeTab === "traces" ? openDemo : undefined}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</TabsContent>
{(["findings", "investigations"] as const).map((view) => (
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
{demo || isProxyAdminTierRole(userRole) ? (
<InvestigationsView
view={view}
active={activeTab === view}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
onDemo={activeTab === view ? openDemo : undefined}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
))}
</Tabs>
</main>
</LensPreviewTarget.Provider>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("investigations")}>
{isProxyAdminTierRole(userRole) ? (
<InvestigationsView
active={activeTab === "investigations"}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</Tabs>
</main>
);
}

View file

@ -30,12 +30,11 @@ export const lensKeys = {
};
export const lensQueries = {
list(api: LensApi, demo: boolean, workerSetup: boolean) {
list(api: LensApi, workerSetup: boolean) {
const options = {
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) =>
item.jobs.some((job) => ["queued", "running"].includes(job.status)),
);
@ -50,24 +49,21 @@ export const lensQueries = {
modelDetails(api: LensApi) {
return queryOptions({ queryKey: lensKeys.modelDetails(api.scope), queryFn: () => api.modelDetails() });
},
activity(api: LensApi, loaded: boolean, demo: boolean) {
activity(api: LensApi, loaded: boolean) {
const options = {
queryKey: lensKeys.activity(api.scope),
queryFn: () => api.activity(),
enabled: loaded,
refetchInterval: demo ? (false as const) : 5000,
refetchInterval: 5000,
};
return queryOptions(options);
},
history(
api: LensApi,
{ lensId, historyOffset, demo }: { lensId: string | undefined; historyOffset: number; demo: boolean },
) {
history(api: LensApi, { lensId, historyOffset }: { lensId: string | undefined; historyOffset: number }) {
const options = {
queryKey: lensKeys.history(api.scope, lensId, historyOffset),
enabled: !!lensId,
queryFn: () => api.runs(lensId as string, historyOffset),
refetchInterval: demo ? (false as const) : 10000,
refetchInterval: 10000,
};
return queryOptions(options);
},

View file

@ -55,7 +55,7 @@ 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 { services } = createLensDemo(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);

View file

@ -1,6 +1,5 @@
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";
@ -375,6 +374,12 @@ function demoLensApi(data: LensDemoData): LensApi {
function demoTracesApi(data: LensDemoData): TracesApi {
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
return {
live: false,
handoff: (traceId, spanId) => {
const found = run(traceId);
const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found;
return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" };
},
list: async ({ startMs, endMs }) => ({
data: data.runs
.map((item) => item.trace.summary)
@ -398,17 +403,7 @@ function demoTracesApi(data: LensDemoData): TracesApi {
};
}
export interface LensDemoSession extends LensDemo {
readonly services: LensServices;
}
export function createLensDemo(now = Date.now()): LensDemoSession {
export function createLensDemo(now = Date.now()): LensServices {
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);
},
};
return { lens: demoLensApi(data), traces: demoTracesApi(data) };
}

View file

@ -1,147 +0,0 @@
"use client";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNow } from "@/hooks/useNow";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
import { cn } from "@/lib/cva.config";
import { ALL_AGENTS, filterInbox, inboxAgents, inboxRows, type InboxRow, type Priority } from "../model/inbox";
import type { Lens } from "../model/types";
const PRIORITY_DOT = { high: "bg-[#e5484d]", medium: "bg-amber-500", low: "bg-muted-foreground/50" } as const;
const PRIORITY_ITEMS: { value: Priority | "all"; label: string }[] = [
{ value: "all", label: "All priorities" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
const TH = "px-3 font-medium";
const TH_NUM = "px-3 text-right font-medium";
function FilterSelect<T extends string>({
label,
value,
items,
onChange,
width,
}: {
label: string;
value: T;
items: { value: T; label: string }[];
onChange: (value: T) => void;
width: string;
}) {
return (
<Select items={items} value={value} onValueChange={(next: T | null) => next !== null && onChange(next)}>
<SelectTrigger size="sm" className={cn("h-7 text-[12px]", width)} aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function FindingsInbox({ lenses, onOpen }: { lenses: readonly Lens[]; onOpen: (row: InboxRow) => void }) {
const [agent, setAgent] = useState(ALL_AGENTS);
const [priority, setPriority] = useState<Priority | "all">("all");
const now = useNow(30000);
const all = inboxRows(lenses);
const rows = filterInbox(all, { agent, priority });
const agentItems = [
{ value: ALL_AGENTS, label: "All agents" },
...inboxAgents(all).map((a) => ({ value: a, label: a })),
];
return (
<div
className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card"
data-testid="findings-inbox"
>
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
<FilterSelect
label="Filter by agent"
value={agent}
items={agentItems}
onChange={setAgent}
width="min-w-[150px]"
/>
<FilterSelect
label="Filter by priority"
value={priority}
items={PRIORITY_ITEMS}
onChange={setPriority}
width="min-w-[130px]"
/>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table aria-label="Findings" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={`w-[96px] ${TH}`}>Priority</th>
<th className={TH}>Finding</th>
<th className={`w-[180px] ${TH}`}>Agent</th>
<th className={`w-[64px] ${TH_NUM}`}>Runs</th>
<th className={`w-[96px] ${TH}`}>Last seen</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.key}
onClick={() => onOpen(row)}
data-testid="inbox-row"
aria-label={row.title}
className="h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none"
>
<td className="px-3">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<span className={cn("size-1.5 rounded-full", PRIORITY_DOT[row.priority])} />
{row.priority}
</span>
</td>
<td className="px-3" title={row.suggestion ? `Fix: ${row.suggestion}` : undefined}>
<span className="block truncate text-foreground">{row.title}</span>
</td>
<td className="truncate px-3 text-muted-foreground" title={row.agents.join(", ")}>
{row.agents.join(", ")}
</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{row.runs}</td>
<td
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"
title={formatActivityTimestamp(row.lastSeen)}
>
{agoLabel(Date.parse(row.lastSeen), now)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<div className="py-16 text-center text-[12px] text-muted-foreground">
{all.length === 0
? "No open findings yet. New problems show up here as soon as an investigation spots them."
: "No findings match these filters."}
</div>
)}
</div>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
{rows.length} {rows.length === 1 ? "finding" : "findings"}
{rows.length !== all.length && ` of ${all.length}`}
</footer>
</div>
);
}

View file

@ -1,7 +1,18 @@
"use client";
import { Pencil, Play, Search } from "lucide-react";
import { useState } from "react";
import { Fragment, useState, type ReactNode } from "react";
import {
ChevronRight,
Circle,
CircleCheck,
CircleDashed,
CircleDot,
CircleSlash,
CircleX,
Pencil,
Play,
Search,
} from "lucide-react";
import { useNow } from "@/hooks/useNow";
import { Input } from "@/components/ui/input";
@ -9,13 +20,37 @@ import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
import { scheduleLabel } from "../model/inbox";
import { findingAgents, openFindings, scheduleLabel } from "../model/inbox";
import { lensStatus } from "../model/status";
import { scopeLabel } from "../model/format";
import { type Lens } from "../model/types";
import { type Finding, type Lens } from "../model/types";
import { useListSearchRoute } from "../route";
const TH = "px-3 font-medium";
const TH_NUM = "px-3 text-right font-medium";
const PRIORITY_COLOR = { high: "text-[#e5484d]", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const ROW =
"border-b border-border/60 transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none";
const META = "truncate text-[11px] text-muted-foreground";
function JobIcon({ lens }: { lens: Lens }) {
const status = lens.jobs[0]?.status;
const className = "size-4 shrink-0";
if (status === "queued" || status === "running")
return <CircleDashed aria-hidden="true" className={cn(className, "text-[#3b5bfd]")} />;
if (status === "failed") return <CircleX aria-hidden="true" className={cn(className, "text-destructive")} />;
if (status === "cancelled")
return <CircleSlash aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
if (status === "completed") return <CircleCheck aria-hidden="true" className={cn(className, "text-emerald-600")} />;
return <Circle aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
}
function TwoLine({ meta, title, className }: { meta: ReactNode; title: ReactNode; className?: string }) {
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className={META}>{meta}</span>
<span className={cn("truncate text-[13px] text-foreground", className)}>{title}</span>
</span>
);
}
export function InvestigationList({
lenses,
@ -23,115 +58,192 @@ export function InvestigationList({
readOnly = false,
onEdit,
onRunNow,
onOpenFinding,
}: {
lenses: Lens[];
connected: boolean;
readOnly?: boolean;
onEdit: (id: string) => void;
onRunNow: (id: string) => void;
onOpenFinding: (lens: Lens, finding: Finding) => void;
}) {
const [search, setSearch] = useState("");
const [search, setSearch] = useListSearchRoute();
const now = useNow(15000);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const toggle = (id: string) =>
setCollapsed((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});
const shown = lenses.filter((lens) =>
`${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-1.5">
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<div className="relative w-full max-w-[380px]">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="h-7 pl-8 text-[12px]"
className="h-7 bg-background pl-8 text-[12px]"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table aria-label="Investigations" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={TH}>Investigation</th>
<th className={`w-[180px] ${TH}`}>Agent</th>
<th className={`w-[220px] ${TH}`}>Schedule</th>
<th className={`w-[150px] ${TH}`}>Last run</th>
<th className={`w-[64px] ${TH_NUM}`}>Open</th>
<th className="w-[92px]" />
<table aria-label="Investigations" className="w-full min-w-[720px] table-fixed border-collapse text-left">
<thead className="sr-only">
<tr>
<th>Investigation</th>
<th>Status</th>
<th>Open findings</th>
<th>Last activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{shown.map((lens) => {
const latest = lens.jobs[0];
const failed = latest?.status === "failed";
const open = lens.findings.filter((f) => f.status === "open").length;
const findings = openFindings(lens);
const expanded = findings.length > 0 && !collapsed.has(lens.id);
return (
<tr
key={lens.id}
onClick={readOnly ? undefined : () => onEdit(lens.id)}
data-testid="investigation-row"
aria-label={lens.settings.name}
className={cn(
"h-9 border-b border-border/60 text-[12px] transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none",
!readOnly && "cursor-pointer",
)}
>
<td className="truncate px-3 text-foreground">{lens.settings.name}</td>
<td className="truncate px-3 text-muted-foreground">{scopeLabel(lens.settings)}</td>
<td className="px-3 font-mono text-[11px]" data-testid="investigation-schedule">
<span
<Fragment key={lens.id}>
<tr
onClick={readOnly ? undefined : () => onEdit(lens.id)}
aria-label={lens.settings.name}
className={cn(ROW, "group h-14", !readOnly && "cursor-pointer")}
>
<td className="pl-2">
<span className="flex min-w-0 items-center gap-2">
{findings.length > 0 ? (
<button
type="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Hide" : "Show"} findings for ${lens.settings.name}`}
onClick={(event) => {
event.stopPropagation();
toggle(lens.id);
}}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span aria-hidden="true" className="size-6 shrink-0" />
)}
<JobIcon lens={lens} />
<TwoLine
meta={`${scopeLabel(lens.settings)} · ${scheduleLabel(lens, now)}`}
title={lens.settings.name}
className="font-semibold"
/>
</span>
</td>
<td
className={cn(
"inline-flex items-center gap-1.5",
lens.settings.enabled ? "text-foreground" : "text-muted-foreground",
"w-[180px] truncate px-3 text-right text-[12px]",
latest?.status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
<span
className={cn(
"size-1.5 rounded-full",
lens.settings.enabled ? "bg-[#3b5bfd]" : "bg-muted-foreground/40",
)}
/>
{scheduleLabel(lens, now)}
</span>
</td>
<td
className="px-3 font-mono text-[11px]"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
<span className={failed ? "text-destructive" : "text-muted-foreground"}>
{lensStatus(lens, connected).toLowerCase()}
</span>
{latest && (
<span className="text-muted-foreground"> · {agoLabel(Date.parse(latest.created_at), now)}</span>
)}
</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{open}</td>
<td className="pr-2">
{!readOnly && (
<span className="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label={`Run ${lens.settings.name} now`}
title="Run now"
onClick={(event) => {
event.stopPropagation();
onRunNow(lens.id);
}}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Play className="size-3" />
</button>
{lensStatus(lens, connected)}
</td>
<td className="w-[72px] px-3 text-right">
{findings.length > 0 && (
<span
aria-hidden="true"
className="inline-flex size-6 items-center justify-center text-muted-foreground/60"
title={`${findings.length} open ${findings.length === 1 ? "finding" : "findings"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-[11px] tabular-nums text-foreground"
>
<Pencil className="size-3" />
{findings.length}
</span>
</span>
)}
</td>
</tr>
)}
</td>
<td
className="w-[120px] px-3 text-right text-[12px] text-muted-foreground"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
</td>
<td className="w-[76px] pr-3">
{!readOnly && (
<span className="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label={`Run ${lens.settings.name} now`}
title="Run now"
onClick={(event) => {
event.stopPropagation();
onRunNow(lens.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Play className="size-3.5" />
</button>
<span
aria-hidden="true"
className="inline-flex size-7 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground"
>
<Pencil className="size-3.5" />
</span>
</span>
)}
</td>
</tr>
{expanded &&
findings.map((finding, index) => {
const priority = finding.priority ?? "medium";
const last = index === findings.length - 1;
const runs = finding.occurrences.length;
return (
<tr
key={finding.id}
onClick={() => onOpenFinding(lens, finding)}
aria-label={finding.title}
className={cn(ROW, "h-12 cursor-pointer")}
>
<td className="pl-2" title={finding.suggestion ? `Fix: ${finding.suggestion}` : undefined}>
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="relative h-12 w-6 shrink-0">
<span
className={cn("absolute left-3 top-0 w-px bg-border", last ? "h-1/2" : "h-full")}
/>
<span className="absolute top-1/2 left-3 h-px w-3 bg-border" />
</span>
<span aria-hidden="true" className="w-4 shrink-0" />
<CircleDot
aria-hidden="true"
className={cn("size-4 shrink-0", PRIORITY_COLOR[priority])}
/>
<TwoLine
meta={`${priority} priority · ${findingAgents(lens, finding).join(", ")}`}
title={finding.title}
/>
</span>
</td>
<td className="px-3 text-right text-[12px] text-muted-foreground">
{runs} {runs === 1 ? "run" : "runs"}
</td>
<td />
<td
className="px-3 text-right text-[12px] text-muted-foreground"
title={formatActivityTimestamp(finding.last_seen)}
>
{agoLabel(Date.parse(finding.last_seen), now)}
</td>
<td className="pr-5">
<ChevronRight aria-hidden="true" className="ml-auto size-3.5 text-muted-foreground/60" />
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>

View file

@ -6,10 +6,16 @@ import { ApiError } from "@/lib/http/client";
import { apiClient } from "@/components/networking";
import { lensKeys } from "../api/queries";
import { InvestigationsView } from "./InvestigationsView";
import { LensPreviewContext } from "../LensPreviewButton";
import { briefMarkdown } from "../model/findings";
import { findingKey } from "../model/inbox";
import { runTime } from "../model/format";
import { type Lens, type Finding } from "../model/types";
const withPreview = (ui: React.ReactElement, open: () => void) => (
<LensPreviewContext.Provider value={{ target: document.body, open }}>{ui}</LensPreviewContext.Provider>
);
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderProviders>[1]) {
return renderProviders(ui, { searchParams: window.location.search, ...options });
}
@ -285,11 +291,11 @@ it("offers the interactive demo without starting an investigation", async () =>
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: false };
return { traces: false, requests: false };
});
const onDemo = vi.fn();
const onPreview = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" onDemo={onDemo} />);
renderWithProviders(withPreview(<InvestigationsView accessToken="test" />, onPreview));
await user.click(await screen.findByRole("button", { name: "Preview sample" }));
expect(onDemo).toHaveBeenCalledOnce();
expect(onPreview).toHaveBeenCalledOnce();
expect(apiClient.post).not.toHaveBeenCalled();
});
@ -302,14 +308,14 @@ it("guides a first-time administrator into worker connection and lens setup", as
return { traces: true, requests: false, data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" onDemo={vi.fn()} />);
renderWithProviders(withPreview(<InvestigationsView accessToken="test" />, vi.fn()));
const guide = within(await screen.findByRole("region", { name: "Find what needs attention" }));
expect(apiClient.get).toHaveBeenCalledWith("/lens/activity/available", { accessToken: "test" });
expect(await guide.findByRole("link", { name: "View traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
expect(await guide.findByRole("button", { name: "Preview sample" })).toBeVisible();
expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible();
await user.click(guide.getByRole("button", { name: "Connect worker" }));
const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" }));
expect(connection.getByRole("button", { name: "Get install command" })).toBeVisible();
@ -502,7 +508,7 @@ it("allows request-only accounts to connect a worker without requiring agent tra
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
});
it("closes editing when browser navigation leaves the investigation", async () => {
it("reopens the edit dialog from a shared link and drops it from the URL on cancel", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
@ -510,20 +516,47 @@ it("closes editing when browser navigation leaves the investigation", async () =
if (path === "/lens/agents") return [];
return { data: [] };
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Edit investigation" }));
expect(await screen.findByRole("dialog")).toBeVisible();
await act(async () => {
window.history.replaceState({}, "", "/lens/");
window.dispatchEvent(new PopStateEvent("popstate"));
renderWithProviders(<InvestigationsView accessToken="test" />, {
searchParams: `?lens=${lens.id}&dialog=edit`,
onUrlUpdate,
});
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const url = new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
expect(url.has("dialog")).toBe(false);
expect(url.get("lens")).toBe(lens.id);
expect(apiClient.request).not.toHaveBeenCalled();
});
it("resolves every investigation's copy of a merged finding from one row", async () => {
it("reopens a finding and a results section from shared links", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return lens.jobs;
return { data: [] };
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
const { unmount } = renderWithProviders(<InvestigationsView accessToken="test" />, {
searchParams: `?issue=${encodeURIComponent(findingKey(lens, issue))}`,
onUrlUpdate,
});
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getAllByText(issue.title)[0]).toBeVisible();
await user.click(within(sheet).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString)).has("issue")).toBe(false);
unmount();
renderWithProviders(<InvestigationsView accessToken="test" />, { searchParams: `?lens=${lens.id}&section=checks` });
expect(await screen.findByRole("tab", { name: "Criteria", selected: true })).toBeVisible();
});
it("lists each finding under the investigation that owns it and resolves only that copy", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } };
@ -536,15 +569,14 @@ it("resolves every investigation's copy of a merged finding from one row", async
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const rows = await screen.findAllByRole("row", { name: issue.title });
expect(rows).toHaveLength(1);
await user.click(rows[0]);
expect(rows).toHaveLength(2);
await user.click(await screen.findByRole("button", { name: `Hide findings for ${lens.settings.name}` }));
const remaining = screen.getByRole("row", { name: issue.title });
expect(remaining.previousElementSibling).toBe(screen.getByRole("row", { name: twin.settings.name }));
await user.click(remaining);
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(2));
const resolved = vi
.mocked(apiClient.patch)
.mock.calls.map(([path]) => path)
.sort();
expect(resolved).toEqual(["/lens/lens/findings/issue", "/lens/twin/findings/issue"]);
await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(1));
expect(vi.mocked(apiClient.patch).mock.calls[0][0]).toBe("/lens/twin/findings/issue");
});
it("lists investigations without edit or run controls for read-only viewers", async () => {
@ -556,7 +588,7 @@ it("lists investigations without edit or run controls for read-only viewers", as
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" view="investigations" readOnly />);
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
const row = await screen.findByRole("row", { name: lens.settings.name });
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
await user.click(row);
@ -583,7 +615,7 @@ it("shows the actual saved failure and run context without opening backend logs"
expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument();
});
it("keeps a merged finding open to retry when one investigation's update fails", async () => {
it("keeps a finding open to retry when its update fails", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const twin: Lens = { ...lens, id: "twin", settings: { ...lens.settings, name: "Twin reviews" } };
@ -598,7 +630,8 @@ it("keeps a merged finding open to retry when one investigation's update fails",
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("row", { name: issue.title }));
await user.click(await screen.findByRole("button", { name: `Hide findings for ${lens.settings.name}` }));
await user.click(screen.getByRole("row", { name: issue.title }));
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
expect(await screen.findByText("Twin reviews could not be updated")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeVisible();

View file

@ -13,10 +13,9 @@ import { ReadinessBanner } from "./ReadinessBanner";
import { RequestEvidenceSheet } from "./RequestEvidenceSheet";
import { useNow } from "@/hooks/useNow";
import { useLensDemo } from "../LensDemoContext";
import { useDialogRoute, useIssueRoute, useLensRoute } from "../route";
import { useEffect, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FindingSheet } from "./FindingSheet";
@ -25,50 +24,38 @@ import { InvestigationSetupDialog } from "../setup/InvestigationSetupDialog";
import { WorkerDialog } from "../setup/worker/WorkerDialog";
import { useAnalysisKeyInfo } from "../setup/worker/AnalysisKeyDetails";
import { InvestigationList } from "./InvestigationList";
import { HeaderActions } from "./HeaderActions";
import { HeaderActions } from "../HeaderActions";
import { RunNowDialog } from "./detail/RunNowDialog";
import { FindingsInbox } from "./FindingsInbox";
import { findingAgents, sampledExecutions, type InboxRow } from "../model/inbox";
import { findFinding, findingAgents, findingKey, sampledExecutions } from "../model/inbox";
import { WatchAllBanner } from "./WatchAllBanner";
import { MonitoringDialog } from "../setup/MonitoringDialog";
import { InvestigationsWelcome } from "./InvestigationsWelcome";
import { workerConnected, readiness } from "../model/status";
import { type Finding, type Settings } from "../model/types";
import { type Finding, type Lens, type Settings } from "../model/types";
export function InvestigationsView({
view = "findings",
active = true,
accessToken,
readOnly = false,
onDemo,
}: {
view?: "findings" | "investigations";
active?: boolean;
accessToken: string;
readOnly?: boolean;
onDemo?: () => void;
}) {
const demo = useLensDemo();
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 { dialog, target: dialogTarget, openDialog, closeDialog } = useDialogRoute();
const { issueKey, setIssueKey } = useIssueRoute();
const workerSetup = dialog === "workers";
const now = useNow(2000);
const query = useQuery(lensQueries.list(api, !!demo, workerSetup));
const query = useQuery(lensQueries.list(api, workerSetup));
const models = useQuery(lensQueries.models(api));
const modelDetails = useQuery(lensQueries.modelDetails(api));
const [agentsAsOf] = useState(() => new Date().toISOString());
const agents = useQuery(lensQueries.agents(api, agentsAsOf, "traces"));
const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
const [demoSelected, setDemoSelected] = useState<string | null>(null);
const selected = demo ? demoSelected : liveSelected;
const setSelected = demo ? setDemoSelected : setLiveSelected;
const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null);
const [peek, setPeek] = useState(false);
const [peeked, setPeeked] = useState<InboxRow | null>(null);
const [runNowId, setRunNowId] = useState<string | null>(null);
const { lensId: selected, setLensId: setSelected } = useLensRoute();
const [skipped, setSkipped] = useState<readonly { id: string; name: string; reason: string }[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -79,7 +66,12 @@ export function InvestigationsView({
const showReadiness = loaded && !showEmpty && !readOnly;
const missingSelection = !!selected && loaded;
const lens = lenses.find((e) => e.id === selected);
const results = useInvestigationResults(accessToken, lens);
const targetLens = dialogTarget ? lenses.find((e) => e.id === dialogTarget) : lens;
const editing = dialog === "new" || dialog === "edit" || dialog === "duplicate" ? dialog : null;
const setupMode = editing === "new" || targetLens ? editing : null;
const peeked = issueKey ? findFinding(lenses, issueKey) : undefined;
const resultsLens = peeked?.lens ?? lens;
const results = useInvestigationResults(accessToken, resultsLens);
const {
finding,
sampledRuns,
@ -95,23 +87,18 @@ export function InvestigationsView({
reset: resetResults,
} = results;
const selectLens = (id: string | null) => {
void setSelected(id);
setSelected(id);
resetResults();
};
useEffect(() => {
const restoreLocation = () => {
setEditing(null);
resetResults();
};
window.addEventListener("popstate", restoreLocation);
return () => window.removeEventListener("popstate", restoreLocation);
}, [resetResults]);
const setEditing = (mode: "new" | "edit" | "duplicate") => openDialog(mode);
const setWorkerSetup = (open: boolean) => (open ? openDialog("workers") : closeDialog());
const setMonitoring = (open: boolean) => (open ? openDialog("monitoring") : closeDialog());
const connected = query.data?.workers?.some((w) => workerConnected(w, now)) ?? false;
const activeWorkers = query.data?.workers.filter((worker) => !worker.revoked) ?? [];
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(api, loaded, !!demo));
const activity = useQuery(lensQueries.activity(api, loaded));
const { tracesReady, requestsReady, activityReady, ready } = readiness(
activity.data,
activity.error,
@ -120,9 +107,9 @@ export function InvestigationsView({
);
const setupSettings = () => {
if (editing === "new") return undefined;
if (editing === "duplicate" && lens)
return { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false };
return lens?.settings;
if (editing === "duplicate" && targetLens)
return { ...targetLens.settings, name: `${targetLens.settings.name} copy`, enabled: false };
return targetLens?.settings;
};
const refresh = () => {
void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
@ -143,53 +130,37 @@ export function InvestigationsView({
}
};
const save = async (settings: Settings) => {
if (editing === "edit" && (!lens || lens.id !== selected))
throw new Error("Reopen the investigation to edit its settings");
if (editing === "edit" && !targetLens) throw new Error("Reopen the investigation to edit its settings");
if (editing !== "edit" && !ready)
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
const saved = await saveLens.mutateAsync({ id: editing === "edit" ? lens?.id : undefined, settings });
selectLens(peek ? null : saved.id);
setPeek(false);
setEditing(null);
const saved = await saveLens.mutateAsync({ id: editing === "edit" ? targetLens?.id : undefined, settings });
closeDialog();
if (!dialogTarget) selectLens(saved.id);
refresh();
};
const editFromTable = (id: string) => {
setPeek(true);
selectLens(id);
setEditing("edit");
};
const openFinding = (row: InboxRow) => {
setPeek(true);
setPeeked(row);
selectLens(row.sources[0].lens.id);
};
const openFinding = (owner: Lens, picked: Finding) => setIssueKey(findingKey(owner, picked));
const closeFinding = () => {
setFindingId(null);
setPeeked(null);
if (!peek) return;
setPeek(false);
selectLens(null);
setIssueKey(null);
};
const changeFinding = async (status: Finding["status"], reason: string) => {
if (peeked) {
const saved = await update((current) =>
Promise.all(peeked.sources.map((s) => current.reviewFinding(s.lens.id, s.finding.id, status, reason))),
);
const saved = await update((current) => current.reviewFinding(peeked.lens.id, peeked.finding.id, status, reason));
if (saved) closeFinding();
return;
}
if (!lens || !finding) return;
await update((current) => current.reviewFinding(lens.id, finding.id, status, reason));
};
const sheetFinding = peeked ? peeked.sources[0].finding : finding;
const sheetRuns = peeked ? peeked.sources.flatMap((s) => sampledExecutions(s.lens)) : sampledRuns;
const sheetFinding = peeked ? peeked.finding : finding;
const sheetRuns = peeked ? sampledExecutions(peeked.lens) : sampledRuns;
const detailAgents = lens && finding ? findingAgents(lens, finding) : [];
const sheetAgents = peeked ? peeked.agents : detailAgents;
const sheetAgents = peeked ? findingAgents(peeked.lens, peeked.finding) : detailAgents;
const onDetail = !!selected && !peek;
const onDetail = !!selected;
const showDetailNav = onDetail && !showEmpty;
const showTables = !onDetail && lenses.length > 0;
const showMissing = missingSelection && !lens && !peek;
const showMissing = missingSelection && !lens;
return (
<section aria-label="Investigations" className="flex w-full min-w-0 flex-1 flex-col gap-3">
{showDetailNav && (
@ -220,7 +191,7 @@ export function InvestigationsView({
}}
onConnect={() => setWorkerSetup(true)}
onCreate={() => setEditing("new")}
onDemo={activity.isSuccess && !ready ? onDemo : undefined}
showPreview={activity.isSuccess && !ready}
/>
)}
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} className="py-2 text-xs" />}
@ -253,21 +224,18 @@ export function InvestigationsView({
/>
</HeaderActions>
)}
{view === "findings" ? (
<FindingsInbox lenses={lenses} onOpen={openFinding} />
) : (
<InvestigationList
lenses={lenses}
connected={connected}
readOnly={readOnly}
onEdit={editFromTable}
onRunNow={(id) => setRunNowId(id)}
/>
)}
<InvestigationList
lenses={lenses}
connected={connected}
readOnly={readOnly}
onEdit={(id) => openDialog("edit", id)}
onRunNow={(id) => openDialog("run_now", id)}
onOpenFinding={openFinding}
/>
</div>
)}
{showMissing && <InvestigationMissing selectLens={selectLens} />}
{lens && !peek && (
{lens && (
<InvestigationDetail
lens={lens}
readOnly={readOnly}
@ -275,16 +243,16 @@ export function InvestigationsView({
busy={busy}
setEditing={setEditing}
setMonitoring={setMonitoring}
onRunNow={() => openDialog("run_now")}
update={update}
connected={connected}
results={results}
agents={Array.isArray(agents.data) ? agents.data : []}
/>
)}
{editing && (
{setupMode && (
<InvestigationSetupDialog
ready={ready}
mode={editing}
mode={setupMode}
initial={setupSettings()}
defaultModel={defaultModel}
defaultSource={!tracesReady && requestsReady ? "requests" : "traces"}
@ -293,25 +261,19 @@ export function InvestigationsView({
modelsLoading={models.isLoading}
modelsError={models.error?.message}
accessToken={accessToken}
onClose={() => {
setEditing(null);
if (peek) {
setPeek(false);
selectLens(null);
}
}}
onClose={closeDialog}
onSave={save}
/>
)}
{runNowId && (
{dialog === "run_now" && targetLens && (
<RunNowDialog
lens={lenses.find((l) => l.id === runNowId) ?? lenses[0]}
lens={targetLens}
agents={Array.isArray(agents.data) ? agents.data : []}
busy={busy}
onClose={() => setRunNowId(null)}
onClose={closeDialog}
onRun={async (request) => {
await update((api) => api.startRun(runNowId, request));
setRunNowId(null);
await update((api) => api.startRun(targetLens.id, request));
closeDialog();
}}
/>
)}
@ -319,23 +281,16 @@ export function InvestigationsView({
<WorkerDialog
accessToken={accessToken}
workers={query.data?.workers ?? []}
onClose={() => setWorkerSetup(false)}
onClose={closeDialog}
onChanged={refresh}
onReady={
ready && showEmpty
? () => {
setWorkerSetup(false);
setEditing("new");
}
: undefined
}
onReady={ready && showEmpty ? () => openDialog("new") : undefined}
/>
)}
{monitoring && lens && (
{dialog === "monitoring" && lens && (
<MonitoringDialog
settings={lens.settings}
ready={ready}
onClose={() => setMonitoring(false)}
onClose={closeDialog}
onSave={async (settings) => {
await saveLens.mutateAsync({ id: lens.id, settings });
refresh();
@ -355,7 +310,7 @@ export function InvestigationsView({
setEvidence(value);
}}
/>
{lens && target?.source === "traces" && (
{resultsLens && target?.source === "traces" && (
<TraceSheet
open={!!evidence}
traceId={target.id}

View file

@ -15,7 +15,7 @@ export function InvestigationsWelcome({
onConnect,
onCreate,
onRetry,
onDemo,
showPreview = false,
}: {
tracesReady: boolean;
requestsReady?: boolean;
@ -26,7 +26,7 @@ export function InvestigationsWelcome({
onConnect: () => void;
onCreate: () => void;
onRetry: () => void;
onDemo?: () => void;
showPreview?: boolean;
}) {
const activityReady = tracesReady || requestsReady;
const workerReady = activityReady && connected;
@ -41,7 +41,7 @@ export function InvestigationsWelcome({
});
return (
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
{onDemo && <LensPreviewButton onClick={onDemo} />}
{showPreview && <LensPreviewButton />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention
</h2>

View file

@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { RunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { TraceSheet } from "./TraceSheet";
vi.mock("@/components/view_logs/TraceView/TraceDrawer", () => ({
RunView: ({ selection }: { selection: RunSelection }) => <div data-testid="run-view">{selection.spanId}</div>,
}));
describe("TraceSheet", () => {
it("opens another finding's evidence on its own step", () => {
const props = { open: true, traceId: "trace-1", accessToken: "t", onClose: vi.fn() };
const { rerender } = render(<TraceSheet {...props} initialSpanId="step-a" />);
expect(screen.getByTestId("run-view")).toHaveTextContent("step-a");
rerender(<TraceSheet {...props} initialSpanId="step-b" />);
expect(screen.getByTestId("run-view")).toHaveTextContent("step-b");
});
});

View file

@ -1,4 +1,5 @@
import { RunView } from "@/components/view_logs/TraceView/TraceDrawer";
import { useLocalRunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
export function TraceSheet({
@ -29,11 +30,11 @@ export function TraceSheet({
<SheetDescription>Recorded agent steps and evidence</SheetDescription>
</SheetHeader>
{open && (
<RunView
<SheetRun
key={`${traceId}:${traceRef}:${initialSpanId}`}
traceId={traceId}
traceRef={traceRef}
initialSpanId={initialSpanId}
initialSpanId={initialSpanId ?? null}
accessToken={accessToken}
onBack={onClose}
/>
@ -42,3 +43,22 @@ export function TraceSheet({
</Sheet>
);
}
function SheetRun({
traceId,
traceRef,
initialSpanId,
accessToken,
onBack,
}: {
traceId: string;
traceRef?: string;
initialSpanId: string | null;
accessToken: string;
onBack: () => void;
}) {
const selection = useLocalRunSelection(initialSpanId);
return (
<RunView traceId={traceId} traceRef={traceRef} selection={selection} accessToken={accessToken} onBack={onBack} />
);
}

View file

@ -7,8 +7,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { RunsTab } from "./RunsTab";
import { InvestigationProgress } from "../InvestigationProgress";
import { StepFeed } from "../StepFeed";
import { RunNowDialog } from "./RunNowDialog";
import { useState } from "react";
import { InvestigationSummary } from "./InvestigationSummary";
import { InvestigationFailure } from "./InvestigationFailure";
import { scopeLabel, sourceLabels } from "../../model/format";
@ -27,10 +25,10 @@ export function InvestigationDetail({
busy,
setEditing,
setMonitoring,
onRunNow,
update,
connected,
results,
agents = [],
}: {
lens: Lens;
readOnly: boolean;
@ -38,12 +36,11 @@ export function InvestigationDetail({
busy: boolean;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
onRunNow: () => void;
update: (write: LensWrite) => Promise<unknown>;
connected: boolean;
results: ReturnType<typeof useInvestigationResults>;
agents?: readonly string[];
}) {
const [runNow, setRunNow] = useState(false);
const {
active,
job,
@ -91,7 +88,7 @@ export function InvestigationDetail({
setEditing={setEditing}
setMonitoring={setMonitoring}
update={update}
onRunNow={() => setRunNow(true)}
onRunNow={onRunNow}
/>
)}
</div>
@ -110,18 +107,6 @@ export function InvestigationDetail({
/>
)}
{active && <StepFeed job={active} />}
{runNow && (
<RunNowDialog
lens={lens}
agents={agents}
busy={busy}
onClose={() => setRunNow(false)}
onRun={async (request) => {
await update((api) => api.startRun(lens.id, request));
setRunNow(false);
}}
/>
)}
{job?.error && <InvestigationFailure job={job} connected={connected} />}
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">

View file

@ -1,24 +1,36 @@
"use client";
import { useQueryStates } from "nuqs";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLensDemo } from "../LensDemoContext";
import { useLensApi } from "../services";
import { lensQueries } from "../api/queries";
import { evidenceTarget, mergeFeedback, sortedFindings } from "../model/findings";
import type { Job, Lens } from "../model/types";
import { RESULT_PARSERS, RESULT_TABS, type ResultTab } from "../route";
const isResultTab = (tab: string): tab is ResultTab => (RESULT_TABS as readonly string[]).includes(tab);
export function useInvestigationResults(accessToken: string, lens: Lens | undefined) {
const api = useLensApi(accessToken);
const demo = useLensDemo();
const [batchId, setBatchId] = useState("latest");
const [params, setParams] = useQueryStates(RESULT_PARSERS);
const { run: batchId, section: tab, finding: findingId, finding_status: filter, kind } = params;
const [historyOffset, setHistoryOffset] = useState(0);
const [tab, setTab] = useState("findings");
const [findingId, setFindingId] = useState<string | null>(null);
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(api, { lensId: lens?.id, historyOffset, demo: !!demo }));
const setBatchId = useCallback((run: string) => void setParams({ run }), [setParams]);
const setTab = useCallback((section: string) => void (isResultTab(section) && setParams({ section })), [setParams]);
const setFindingId = useCallback(
(finding: string | null) => void setParams({ finding }, { history: "push" }),
[setParams],
);
const setFilter = useCallback((finding_status: string) => void setParams({ finding_status }), [setParams]);
const setKind = useCallback((next: typeof kind) => void setParams({ kind: next }), [setParams]);
const evidence = params.evidence === null ? null : { id: params.evidence, span: params.evidence_span ?? "" };
const setEvidence = useCallback(
(next: { id: string; span: string } | null) =>
void setParams({ evidence: next?.id ?? null, evidence_span: next?.span || null }, { history: "push" }),
[setParams],
);
const history = useQuery(lensQueries.history(api, { lensId: lens?.id, historyOffset }));
const historical = useQuery(lensQueries.run(api, lens?.id, batchId));
const { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings } = runSnapshot(
lens,
@ -27,11 +39,7 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi
history.data,
);
const finding = batchFindings.find((f) => f.id === findingId);
const openBatch = (id: string) => {
setBatchId(id);
setTab("findings");
setFindingId(null);
};
const openBatch = (id: string) => void setParams({ run: id, section: null, finding: null });
const active = lens?.jobs?.find((j) => j.status === "queued" || j.status === "running");
const visibleFindings = sortedFindings(
batchFindings.filter((f) => (filter === "all" || f.status === filter) && f.kind === kind),
@ -42,11 +50,9 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi
const evidenceInput = { lensId: lens?.id, evidenceId: evidence?.id, requestOffset, source: target?.source };
const requestEvidence = useQuery(lensQueries.evidence(api, evidenceInput));
const reset = useCallback(() => {
setBatchId("latest");
setHistoryOffset(0);
setFindingId(null);
setTab("findings");
}, []);
void setParams({ run: null, finding: null, section: null });
}, [setParams]);
return {
batchId,

View file

@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import {
ALL_AGENTS,
UNKNOWN_AGENT,
filterInbox,
findFinding,
findingAgents,
inboxAgents,
inboxRows,
findingKey,
openFindings,
modelsUsed,
scheduleLabel,
stepLine,
@ -70,50 +69,35 @@ const ranked = (id: string, title: string, priority: Finding["priority"], lastSe
last_seen: lastSeen,
});
describe("inboxRows", () => {
it("merges the same problem for the same agent across investigations into one row", () => {
const rows = inboxRows([
lens("a", "support", [finding({ id: "1", occurrences: ["run-1", "run-2"] })]),
lens("b", "support", [finding({ id: "2", title: "tool errors swallowed ", occurrences: ["run-2", "run-3"] })]),
]);
expect(rows).toHaveLength(1);
expect(rows[0].sources).toHaveLength(2);
expect(rows[0].runs).toBe(3);
});
it("keeps the same title for different agents as separate problems", () => {
expect(inboxRows([lens("a", "support", [finding({})]), lens("b", "billing", [finding({})])])).toHaveLength(2);
});
it("takes the most urgent priority and latest sighting across merged findings", () => {
const [row] = inboxRows([
lens("a", "support", [finding({ priority: "low", last_seen: "2026-10-03T00:00:00Z" })]),
lens("b", "support", [finding({ priority: "high", last_seen: "2026-10-01T00:00:00Z" })]),
]);
expect(row.priority).toBe("high");
expect(row.lastSeen).toBe("2026-10-03T00:00:00Z");
});
describe("openFindings", () => {
it("leaves out resolved, dismissed and pattern findings", () => {
const rows = inboxRows([
lens("a", "support", [
finding({ id: "1", status: "resolved", title: "a" }),
finding({ id: "2", status: "dismissed", title: "b" }),
finding({ id: "3", kind: "pattern", title: "c" }),
]),
const owner = lens("a", "support", [
finding({ id: "1", status: "resolved", title: "a" }),
finding({ id: "2", status: "dismissed", title: "b" }),
finding({ id: "3", kind: "pattern", title: "c" }),
finding({ id: "4", title: "d" }),
]);
expect(rows).toEqual([]);
expect(openFindings(owner).map((f) => f.title)).toEqual(["d"]);
});
it("orders by priority, then by most recent", () => {
const rows = inboxRows([
lens("a", "x", [
finding(ranked("1", "old high", "high", "2026-10-01T00:00:00Z")),
finding(ranked("2", "new low", "low", "2026-10-03T00:00:00Z")),
finding(ranked("3", "new high", "high", "2026-10-02T00:00:00Z")),
]),
const owner = lens("a", "x", [
finding(ranked("1", "old high", "high", "2026-10-01T00:00:00Z")),
finding(ranked("2", "new low", "low", "2026-10-03T00:00:00Z")),
finding(ranked("3", "new high", "high", "2026-10-02T00:00:00Z")),
]);
expect(rows.map((r) => r.title)).toEqual(["new high", "old high", "new low"]);
expect(openFindings(owner).map((f) => f.title)).toEqual(["new high", "old high", "new low"]);
});
});
describe("findFinding", () => {
it("resolves a key to the investigation that owns the finding, even when ids repeat across investigations", () => {
const a = lens("a", "support", [finding({ id: "same", title: "from a" })]);
const b = lens("b", "support", [finding({ id: "same", title: "from b" })]);
const found = findFinding([a, b], findingKey(b, b.findings[0]));
expect(found?.lens.id).toBe("b");
expect(found?.finding.title).toBe("from b");
expect(findFinding([a, b], "missing:same")).toBeUndefined();
});
});
@ -130,20 +114,6 @@ const step = (overrides: Partial<Step>): Step =>
...overrides,
}) as Step;
describe("filterInbox", () => {
const rows = inboxRows([
lens("a", "support", [finding({ id: "1", title: "one", priority: "high" })]),
lens("b", "billing", [finding({ id: "2", title: "two", priority: "low" })]),
]);
it("narrows to one agent and one priority, and lists every agent once", () => {
expect(filterInbox(rows, { agent: "billing", priority: "all" }).map((r) => r.title)).toEqual(["two"]);
expect(filterInbox(rows, { agent: ALL_AGENTS, priority: "high" }).map((r) => r.title)).toEqual(["one"]);
expect(filterInbox(rows, { agent: "billing", priority: "high" })).toEqual([]);
expect(inboxAgents(rows)).toEqual(["billing", "support"]);
});
});
describe("step feed helpers", () => {
it("summarizes a model call with its token count and cost", () => {
expect(stepLine(step({ prompt_tokens: 1000, completion_tokens: 200, cost: 0.0031 }))).toBe(

View file

@ -3,23 +3,6 @@ import type { Finding, Job, Lens } from "./types";
export type Step = Job["steps"][number];
export type Priority = NonNullable<Finding["priority"]>;
export interface InboxRow {
key: string;
title: string;
agents: readonly string[];
priority: Priority;
suggestion: string;
runs: number;
lastSeen: string;
sources: readonly { lens: Lens; finding: Finding }[];
}
export interface InboxFilter {
agent: string;
priority: Priority | "all";
}
export const ALL_AGENTS = "all";
export const UNKNOWN_AGENT = "unknown agent";
const priorityRank = { high: 0, medium: 1, low: 2 } as const;
@ -43,42 +26,22 @@ function groupBy<T>(items: readonly T[], key: (item: T) => string): Map<string,
}, new Map<string, T[]>());
}
export function inboxRows(lenses: readonly Lens[]): InboxRow[] {
const open = lenses.flatMap((lens) =>
lens.findings
.filter((f) => f.status === "open" && f.kind === "issue")
.map((finding) => ({ lens, finding, agents: findingAgents(lens, finding) })),
);
const grouped = groupBy(open, ({ agents, finding }) => `${agents.join(",")}::${finding.title.trim().toLowerCase()}`);
return [...grouped.entries()]
.map(([key, sources]): InboxRow => {
const best = sources.reduce((a, b) =>
priorityRank[a.finding.priority ?? "medium"] <= priorityRank[b.finding.priority ?? "medium"] ? a : b,
);
return {
key,
title: best.finding.title,
agents: best.agents,
priority: best.finding.priority ?? "medium",
suggestion: best.finding.suggestion,
runs: new Set(sources.flatMap((s) => s.finding.occurrences)).size,
lastSeen: sources.map((s) => s.finding.last_seen).reduce((a, b) => (Date.parse(a) > Date.parse(b) ? a : b)),
sources: sources.map(({ lens, finding }) => ({ lens, finding })),
};
})
export function openFindings(lens: Lens): Finding[] {
return lens.findings
.filter((f) => f.status === "open" && f.kind === "issue")
.sort(
(a, b) => priorityRank[a.priority] - priorityRank[b.priority] || Date.parse(b.lastSeen) - Date.parse(a.lastSeen),
(a, b) =>
priorityRank[a.priority ?? "medium"] - priorityRank[b.priority ?? "medium"] ||
Date.parse(b.last_seen) - Date.parse(a.last_seen),
);
}
export function filterInbox(rows: readonly InboxRow[], { agent, priority }: InboxFilter): InboxRow[] {
const agentMatches = (row: InboxRow) => agent === ALL_AGENTS || row.agents.includes(agent);
const priorityMatches = (row: InboxRow) => priority === "all" || row.priority === priority;
return rows.filter((row) => agentMatches(row) && priorityMatches(row));
}
export const findingKey = (lens: Lens, finding: Finding) => `${lens.id}:${finding.id}`;
export function inboxAgents(rows: readonly InboxRow[]): string[] {
return [...new Set(rows.flatMap((row) => row.agents))].sort();
export function findFinding(lenses: readonly Lens[], key: string) {
return lenses
.flatMap((lens) => lens.findings.map((finding) => ({ lens, finding })))
.find(({ lens, finding }) => findingKey(lens, finding) === key);
}
export function stepLine(step: Step): string {

View file

@ -0,0 +1,103 @@
"use client";
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { useCallback } from "react";
import { OPEN_TRACE_PARSERS, RUN_FILTER_PARSERS } from "@/components/view_logs/TraceView/traceRouting";
export const LENS_TABS = { traces: "Traces", investigations: "Investigations" } as const;
export type LensTab = keyof typeof LENS_TABS;
const lensTabs = Object.keys(LENS_TABS) as LensTab[];
export const RESULT_TABS = ["findings", "checks", "runs", "activity"] as const;
export type ResultTab = (typeof RESULT_TABS)[number];
export const FINDING_KINDS = ["issue", "pattern"] as const;
export const LENS_DIALOGS = ["new", "edit", "duplicate", "run_now", "workers", "monitoring"] as const;
export type LensDialog = (typeof LENS_DIALOGS)[number];
const LENS_PARSERS = {
tab: parseAsStringLiteral(lensTabs),
lens: parseAsString,
demo: parseAsBoolean.withDefault(false),
};
const ISSUE_PARSERS = { issue: parseAsString };
const LIST_PARSERS = { search: parseAsString.withDefault("") };
export const RESULT_PARSERS = {
run: parseAsString.withDefault("latest"),
section: parseAsStringLiteral(RESULT_TABS).withDefault("findings"),
finding: parseAsString,
finding_status: parseAsString.withDefault("open"),
kind: parseAsStringLiteral(FINDING_KINDS).withDefault("issue"),
evidence: parseAsString,
evidence_span: parseAsString,
};
/** A dialog opened from a list row names its investigation in `target`; inside a detail view it acts on `lens`. */
const DIALOG_PARSERS = { dialog: parseAsStringLiteral(LENS_DIALOGS), target: parseAsString };
const SESSION_PARSERS = {
...LENS_PARSERS,
...OPEN_TRACE_PARSERS,
...RUN_FILTER_PARSERS,
...ISSUE_PARSERS,
...LIST_PARSERS,
...RESULT_PARSERS,
...DIALOG_PARSERS,
};
const nulls = <K extends string>(keys: readonly K[]) =>
Object.fromEntries(keys.map((key) => [key, null])) as Record<K, null>;
/** Switching the sample session clears every Lens key but the tab so ids never cross between live and sample data. */
const CLEARED_SESSION = nulls(Object.keys(SESSION_PARSERS).filter((key) => key !== "tab"));
const CLEARED_RESULTS = nulls(Object.keys(RESULT_PARSERS));
export interface LensRoute {
readonly tab: LensTab | null;
readonly lensId: string | null;
readonly demo: boolean;
setTab(tab: LensTab): void;
setLensId(lensId: string | null): void;
setDemo(demo: boolean): void;
}
/** Lens navigation lives in the URL, sample session included, so any view is a shareable link. */
export function useLensRoute(): LensRoute {
const [{ tab, lens, demo }, setParams] = useQueryStates(SESSION_PARSERS, { history: "push" });
const setTab = useCallback((next: LensTab) => void setParams({ tab: next }), [setParams]);
const setLensId = useCallback(
(next: string | null) => void setParams({ ...CLEARED_RESULTS, lens: next }),
[setParams],
);
const setDemo = useCallback(
(next: boolean) => void setParams(next ? { ...CLEARED_SESSION, demo: true } : CLEARED_SESSION),
[setParams],
);
return { tab, lensId: lens, demo, setTab, setLensId, setDemo };
}
export function useIssueRoute() {
const [{ issue }, setParams] = useQueryStates(ISSUE_PARSERS);
return {
issueKey: issue,
setIssueKey: useCallback(
(next: string | null) => void setParams({ issue: next }, { history: "push" }),
[setParams],
),
};
}
export function useListSearchRoute(): [string, (search: string) => void] {
const [{ search }, setParams] = useQueryStates(LIST_PARSERS);
return [search, useCallback((next: string) => void setParams({ search: next }), [setParams])];
}
export function useDialogRoute() {
const [{ dialog, target }, setParams] = useQueryStates(DIALOG_PARSERS, { history: "push" });
const openDialog = useCallback(
(next: LensDialog, targetId: string | null = null) => void setParams({ dialog: next, target: targetId }),
[setParams],
);
const closeDialog = useCallback(() => void setParams({ dialog: null, target: null }), [setParams]);
return { dialog, target, openDialog, closeDialog };
}

View file

@ -2,25 +2,19 @@
import { createContext, useContext, useMemo } from "react";
import { apiClient } from "@/components/networking";
import { liveTracesApi, type TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import 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 const LensApiContext = createContext<LensApi | null>(null);
/** Without a provider, falls back to the live HTTP implementation for the caller's token. */
export function useLensApi(accessToken: string): LensApi {
const provided = useContext(LensServicesContext)?.lens;
const provided = useContext(LensApiContext);
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

@ -1,12 +1,12 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import moment from "moment";
import { useMemo, useState } from "react";
import { AgentTracesSection } from "./AgentTracesSection";
import { useTracesLive } from "./tracesApi";
import { useRangeHoursRouting } from "./traceRouting";
const DEFAULT_RANGE_HOURS = 24;
const TIME_FORMAT = "YYYY-MM-DDTHH:mm";
export default function AgentTracesPage({
@ -14,16 +14,14 @@ export default function AgentTracesPage({
isActive = true,
readOnly = false,
canMintTracingKey = false,
onDemo,
}: {
accessToken: string;
isActive?: boolean;
readOnly?: boolean;
canMintTracingKey?: boolean;
onDemo?: () => void;
}) {
const demo = useLensDemo();
const [rangeHours, setRangeHours] = useState(DEFAULT_RANGE_HOURS);
const sourceLive = useTracesLive();
const [rangeHours, setRangeHours] = useRangeHoursRouting();
const [live, setLive] = useState(true);
const [anchor, setAnchor] = useState(() => moment());
const { startTime, endTime } = useMemo(
@ -47,10 +45,9 @@ export default function AgentTracesPage({
startTime={startTime}
endTime={endTime}
isCustomDate={false}
isLiveTail={live && !demo}
isLiveTail={live && sourceLive}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
onDemo={onDemo}
timeControls={{ rangeHours, onRangeHoursChange: changeRange, onLiveChange: setLive }}
/>
</div>

View file

@ -1,13 +1,15 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { mockAllIsIntersecting, setupIntersectionMocking } from "react-intersection-observer/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import traceList from "./__fixtures__/trace_list.json";
import { LensPreviewContext } from "@/components/lens/LensPreviewButton";
import AgentTracesPage from "./AgentTracesPage";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
import { AgentTracesSection, filterRuns, type TimeControls } from "./AgentTracesSection";
import type { TracePage, TraceSummary } from "./traceTypes";
vi.mock("../../networking", () => ({
@ -32,17 +34,21 @@ import { agentTraceListCall, apiClient } from "../../networking";
const runs = (traceList as TracePage).data as TraceSummary[];
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const renderSection = () =>
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
onDemo={vi.fn()}
/>,
<LensPreviewContext.Provider value={{ target: document.body, open: vi.fn() }}>
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>
</LensPreviewContext.Provider>,
);
// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone.
@ -80,11 +86,45 @@ describe("AgentTracesSection", () => {
y: 0,
toJSON: () => ({}),
} as DOMRect);
setupIntersectionMocking(vi.fn);
testQueryClient.clear();
vi.mocked(agentTraceListCall).mockReset();
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
});
it("loads the next page only once the list scrolls near its end, then stops at the last page", async () => {
vi.mocked(agentTraceListCall)
.mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" })
.mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: null });
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
act(() => mockAllIsIntersecting(false));
expect(agentTraceListCall).toHaveBeenCalledOnce();
act(() => mockAllIsIntersecting(true));
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(2));
expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toMatchObject({ cursor: "next" });
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledTimes(2);
});
it("waits for the user before paging when filters hide every loaded run", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall)
.mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" })
.mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: "later" });
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "no-such-run" } });
act(() => mockAllIsIntersecting(true));
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Load older runs" }));
await waitFor(() => expect(agentTraceListCall).toHaveBeenCalledTimes(2));
act(() => mockAllIsIntersecting(true));
expect(agentTraceListCall).toHaveBeenCalledTimes(2);
});
it("keeps the original time window and loaded rows when another page fails", async () => {
const user = userEvent.setup();
const now = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-10-01T00:00Z"));
@ -94,9 +134,11 @@ describe("AgentTracesSection", () => {
const first = vi.mocked(agentTraceListCall).mock.calls[0][0];
now.mockReturnValue(Date.parse("2026-10-01T01:00Z"));
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Please try again", 403, {}));
await user.click(screen.getByRole("button", { name: "Load more" }));
act(() => mockAllIsIntersecting(true));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load more runs");
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledTimes(2);
expect(vi.mocked(agentTraceListCall).mock.calls[1][0]).toEqual({ ...first, cursor: "next" });
vi.mocked(agentTraceListCall).mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: null });
await user.click(screen.getByRole("button", { name: "Retry" }));
@ -346,6 +388,7 @@ describe("AgentTracesSection", () => {
);
expect(agentCell(cliRun)).toHaveTextContent(/^Claude Code$/);
expect(within(plainRun).queryByRole("img", { hidden: true })).not.toBeInTheDocument();
expect(within(plainRun).getByTestId("span-icon")).toBeInTheDocument();
expect(agentCell(plainRun)).toHaveTextContent((runs[2].agent_names ?? [runs[2].service]).join(", "));
});
@ -396,6 +439,87 @@ describe("AgentTracesSection", () => {
expect(screen.getByTestId("run-view")).toHaveTextContent(`run ${runs[1].trace_id}`);
});
it("narrows the list to the zoom window named in the URL and clears it on request", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const startMs = Date.parse(runs[0].start_time);
const inWindow = runs.filter((run) => Math.abs(Date.parse(run.start_time) - startMs) <= 1);
const onUrlUpdate = vi.fn();
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-30T00:00Z"
endTime="2026-10-01T00:00Z"
isCustomDate
isLiveTail={false}
/>,
{ searchParams: `?from=${startMs - 1}&to=${startMs + 1}`, onUrlUpdate },
);
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(inWindow.length));
expect(inWindow.length).toBeLessThan(runs.length);
fireEvent.click(screen.getByRole("button", { name: "Clear time zoom" }));
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length));
expect(lastUrl(onUrlUpdate).has("from")).toBe(false);
});
it("opens the run named by ?trace= even when it is outside the loaded list, and clears it on close", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?trace=older-than-the-list&trace_ref=ref-9", onUrlUpdate },
);
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
expect(within(drawer).getByTestId("run-view")).toHaveTextContent("run older-than-the-list");
expect(screen.getByRole("button", { name: "Next trace (J)" })).toBeDisabled();
const rows = await screen.findAllByTestId("agent-trace-row");
expect(rows.every((row) => row.getAttribute("aria-selected") === "false")).toBe(true);
fireEvent.click(rows[1]);
await waitFor(() => expect(lastUrl(onUrlUpdate).get("trace")).toBe(runs[1].trace_id));
expect(lastUrl(onUrlUpdate).get("trace_ref")).toBe(runs[1].trace_ref ?? null);
expect(onUrlUpdate.mock.lastCall?.[0].options.history).toBe("push");
fireEvent.keyDown(window, { key: "Escape" });
await waitFor(() => expect(lastUrl(onUrlUpdate).has("trace")).toBe(false));
expect(rows[1]).toHaveAttribute("aria-selected", "false");
});
it("reads the search, agent and status filters from the URL and writes changes back", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
const failed = filterRuns(runs, "", "all", "error");
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?status=error", onUrlUpdate },
);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
expect(screen.getByRole("combobox", { name: "Filter by status" })).toHaveTextContent("Failed");
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: failed[0].trace_id } });
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe(failed[0].trace_id));
expect(lastUrl(onUrlUpdate).get("status")).toBe("error");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "All status");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("status")).toBe(false));
});
it("plots every loaded run on the timeline", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderWindowed();
@ -516,6 +640,21 @@ describe("AgentTracesPage", () => {
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
});
it("opens on the range named by ?hours= and writes a new preset back", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=168", onUrlUpdate });
await screen.findByTestId("runs-table");
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 7d");
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByText("Total 1h")).toBeInTheDocument();
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
});
it("asks the proxy for the last 24 hours by default", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderWithProviders(<AgentTracesPage accessToken="sk-test" />);

View file

@ -1,20 +1,29 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import moment from "moment";
import { useMemo, useState } from "react";
import { HeaderActions } from "@/components/lens/HeaderActions";
import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { RunDrawer } from "./RunDrawer";
import { ALL_AGENTS, RunsToolbar, type RunStatusFilter } from "./RunsToolbar";
import {
type TraceRef,
traceKey,
traceRefOf,
useOpenTraceRouting,
useRunFilterRouting,
useZoomRouting,
} from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { previewText, traceAgentNames } from "./traceUtils";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { ActiveDot } from "./ActiveDot";
import { TracingSetupCard } from "./TracingSetupCard";
import { useTracesLive } from "./tracesApi";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
/** Client-side search (input text or trace id) plus agent / status filters over the loaded runs. */
@ -35,8 +44,6 @@ export function filterRuns(
});
}
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] =>
runs.filter((run) => {
const t = moment(run.start_time).valueOf();
@ -58,11 +65,8 @@ interface AgentTracesSectionProps {
isLiveTail: boolean;
/** Page-owned time range + live state; when given, the toolbar shows the range / Live control group. */
timeControls?: TimeControls;
/** Called when a run opens / closes, so the page can hide its own header while a run fills the view. */
onRunOpenChange?: (open: boolean) => void;
readOnly?: boolean;
canMintTracingKey?: boolean;
onDemo?: () => void;
}
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
@ -101,18 +105,14 @@ export function AgentTracesSection({
isCustomDate,
isLiveTail,
timeControls,
onRunOpenChange,
readOnly = false,
canMintTracingKey = false,
onDemo,
}: AgentTracesSectionProps) {
const demo = useLensDemo();
const [openTrace, setOpenTrace] = useState<TraceSummary | null>(null);
const [query, setQuery] = useState("");
const [agent, setAgent] = useState(ALL_AGENTS);
const [status, setStatus] = useState<RunStatusFilter>("all");
const live = useTracesLive();
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
const { query, agent, status, setQuery, setAgent, setStatus } = useRunFilterRouting();
const [showSetup, setShowSetup] = useState(false);
const [zoom, setZoom] = useState<TimeWindow | null>(null);
const [zoom, setZoom] = useZoomRouting();
const [rangeChanged, setRangeChanged] = useState(false);
const traceQuery = { accessToken, startTime, endTime, isCustomDate, isLiveTail, enabled: isActive };
const traces = useAgentTraces(traceQuery);
@ -144,16 +144,11 @@ export function AgentTracesSection({
apply(hours);
};
const openRun = (trace: TraceSummary | null) => {
setOpenTrace(trace);
onRunOpenChange?.(trace !== null);
};
const openSentTrace = (trace: TraceSummary) => {
setShowSetup(false);
setRangeChanged(true);
checkTraces();
openRun(trace);
openRun(traceRefOf(trace));
};
const setupProps = {
accessToken,
@ -164,11 +159,10 @@ export function AgentTracesSection({
checking: traces.isFetching,
};
if (setup.disabledDetail != null)
return <TracingSetupCard detail={setup.disabledDetail} onDemo={onDemo} {...setupProps} />;
if (setup.disabledDetail != null) return <TracingSetupCard detail={setup.disabledDetail} {...setupProps} />;
// Onboarding only on the first, default view; an empty range the user picked keeps its controls.
if (checkHistory && !history.error && history.data === false)
return <TracingSetupCard detail={null} onDemo={onDemo} {...setupProps} />;
return <TracingSetupCard detail={null} {...setupProps} />;
if (showSetup) {
return (
<div>
@ -184,14 +178,25 @@ export function AgentTracesSection({
);
}
const toggleRun = (trace: TraceSummary | null) =>
openRun(trace !== null && openTrace !== null && runKey(trace) === runKey(openTrace) ? null : trace);
const toggleRun = (trace: TraceSummary | null) => {
const ref: TraceRef | null = trace && traceRefOf(trace);
openRun(ref !== null && openTrace !== null && traceKey(ref) === traceKey(openTrace) ? null : ref);
};
return (
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
{checkHistory && <TraceHistoryError history={history} />}
<TracesReceived received={setup.received} />
<RunDrawer trace={openTrace} runs={runs} accessToken={accessToken} onSelect={openRun} />
<TracingSetupAction live={live} isActive={isActive} onClick={() => setShowSetup(true)} />
<RunDrawer
trace={openTrace}
runs={runs}
accessToken={accessToken}
selection={selection}
onSelect={openRun}
fullScreen={fullScreen}
onFullScreenChange={setFullScreen}
/>
<RunsToolbar
query={query}
agent={agent}
@ -201,19 +206,13 @@ export function AgentTracesSection({
onAgentChange={setAgent}
onStatusChange={setStatus}
>
{!demo && (
<Button variant="outline" size="sm" onClick={() => setShowSetup(true)} className="shrink-0 gap-1.5">
<ActiveDot />
Set up tracing
</Button>
)}
{timeControls && (
<TimeRangeControls
range={zoom ?? range}
rangeHours={timeControls.rangeHours}
onRangeHoursChange={(hours) => changeRange(hours, timeControls.onRangeHoursChange)}
live={isLiveTail}
showLive={!demo}
showLive={live}
onLiveChange={timeControls.onLiveChange}
onRefresh={() => {
setZoom(null);
@ -233,7 +232,7 @@ export function AgentTracesSection({
onRetry={traces.hasMore ? traces.loadMore : traces.refetch}
onLoadMore={traces.loadMore}
onOpenTrace={toggleRun}
selectedKey={openTrace === null ? null : runKey(openTrace)}
selectedKey={openTrace === null ? null : traceKey(openTrace)}
/>
<RunsFooter
count={runs.length}
@ -246,9 +245,20 @@ export function AgentTracesSection({
);
}
function TracingSetupAction({ live, isActive, onClick }: { live: boolean; isActive: boolean; onClick: () => void }) {
if (!live || !isActive) return null;
return (
<HeaderActions>
<Button variant="outline" size="sm" onClick={onClick} className="shrink-0 gap-1.5">
<ActiveDot />
Set up tracing
</Button>
</HeaderActions>
);
}
function TracesReceived({ received }: { received: boolean }) {
const demo = useLensDemo();
if (!received || demo) return null;
if (!received) return null;
return (
<p role="status" className="border-b px-3 py-3 text-sm text-emerald-700 dark:text-emerald-400">
Traces received. Select a run to inspect it.

View file

@ -1,11 +1,14 @@
"use client";
import { ArrowDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
import { SpanIcon } from "./SpanIcon";
import { StatusMark } from "./StatusMark";
import { FrameworkLogo, traceFramework } from "./TraceFramework";
import type { TraceSummary } from "./traceTypes";
@ -32,6 +35,8 @@ export const formatCost = (cost: number): string => {
const firstLine = (text: string): string => text.split("\n")[0] ?? text;
const TH = "px-3 font-medium";
const PREFETCH_MARGIN = "0px 0px 480px 0px";
const PLACEHOLDER_ROWS = [0, 1, 2];
const TH_NUM = "px-3 text-right font-medium";
const TD_NUM = "px-3 text-right font-mono tabular-nums text-muted-foreground";
@ -42,13 +47,29 @@ function AgentCell({ run }: { run: TraceSummary }) {
return (
<td className="px-3 text-muted-foreground" title={title}>
<div className="flex min-w-0 items-center gap-1.5">
{framework && <FrameworkLogo framework={framework} />}
{framework ? <FrameworkLogo framework={framework} /> : <SpanIcon type="agent" size="sm" />}
<span className="truncate">{agents || framework?.label || "—"}</span>
</div>
</td>
);
}
function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void }) {
return (
<tr ref={rowRef} aria-hidden data-testid="runs-placeholder" className="h-9 border-b border-border/60">
<td className="px-3">
<div className="h-2.5 w-28 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
<td className="px-3">
<div className="h-2.5 w-24 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
<td className="px-3" colSpan={7}>
<div className="h-2.5 w-2/5 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
</td>
</tr>
);
}
/** Devtool-dense runs list: one row per agent run, newest first. */
export function AgentTracesTable({
traces,
@ -61,15 +82,30 @@ export function AgentTracesTable({
onOpenTrace,
selectedKey = null,
}: AgentTracesTableProps) {
const isEmpty = !isLoading && !error && traces.length === 0;
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
const canContinue = settled && hasMore;
const autoContinue = canContinue && traces.length > 0;
const [scroller, setScroller] = useState<HTMLDivElement | null>(null);
const { ref: tailRef, inView: nearTail } = useInView({ root: scroller, rootMargin: PREFETCH_MARGIN });
useEffect(() => {
if (nearTail && autoContinue && !isFetching) onLoadMore();
}, [nearTail, autoContinue, isFetching, onLoadMore]);
return (
<div className="min-h-0 flex-1 overflow-auto" data-testid="runs-table">
<table aria-label="Agent runs" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<div ref={setScroller} className="min-h-0 flex-1 overflow-auto" data-testid="runs-table">
<table
aria-label="Agent runs"
aria-busy={isFetching}
className="w-full min-w-[900px] table-fixed border-collapse text-left"
>
<thead className="sticky top-0 z-sticky bg-[color-mix(in_oklab,var(--muted)_40%,var(--card))]">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={`w-[190px] ${TH}`}>
<span className="inline-flex items-center gap-1">
<th className={`w-[170px] ${TH}`}>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
Time <ArrowDown className="size-2.5" />
<span className="font-normal normal-case tracking-normal text-muted-foreground/70">
{localTimeZoneAbbreviation()}
</span>
</span>
</th>
<th className={`w-[160px] ${TH}`}>Agent</th>
@ -97,10 +133,10 @@ export function AgentTracesTable({
)}
>
<td
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"
className="px-3 font-mono text-[11px] whitespace-nowrap tabular-nums text-muted-foreground"
title={formatActivityTimestamp(run.start_time)}
>
{formatActivityTimestamp(run.start_time)}
{formatRunTimestamp(run.start_time)}
</td>
<AgentCell run={run} />
<td className="px-3">
@ -140,6 +176,8 @@ export function AgentTracesTable({
</td>
</tr>
))}
{autoContinue &&
PLACEHOLDER_ROWS.map((row) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />)}
</tbody>
</table>
{isLoading && <div className="py-16 text-center text-[12px] text-muted-foreground">Loading runs…</div>}
@ -155,16 +193,17 @@ export function AgentTracesTable({
)}
</div>
)}
{isEmpty && (
<div className="py-16 text-center text-[12px] text-muted-foreground">No runs match these filters.</div>
)}
{hasMore && (
<div className="border-t border-border/60 px-3 py-2">
<Button size="xs" variant="ghost" disabled={isFetching} onClick={onLoadMore}>
{isFetching ? "Loading…" : "Load more"}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-[12px] text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && (
<div className="py-16 text-center text-[12px] text-muted-foreground">No runs match these filters.</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
"use client";
import { useTracesApi } from "@/components/lens/services";
import { useTracesApi } from "./tracesApi";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { useState } from "react";

View file

@ -1,9 +1,11 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type ComponentProps, useState } from "react";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import { DetailPane } from "./DetailPane";
import type { SpanTab } from "./traceRouting";
import { absoluteTime, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import type { GroupRowData, SpanRowData } from "./traceTree";
import type { Span, SpanDetail, SpanErrorPage, Trace } from "./traceTypes";
@ -152,8 +154,13 @@ const spanRow = (s: Span): SpanRowData => ({
collapsed: false,
});
function LocalDetailPane(props: Omit<ComponentProps<typeof DetailPane>, "spanTab" | "onSpanTabChange">) {
const [spanTab, setSpanTab] = useState<SpanTab>("content");
return <DetailPane {...props} spanTab={spanTab} onSpanTabChange={setSpanTab} />;
}
const renderPane = (row: SpanRowData | GroupRowData) =>
renderWithProviders(<DetailPane trace={trace} row={row} accessToken="sk-test" onClose={vi.fn()} />);
renderWithProviders(<LocalDetailPane trace={trace} row={row} accessToken="sk-test" onClose={vi.fn()} />);
describe("DetailPane", () => {
beforeEach(() => {

View file

@ -1,8 +1,6 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { PanelRightClose } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
@ -13,25 +11,25 @@ import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
import { IdChip } from "./IdChip";
import { RequestDetail } from "./RequestDetail";
import { SpanIcon } from "./SpanIcon";
import { agentHandoffText } from "./TraceDrawer";
import { useTracesApi } from "./tracesApi";
import { SPAN_TABS, type SpanTab } from "./traceRouting";
import type { GroupRowData, TreeRow } from "./traceTree";
import type { Span, SpanType, Trace } from "./traceTypes";
import { fmtMs, fmtTok } from "./traceUtils";
interface DetailPaneProps {
interface SpanTabProps {
spanTab: SpanTab;
onSpanTabChange: (tab: SpanTab) => void;
}
interface DetailPaneProps extends SpanTabProps {
trace: Trace;
row: TreeRow | undefined;
accessToken: string;
onClose: () => void;
}
type Tab = "content" | "request" | "attributes";
const TABS: { id: Tab; label: string }[] = [
{ id: "content", label: "Content" },
{ id: "request", label: "Request" },
{ id: "attributes", label: "Attributes" },
];
const TAB_LABELS: Record<SpanTab, string> = { content: "Content", request: "Request", attributes: "Attributes" };
function PaneHeader({
type,
@ -95,15 +93,16 @@ function SpanPane({
trace,
span,
accessToken,
spanTab: tab,
onSpanTabChange,
onClose,
}: {
}: SpanTabProps & {
trace: Trace;
span: Span;
accessToken: string;
onClose: () => void;
}) {
const [tab, setTab] = useState<Tab>("content");
const demo = useLensDemo();
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, span.span_id, trace.summary.trace_ref);
const traceId = trace.summary.trace_id;
const detailQuery = useSpanDetail(
accessToken,
@ -114,7 +113,7 @@ function SpanPane({
const tokens = span.input_tokens + span.output_tokens;
return (
<aside
className="flex h-full min-w-0 animate-view-fade-in flex-col bg-background pt-4 text-[13px] text-foreground motion-reduce:animate-none"
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Span details"
>
<PaneHeader
@ -125,12 +124,12 @@ function SpanPane({
idValue={span.span_id}
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => setTab(value as Tab)} className="min-h-0 flex-1 gap-0">
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
<div className="shrink-0 border-b px-5">
<TabsList variant="line" aria-label="Span detail sections" className="h-10 gap-4 px-0">
{TABS.map((t) => (
<TabsTrigger key={t.id} value={t.id} className="flex-none px-0 text-xs">
{t.label}
{SPAN_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="flex-none px-0 text-xs">
{TAB_LABELS[id]}
</TabsTrigger>
))}
</TabsList>
@ -151,15 +150,7 @@ function SpanPane({
</TabsContent>
</Tabs>
<PaneFooter>
<CopyButton
value={
demo
? demo.copyTrace(traceId, span.span_id)
: agentHandoffText(traceId, span.span_id, trace.summary.trace_ref)
}
label="Copy step"
copiedLabel={demo ? "Step copied" : "Command copied"}
/>
<CopyButton value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
<Meta label="time" value={fmtMs(span.duration_ms)} />
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
@ -179,13 +170,24 @@ function GroupMetric({ label, value }: { label: string; value: string }) {
}
/** ×N group: rollup of every invocation plus the first failure's message. */
function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; onClose: () => void }) {
const demo = useLensDemo();
function GroupPane({
trace,
row,
accessToken,
onClose,
}: {
trace: Trace;
row: GroupRowData;
accessToken: string;
onClose: () => void;
}) {
const tokens = row.members.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
const firstFailure = row.members.find((m) => m.status === "error" && m.error);
const sample = (firstFailure ?? row.members[0]).span_id;
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
return (
<aside
className="flex h-full min-w-0 animate-view-fade-in flex-col bg-background pt-4 text-[13px] text-foreground motion-reduce:animate-none"
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Group details"
>
<PaneHeader
@ -216,21 +218,14 @@ function GroupPane({ trace, row, onClose }: { trace: Trace; row: GroupRowData; o
)}
</div>
<PaneFooter>
<CopyButton
value={
demo
? demo.copyTrace(trace.summary.trace_id, (firstFailure ?? row.members[0]).span_id)
: agentHandoffText(trace.summary.trace_id, (firstFailure ?? row.members[0]).span_id)
}
label="Copy group sample"
/>
<CopyButton value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
</PaneFooter>
</aside>
);
}
/** Right pane of the run view: switches on the selected tree row. */
export function DetailPane({ trace, row, accessToken, onClose }: DetailPaneProps) {
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background text-[13px] text-muted-foreground">
@ -238,6 +233,17 @@ export function DetailPane({ trace, row, accessToken, onClose }: DetailPaneProps
</div>
);
}
if (row.kind === "group") return <GroupPane key={row.id} trace={trace} row={row} onClose={onClose} />;
return <SpanPane key={row.id} trace={trace} span={row.span} accessToken={accessToken} onClose={onClose} />;
if (row.kind === "group")
return <GroupPane key={row.id} trace={trace} row={row} accessToken={accessToken} onClose={onClose} />;
return (
<SpanPane
key={row.id}
trace={trace}
span={row.span}
accessToken={accessToken}
spanTab={spanTab}
onSpanTabChange={onSpanTabChange}
onClose={onClose}
/>
);
}

View file

@ -1,8 +1,9 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
import { type RunSelection, traceRefOf, useOpenTraceRouting } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
vi.mock("./TraceDrawer", () => ({
@ -29,6 +30,37 @@ const run = (trace_id: string): TraceSummary => ({
spend: null,
});
const selection: RunSelection = {
spanId: null,
view: "steps",
spanTab: "content",
stepQuery: "",
errorsOnly: false,
selectSpan: vi.fn(),
setView: vi.fn(),
setSpanTab: vi.fn(),
setStepQuery: vi.fn(),
setErrorsOnly: vi.fn(),
};
function RoutedDrawer({ runs }: { runs: TraceSummary[] }) {
const { trace, openTrace, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
return (
<RunDrawer
trace={trace}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={openTrace}
fullScreen={fullScreen}
onFullScreenChange={setFullScreen}
/>
);
}
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const mockReducedMotion = (reduce: boolean) =>
vi
.spyOn(window, "matchMedia")
@ -56,12 +88,10 @@ describe("RunDrawer", () => {
window.localStorage.clear();
});
it("fills the page across trace navigation and restores the resized drawer width", () => {
it("fills the page across trace navigation and restores the resized drawer width", async () => {
const runs = [run("a"), run("b")];
const onSelect = vi.fn();
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={onSelect} />,
);
const onUrlUpdate = vi.fn();
renderWithProviders(<RoutedDrawer runs={runs} />, { searchParams: "?trace=a", onUrlUpdate });
const drawer = screen.getByRole("complementary", { name: "Trace details" });
fireEvent.keyDown(screen.getByRole("separator", { name: "Resize trace panel" }), { key: "ArrowLeft" });
const resizedWidth = drawer.style.width;
@ -71,10 +101,9 @@ describe("RunDrawer", () => {
expect(drawer).toHaveStyle({ width: "100%" });
expect(screen.queryByRole("separator", { name: "Resize trace panel" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next trace (J)" }));
expect(onSelect).toHaveBeenCalledWith(runs[1]);
rerender(<RunDrawer trace={runs[1]} runs={runs} accessToken="sk" onSelect={onSelect} />);
expect(drawer).toHaveStyle({ width: "100%" });
expect(screen.getByTestId("run-view")).toHaveTextContent("run b");
await waitFor(() => expect(lastUrl(onUrlUpdate).get("fullscreen")).toBe("true"));
fireEvent.click(screen.getByRole("button", { name: "Exit full screen" }));
expect(drawer).toHaveStyle({ width: resizedWidth });
@ -82,28 +111,29 @@ describe("RunDrawer", () => {
expect(window.localStorage.getItem("litellm.agentTraces.drawerWidth")).toBe(storedWidth);
});
it.each([false, true])("reopens at the saved width after closing full screen (reduced motion: %s)", (reduce) => {
mockReducedMotion(reduce);
const runs = [run("a"), run("b")];
const onSelect = vi.fn();
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={onSelect} />,
);
fireEvent.keyDown(screen.getByRole("separator", { name: "Resize trace panel" }), { key: "ArrowLeft" });
const savedWidth = screen.getByRole("complementary", { name: "Trace details" }).style.width;
fireEvent.click(screen.getByRole("button", { name: "Enter full screen" }));
rerender(<RunDrawer trace={null} runs={runs} accessToken="sk" onSelect={onSelect} />);
rerender(<RunDrawer trace={runs[1]} runs={runs} accessToken="sk" onSelect={onSelect} />);
expect(screen.getByRole("complementary", { name: "Trace details" })).toHaveStyle({ width: savedWidth });
expect(screen.getByRole("button", { name: "Enter full screen" })).toBeVisible();
expect(screen.getByRole("separator", { name: "Resize trace panel" })).toBeVisible();
it("opens full screen from a shared link and drops it from the URL on close", async () => {
const onUrlUpdate = vi.fn();
renderWithProviders(<RoutedDrawer runs={[run("a")]} />, { searchParams: "?trace=a&fullscreen=true", onUrlUpdate });
expect(screen.getByRole("complementary", { name: "Trace details" })).toHaveStyle({ width: "100%" });
fireEvent.click(screen.getByRole("button", { name: "Close trace (Esc)" }));
await waitFor(() => expect(lastUrl(onUrlUpdate).has("trace")).toBe(false));
expect(lastUrl(onUrlUpdate).has("fullscreen")).toBe(false);
});
it.each(["Close (Esc)", "Close trace (Esc)"])("closes a full-screen trace using %s", (name) => {
const runs = [run("a")];
const onSelect = vi.fn();
renderWithProviders(<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={onSelect} />);
fireEvent.click(screen.getByRole("button", { name: "Enter full screen" }));
renderWithProviders(
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen
onFullScreenChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name }));
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
@ -111,8 +141,17 @@ describe("RunDrawer", () => {
it("keeps Escape available to close a full-screen trace", () => {
const runs = [run("a")];
const onSelect = vi.fn();
renderWithProviders(<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={onSelect} />);
fireEvent.click(screen.getByRole("button", { name: "Enter full screen" }));
renderWithProviders(
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen
onFullScreenChange={vi.fn()}
/>,
);
fireEvent.keyDown(window, { key: "Escape" });
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
@ -121,10 +160,28 @@ describe("RunDrawer", () => {
mockReducedMotion(true);
const runs = [run("a")];
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={vi.fn()} />,
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={vi.fn()}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>,
);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBeInTheDocument();
rerender(<RunDrawer trace={null} runs={runs} accessToken="sk" onSelect={vi.fn()} />);
rerender(
<RunDrawer
trace={null}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={vi.fn()}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>,
);
expect(screen.queryByRole("complementary", { name: "Trace details" })).not.toBeInTheDocument();
});
@ -132,9 +189,27 @@ describe("RunDrawer", () => {
mockReducedMotion(false);
const runs = [run("a")];
const { rerender } = renderWithProviders(
<RunDrawer trace={runs[0]} runs={runs} accessToken="sk" onSelect={vi.fn()} />,
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={vi.fn()}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>,
);
rerender(
<RunDrawer
trace={null}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={vi.fn()}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>,
);
rerender(<RunDrawer trace={null} runs={runs} accessToken="sk" onSelect={vi.fn()} />);
expect(screen.getByRole("complementary", { name: "Trace details" })).toHaveClass("animate-trace-drawer-out");
});
});

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/cva.config";
import { RunView } from "./TraceDrawer";
import { type RunSelection, type TraceRef, traceKey, traceRefOf } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { ignoresLetterShortcut } from "../letterShortcut";
@ -139,32 +140,39 @@ function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onTog
}
interface RunDrawerProps {
trace: TraceSummary | null;
trace: TraceRef | null;
runs: readonly TraceSummary[];
accessToken: string;
onSelect: (trace: TraceSummary | null) => void;
selection: RunSelection;
onSelect: (trace: TraceRef | null) => void;
fullScreen: boolean;
onFullScreenChange: (fullScreen: boolean) => void;
}
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
/** Right-side drawer over the runs list: resizable, keeps the list clickable, swaps runs in place. */
export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps) {
export function RunDrawer({
trace,
runs,
accessToken,
selection,
onSelect,
fullScreen,
onFullScreenChange,
}: RunDrawerProps) {
const [width, setWidth] = useDrawerWidth();
const [fullScreen, setFullScreen] = useState(false);
if (trace === null && fullScreen) setFullScreen(false);
const [lastShown, setLastShown] = useState<TraceSummary | null>(trace);
const [lastShown, setLastShown] = useState<TraceRef | null>(trace);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (trace !== null && trace !== lastShown) setLastShown(trace);
const shown = trace ?? lastShown;
const closing = trace === null && shown !== null;
if (trace !== null && exitedKey !== null) setExitedKey(null);
if (closing && exitedKey !== runKey(shown) && prefersReducedMotion()) setExitedKey(runKey(shown));
if (closing && exitedKey !== traceKey(shown) && prefersReducedMotion()) setExitedKey(traceKey(shown));
const index = trace === null ? -1 : runs.findIndex((run) => runKey(run) === runKey(trace));
const index = trace === null ? -1 : runs.findIndex((run) => traceKey(traceRefOf(run)) === traceKey(trace));
const step = useCallback(
(delta: number) => {
const next = runs[index + delta];
if (next) onSelect(next);
if (next) onSelect(traceRefOf(next));
},
[runs, index, onSelect],
);
@ -188,14 +196,14 @@ export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps
return () => window.removeEventListener("keydown", onKeyDown);
}, [trace, step, onSelect]);
if (shown === null || (closing && exitedKey === runKey(shown))) return null;
if (shown === null || (closing && exitedKey === traceKey(shown))) return null;
return (
<aside
aria-label="Trace details"
data-testid="run-drawer"
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (closing && event.target === event.currentTarget) setExitedKey(runKey(shown));
if (closing && event.target === event.currentTarget) setExitedKey(traceKey(shown));
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
@ -220,7 +228,7 @@ export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps
</span>
)}
<div className="ml-auto flex items-center gap-1">
<FullScreenButton fullScreen={fullScreen} onToggle={() => setFullScreen((current) => !current)} />
<FullScreenButton fullScreen={fullScreen} onToggle={() => onFullScreenChange(!fullScreen)} />
<HeaderButton label="Close trace (Esc)" onClick={() => onSelect(null)}>
<X className="size-4" />
</HeaderButton>
@ -228,9 +236,10 @@ export function RunDrawer({ trace, runs, accessToken, onSelect }: RunDrawerProps
</div>
<div className="flex min-h-0 flex-1 flex-col">
<RunView
key={runKey(shown)}
traceId={shown.trace_id}
traceRef={shown.trace_ref}
key={traceKey(shown)}
traceId={shown.traceId}
traceRef={shown.traceRef}
selection={selection}
accessToken={accessToken}
onBack={() => onSelect(null)}
embedded

View file

@ -1,8 +1,10 @@
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ComponentProps } from "react";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import { RunView } from "./TraceDrawer";
import { useOpenTraceRouting } from "./traceRouting";
import { TraceConversation } from "./TraceConversation";
import type { SpanDetail, Trace } from "./traceTypes";
import research from "./__fixtures__/research_trace.json";
@ -14,6 +16,11 @@ vi.mock("../../networking", () => ({
}));
import { agentTraceCall, agentTraceSpanCall } from "../../networking";
function RoutedRunView(props: Omit<ComponentProps<typeof RunView>, "selection">) {
const { selection } = useOpenTraceRouting();
return <RunView {...props} selection={selection} />;
}
const root = { ...research.spans[0], span_id: "root", parent_span_id: null };
const tool = { ...root, span_id: "tool", name: "read_file", parent_span_id: "root", type: "tool", start_offset_ms: 1 };
const trace = { ...research, spans: [root, tool] } as Trace;
@ -41,7 +48,9 @@ describe("TraceConversation", () => {
it("switches to a readable transcript and opens the exact tool step from it", async () => {
const user = userEvent.setup();
renderWithProviders(<RunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />);
renderWithProviders(
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
);
const conversationTab = await screen.findByRole("tab", { name: "Conversation", selected: false });
await user.click(conversationTab);
expect(conversationTab).toHaveAttribute("aria-selected", "true");

View file

@ -5,7 +5,7 @@ import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { CopyButton } from "./CopyButton";
import { useTracesApi } from "@/components/lens/services";
import { useTracesApi } from "./tracesApi";
import { Button } from "@/components/ui/button";
import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation";
import { ErrorBlock } from "./DetailContent";

View file

@ -1,4 +1,4 @@
import { screen, within, waitFor, act } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { focusManager, onlineManager } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import researchTrace from "./__fixtures__/research_trace.json";
import swarmTrace from "./__fixtures__/swarm_trace.json";
import { agentHandoffText, initialRunSelection, RunView } from "./TraceDrawer";
import type { ComponentProps } from "react";
import { initialRunSelection, RunView } from "./TraceDrawer";
import { useOpenTraceRouting } from "./traceRouting";
import { agentHandoffText } from "./tracesApi";
import type { Span } from "./traceTypes";
import type { Trace } from "./traceTypes";
import { traceDisplayName } from "./traceUtils";
@ -37,9 +40,14 @@ import { agentTraceCall } from "../../networking";
const swarm = swarmTrace as Trace;
const research = researchTrace as Trace;
function RoutedRunView(props: Omit<ComponentProps<typeof RunView>, "selection">) {
const { selection } = useOpenTraceRouting();
return <RunView {...props} selection={selection} />;
}
const renderRun = (trace: Trace) => {
vi.mocked(agentTraceCall).mockResolvedValue(trace);
return renderWithProviders(<RunView traceId={trace.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
return renderWithProviders(<RoutedRunView traceId={trace.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
};
const rootSpanId = (trace: Trace): string => trace.spans.find((s) => s.parent_span_id === null)?.span_id ?? "";
@ -113,7 +121,7 @@ describe("RunView", () => {
const user = userEvent.setup();
vi.mocked(agentTraceCall).mockResolvedValue(research);
renderWithProviders(
<RunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
);
const root = rootSpanId(research);
@ -174,7 +182,7 @@ describe("RunView", () => {
.mockResolvedValueOnce(first)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(second);
renderWithProviders(<RunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
expect(await screen.findByText("Showing 1 of 2 steps")).toBeVisible();
const before = screen.getByRole("banner").textContent;
@ -216,7 +224,7 @@ describe("RunView", () => {
.mockRejectedValueOnce(new Error("Trace changed while paging; refresh the trace"))
.mockResolvedValueOnce(fresh)
.mockResolvedValueOnce(freshSecond);
renderWithProviders(<RunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "Load more steps" }));
expect(await screen.findByText("old-snapshot-tool")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Load more steps" }));
@ -256,7 +264,7 @@ describe("RunView", () => {
const user = userEvent.setup();
const onBack = vi.fn();
vi.mocked(agentTraceCall).mockRejectedValue(new Error("Traces are temporarily unavailable"));
renderWithProviders(<RunView traceId="big" accessToken="sk-test" onBack={onBack} />);
renderWithProviders(<RoutedRunView traceId="big" accessToken="sk-test" onBack={onBack} />);
expect(await screen.findByText("Traces are temporarily unavailable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /back to traces/i }));
@ -281,7 +289,7 @@ describe("RunView", () => {
);
renderRun({ ...research, spans: [root, ...children] });
const search = await screen.findByRole("textbox", { name: "Search steps" });
await user.type(search, "case 43");
fireEvent.change(search, { target: { value: "case 43" } });
expect(screen.getAllByRole("treeitem")).toHaveLength(1);
await user.click(screen.getByRole("treeitem"));
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", "case-43");
@ -290,12 +298,23 @@ describe("RunView", () => {
await user.click(screen.getByRole("button", { name: /^Errors/ }));
expect(screen.getAllByRole("treeitem")).toHaveLength(1);
expect(screen.getByRole("treeitem")).toHaveAttribute("data-row-id", "case-44");
await user.type(search, "not present");
fireEvent.change(search, { target: { value: "not present" } });
expect(screen.getByText("No matching steps")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Clear filters" }));
expect(screen.getAllByRole("treeitem").length).toBeGreaterThan(1);
});
it("restores the step search and errors filter from a shared link", async () => {
vi.mocked(agentTraceCall).mockResolvedValue(research);
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />, {
searchParams: "?steps_q=no+such+step&errors=true",
});
expect(await screen.findByRole("textbox", { name: "Search steps" })).toHaveValue("no such step");
expect(screen.getByText("No matching steps")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(screen.getAllByRole("treeitem").length).toBeGreaterThan(1);
});
it("does not navigate steps while typing or moving the search cursor", async () => {
const user = userEvent.setup();
renderRun(research);

View file

@ -1,6 +1,5 @@
"use client";
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesApi } from "@/components/lens/services";
import { type TraceHandoff, useTracesApi } from "./tracesApi";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy } from "lucide-react";
@ -12,7 +11,6 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { getProxyBaseUrl } from "../../networking";
import { DetailPane } from "./DetailPane";
import { IdChip } from "./IdChip";
import { formatCost } from "./AgentTracesTable";
@ -20,6 +18,7 @@ import { SpanIcon } from "./SpanIcon";
import { SpanTree } from "./SpanTree";
import { TraceConversation } from "./TraceConversation";
import { FrameworkLogo, traceFramework } from "./TraceFramework";
import type { RunSelection } from "./traceRouting";
import type { SpanTreeState, TreeRow } from "./traceTree";
import type { Trace } from "./traceTypes";
import {
@ -37,13 +36,6 @@ import {
} from "./traceUtils";
import { ignoresLetterShortcut } from "../letterShortcut";
/** What "Copy for agent" puts on the clipboard: a one-liner Claude Code / Codex can run. */
export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => {
const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}${traceRef ? `&trace_ref=${traceRef}` : ""}`;
const what = spanId ? "this step of a LiteLLM agent trace" : "this LiteLLM agent trace";
return `Read ${what} and explain what happened and why it failed:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`;
};
const INITIAL_STATE: SpanTreeState = {
hideFramework: true,
collapsedSpanIds: new Set(),
@ -86,8 +78,7 @@ const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
return next;
};
function CopyForAgent({ traceId, traceRef }: { traceId: string; traceRef?: string }) {
const demo = useLensDemo();
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
@ -99,14 +90,7 @@ function CopyForAgent({ traceId, traceRef }: { traceId: string; traceRef?: strin
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 rounded-md text-[12px] shadow-none"
onClick={async () =>
setCopied(
await copyToClipboard(
demo ? demo.copyTrace(traceId) : agentHandoffText(traceId, null, traceRef),
demo ? "Trace copied" : "Command copied",
),
)
}
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "Copied" : "Copy for agent"}
@ -142,7 +126,17 @@ function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boole
);
}
function RunHeader({ trace, onBack, embedded }: { trace: Trace; onBack: () => void; embedded: boolean }) {
function RunHeader({
trace,
handoff,
onBack,
embedded,
}: {
trace: Trace;
handoff: TraceHandoff;
onBack: () => void;
embedded: boolean;
}) {
const { summary } = trace;
const failed = summary.status === "error";
return (
@ -165,7 +159,7 @@ function RunHeader({ trace, onBack, embedded }: { trace: Trace; onBack: () => vo
</TabsTrigger>
</TabsList>
<div className="shrink-0">
<CopyForAgent traceId={summary.trace_id} traceRef={summary.trace_ref} />
<CopyForAgent handoff={handoff} />
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2">
@ -193,25 +187,20 @@ function ignoreStepKey(event: KeyboardEvent): boolean {
return event.defaultPrevented || ignoresLetterShortcut(event) || Boolean(control);
}
type TraceView = "steps" | "conversation";
interface RunBodyProps {
trace: Trace;
accessToken: string;
initialSpanId?: string;
selection: RunSelection;
embedded: boolean;
view: TraceView;
onViewChange: (view: TraceView) => void;
}
function RunBody({ trace, accessToken, initialSpanId, embedded, view, onViewChange }: RunBodyProps) {
function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
const spanKeys = embedded ? EMBEDDED_SPAN_KEYS : SPAN_KEYS;
const initial = useMemo(() => initialRunSelection(trace, initialSpanId), [trace, initialSpanId]);
const { view, selectSpan, setView, stepQuery: query, setStepQuery: setQuery, errorsOnly, setErrorsOnly } = selection;
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
const [state, setState] = useState<SpanTreeState>(initial.state);
const [selectedId, setSelectedId] = useState<string>(initial.selectedId);
const selectedId = selection.spanId ?? initial.selectedId;
const [detailOpen, setDetailOpen] = useState(true);
const [query, setQuery] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
const filtering = Boolean(query.trim()) || errorsOnly;
const treeRows = useMemo(() => buildTreeRows(trace.spans, state), [trace, state]);
@ -234,11 +223,11 @@ function RunBody({ trace, accessToken, initialSpanId, embedded, view, onViewChan
const select = useCallback(
(id: string) => {
setSelectedId(id);
selectSpan(id);
setDetailOpen(true);
setState((prev) => revealSpanInState(trace.spans, prev, id));
},
[trace.spans],
[trace.spans, selectSpan],
);
const toggleSpan = useCallback(
(id: string) => setState((prev) => ({ ...prev, collapsedSpanIds: toggle(prev.collapsedSpanIds, id) })),
@ -302,7 +291,7 @@ function RunBody({ trace, accessToken, initialSpanId, embedded, view, onViewChan
accessToken={accessToken}
onOpenStep={(id) => {
select(id);
onViewChange("steps");
setView("steps");
}}
/>
</TabsContent>
@ -350,8 +339,15 @@ function RunBody({ trace, accessToken, initialSpanId, embedded, view, onViewChan
}
/>
{detailOpen && (
<div className="min-h-0 min-w-0 animate-slide-left motion-reduce:animate-none">
<DetailPane trace={trace} row={selectedRow} accessToken={accessToken} onClose={() => setDetailOpen(false)} />
<div className="min-h-0 min-w-0">
<DetailPane
trace={trace}
row={selectedRow}
accessToken={accessToken}
spanTab={selection.spanTab}
onSpanTabChange={selection.setSpanTab}
onClose={() => setDetailOpen(false)}
/>
</div>
)}
</TabsContent>
@ -361,21 +357,20 @@ function RunBody({ trace, accessToken, initialSpanId, embedded, view, onViewChan
interface RunViewProps {
traceId: string;
traceRef?: string;
initialSpanId?: string;
selection: RunSelection;
accessToken: string;
onBack: () => void;
/** Rendered inside the side drawer: the drawer owns closing and sizing. */
embedded?: boolean;
}
function initialSpanMissing(trace: Trace | undefined, spanId?: string): boolean {
function selectedSpanMissing(trace: Trace | undefined, spanId: string | null): boolean {
return Boolean(spanId && trace && !trace.spans.some((span) => span.span_id === spanId));
}
export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack, embedded = false }: RunViewProps) {
export function RunView({ traceId, traceRef, selection, accessToken, onBack, embedded = false }: RunViewProps) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const [view, setView] = useState<TraceView>("steps");
const traceQueryOptions = {
queryKey: ["agentTrace", traceId, traceRef, accessToken],
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
@ -394,7 +389,7 @@ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack,
if (!pages?.length) return undefined;
return { ...pages[0], spans: pages.flatMap((page) => page.spans) };
}, [traceQuery.data]);
const seekingSpan = initialSpanMissing(trace, initialSpanId);
const seekingSpan = selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
@ -439,15 +434,20 @@ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack,
}
return (
<Tabs
value={view}
onValueChange={(value) => setView(value as TraceView)}
value={selection.view}
onValueChange={(value) => selection.setView(value as RunSelection["view"])}
className={cn(
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
embedded ? "min-h-0 animate-view-fade-in motion-reduce:animate-none" : "min-h-[560px] border-y border-border",
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
)}
data-testid="run-view"
>
<RunHeader trace={trace} onBack={onBack} embedded={embedded} />
<RunHeader
trace={trace}
handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)}
onBack={onBack}
embedded={embedded}
/>
{(traceQuery.hasNextPage || traceQuery.isError) && (
<div className="flex items-center justify-between gap-3 border-b px-3 py-2 text-xs" role="status">
<span>
@ -471,13 +471,11 @@ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack,
</div>
)}
<RunBody
key={`${trace.summary.trace_ref || trace.summary.trace_id}:${initialSpanId && !seekingSpan ? initialSpanId : "root"}`}
key={seekingSpan ? "seeking" : "loaded"}
trace={trace}
accessToken={accessToken}
initialSpanId={initialSpanId}
selection={selection}
embedded={embedded}
view={view}
onViewChange={setView}
/>
</Tabs>
);

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { traceFramework } from "./TraceFramework";
describe("traceFramework", () => {
it.each([
[["claude-agent-sdk", "claude-code"], "Claude Agent SDK", "anthropic"],
[["deepagents", "langchain_chat_model", "langchain_create_agent"], "Deep Agents", "langchain"],
[["langchain_chat_model", "langgraph"], "LangGraph", "langgraph"],
[["langchain_chat_model", "langchain_create_agent"], "LangChain", "langchain"],
[["crewai", "openai"], "CrewAI", "crewai"],
[["google-adk"], "Google ADK", "google-adk"],
[["llama-index"], "LlamaIndex", "llamaindex"],
[["openai-agents"], "OpenAI Agents", "openai-agents"],
[["pydantic-ai"], "Pydantic AI", "pydantic-ai"],
[["strands"], "Strands", "strands"],
[["vercel-ai-sdk"], "Vercel AI SDK", "vercel"],
[["openai"], "OpenAI SDK", "openai"],
])("resolves %j to %s with the agent framework outranking the model SDK", (frameworks, label, logo) => {
const framework = traceFramework({ frameworks });
expect(framework?.label).toBe(label);
expect(framework?.logo).toContain(logo);
});
it("returns null for an unknown or empty framework list", () => {
expect(traceFramework({ frameworks: ["some-other-sdk"] })).toBeNull();
expect(traceFramework({ frameworks: [] })).toBeNull();
});
});

View file

@ -1,4 +1,16 @@
import anthropicLogo from "../../../../public/assets/logos/anthropic.svg";
import crewaiLogo from "../../../../public/assets/logos/crewai-color.svg";
import cursorLogo from "../../../../public/assets/logos/cursor.svg";
import copilotLogo from "../../../../public/assets/logos/github_copilot.svg";
import googleAdkLogo from "../../../../public/assets/logos/google-adk.png";
import langchainLogo from "../../../../public/assets/logos/langchain.svg";
import langgraphLogo from "../../../../public/assets/logos/langgraph-color.svg";
import llamaIndexLogo from "../../../../public/assets/logos/llamaindex-color.svg";
import openaiLogo from "../../../../public/assets/logos/openai_small.svg";
import openaiAgentsLogo from "../../../../public/assets/logos/openai-agents.svg";
import pydanticAiLogo from "../../../../public/assets/logos/pydantic-ai-color.svg";
import strandsLogo from "../../../../public/assets/logos/strands.svg";
import vercelLogo from "../../../../public/assets/logos/vercel.svg";
import { Logo } from "@/components/molecules/logo/Logo";
import { cn } from "@/lib/cva.config";
@ -10,15 +22,32 @@ export interface TraceFramework {
readonly logo: string;
}
/**
* Ids are the slugs the trace normalizer emits (litellm-rust `Integration`, kebab-case) plus the raw
* `ls_integration` values LangChain reports. A trace lists every framework seen on its spans, so the
* registry runs most-specific first: an agent framework beats the model SDK it wraps.
*/
const FRAMEWORKS: readonly TraceFramework[] = [
{ id: "claude-agent-sdk", label: "Claude Agent SDK", logo: anthropicLogo.src },
{ id: "claude-code", label: "Claude Code", logo: anthropicLogo.src },
{ id: "openai-codex", label: "Codex", logo: openaiLogo.src },
{ id: "cursor", label: "Cursor", logo: cursorLogo.src },
{ id: "copilot", label: "GitHub Copilot", logo: copilotLogo.src },
{ id: "deepagents", label: "Deep Agents", logo: langchainLogo.src },
{ id: "langgraph", label: "LangGraph", logo: langgraphLogo.src },
{ id: "langchain", label: "LangChain", logo: langchainLogo.src },
{ id: "langchain_create_agent", label: "LangChain", logo: langchainLogo.src },
{ id: "langchain_chat_model", label: "LangChain", logo: langchainLogo.src },
{ id: "crewai", label: "CrewAI", logo: crewaiLogo.src },
{ id: "google-adk", label: "Google ADK", logo: googleAdkLogo.src },
{ id: "llama-index", label: "LlamaIndex", logo: llamaIndexLogo.src },
{ id: "openai-agents", label: "OpenAI Agents", logo: openaiAgentsLogo.src },
{ id: "pydantic-ai", label: "Pydantic AI", logo: pydanticAiLogo.src },
{ id: "strands", label: "Strands", logo: strandsLogo.src },
{ id: "vercel-ai-sdk", label: "Vercel AI SDK", logo: vercelLogo.src },
{ id: "openai", label: "OpenAI SDK", logo: openaiLogo.src },
];
/**
* The SDK that produced the trace. A Claude Agent SDK trace also carries "claude-code" (its root span has no
* SDK marker), so registry order decides: the more specific framework wins.
*/
export const traceFramework = (summary: Pick<TraceSummary, "frameworks">): TraceFramework | null =>
FRAMEWORKS.find((framework) => summary.frameworks?.includes(framework.id)) ?? null;

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders } from "@/../tests/test-utils";
import { copyToClipboard } from "@/utils/dataUtils";
import { LensPreviewContext } from "@/components/lens/LensPreviewButton";
import { agentTraceCall, apiClient, sendOtlpTraceCall } from "../../networking";
import {
codingAgentCommand,
@ -32,21 +33,22 @@ const renderCard = (
onCheck?: () => void;
readOnly?: boolean;
canMintTracingKey?: boolean;
onDemo?: () => void;
onPreview?: () => void;
} = {},
) => {
const onOpenTrace = vi.fn();
renderWithProviders(
<TracingSetupCard
detail={props.detail ?? null}
connected={props.connected}
onDemo={props.onDemo}
onCheck={props.onCheck}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey ?? true}
accessToken="sk-admin"
onOpenTrace={onOpenTrace}
/>,
<LensPreviewContext.Provider value={{ target: document.body, open: props.onPreview }}>
<TracingSetupCard
detail={props.detail ?? null}
connected={props.connected}
onCheck={props.onCheck}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey ?? true}
accessToken="sk-admin"
onOpenTrace={onOpenTrace}
/>
</LensPreviewContext.Provider>,
);
return { onOpenTrace, card: screen.getByTestId("tracing-setup-card") };
};
@ -56,13 +58,13 @@ beforeEach(() => vi.clearAllMocks());
describe("TracingSetupCard", () => {
it("offers the interactive demo while waiting for the first trace", async () => {
const user = userEvent.setup();
const onDemo = vi.fn();
const { card } = renderCard({ onDemo });
const onPreview = vi.fn();
const { card } = renderCard({ onPreview });
expect(screen.getByRole("heading", { name: "Connect your agent" })).toBeVisible();
expect(screen.getByText("Tracing enabled")).toBeVisible();
expect(screen.getByText("Waiting for your first trace")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Preview sample" }));
expect(onDemo).toHaveBeenCalledOnce();
expect(onPreview).toHaveBeenCalledOnce();
expect(sendOtlpTraceCall).not.toHaveBeenCalled();
expect(card).not.toHaveTextContent("store: clickhouse");
await user.click(screen.getByText("Set up manually"));

View file

@ -654,7 +654,6 @@ export function TracingSetupCard({
checking = false,
readOnly = false,
canMintTracingKey = false,
onDemo,
}: {
detail: string | null;
accessToken: string;
@ -664,7 +663,6 @@ export function TracingSetupCard({
checking?: boolean;
readOnly?: boolean;
canMintTracingKey?: boolean;
onDemo?: () => void;
}) {
const [checked, setChecked] = useState(false);
const enabled = detail === null;
@ -675,7 +673,7 @@ export function TracingSetupCard({
return (
<div className="w-full max-w-3xl pb-8" data-testid="tracing-setup-card">
{onDemo && <LensPreviewButton onClick={onDemo} />}
{!connected && <LensPreviewButton />}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, connected)}</h2>
<span role="status" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">

View file

@ -1,4 +1,4 @@
import { useLensDemo } from "@/components/lens/LensDemoContext";
import { useTracesLive } from "./tracesApi";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@ -30,7 +30,6 @@ export function resolveSpanProvider(model: string | null, lookup: ProviderLookup
}
export function useSpanProvider(model: string | null): string | null {
const demo = useLensDemo();
const { data } = useModelCostMap(!demo && model !== null);
const { data } = useModelCostMap(useTracesLive() && model !== null);
return resolveSpanProvider(model, costMapLookup(data));
}

View file

@ -0,0 +1,181 @@
import {
parseAsBoolean,
parseAsInteger,
parseAsNumberLiteral,
parseAsString,
parseAsStringLiteral,
useQueryState,
useQueryStates,
} from "nuqs";
import { useCallback, useState } from "react";
import { ALL_AGENTS, type RunStatusFilter } from "./RunsToolbar";
import { RANGE_PRESETS } from "./TimeRangeControls";
import type { TimeWindow } from "./TracesTimeline";
import type { TraceSummary } from "./traceTypes";
export const TRACE_VIEWS = ["steps", "conversation"] as const;
export type TraceView = (typeof TRACE_VIEWS)[number];
export const SPAN_TABS = ["content", "request", "attributes"] as const;
export type SpanTab = (typeof SPAN_TABS)[number];
const STATUS_FILTERS = ["all", "ok", "error"] as const satisfies readonly RunStatusFilter[];
const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours);
type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
export const DEFAULT_RANGE_HOURS = 24;
export interface TraceRef {
traceId: string;
traceRef?: string;
}
export const traceRefOf = (run: TraceSummary): TraceRef => ({ traceId: run.trace_id, traceRef: run.trace_ref });
export const traceKey = (ref: TraceRef): string => ref.traceRef || ref.traceId;
/** Which step, view and detail section of an open run are showing. Owned by the URL in the drawer, locally in sheets. */
export interface RunSelection {
spanId: string | null;
view: TraceView;
spanTab: SpanTab;
stepQuery: string;
errorsOnly: boolean;
selectSpan: (id: string) => void;
setView: (view: TraceView) => void;
setSpanTab: (tab: SpanTab) => void;
setStepQuery: (query: string) => void;
setErrorsOnly: (errorsOnly: boolean) => void;
}
export const OPEN_TRACE_PARSERS = {
trace: parseAsString,
trace_ref: parseAsString,
span: parseAsString,
view: parseAsStringLiteral(TRACE_VIEWS).withDefault("steps"),
span_tab: parseAsStringLiteral(SPAN_TABS).withDefault("content"),
steps_q: parseAsString.withDefault(""),
errors: parseAsBoolean.withDefault(false),
fullscreen: parseAsBoolean.withDefault(false),
};
export const RUN_FILTER_PARSERS = {
q: parseAsString.withDefault(""),
agent: parseAsString.withDefault(ALL_AGENTS),
status: parseAsStringLiteral(STATUS_FILTERS).withDefault("all"),
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
from: parseAsInteger,
to: parseAsInteger,
};
export interface OpenTraceRouting {
trace: TraceRef | null;
openTrace: (ref: TraceRef | null) => void;
selection: RunSelection;
fullScreen: boolean;
setFullScreen: (fullScreen: boolean) => void;
}
const FRESH_RUN = { span: null, view: null, span_tab: null, steps_q: null, errors: null };
/** The open run is a history entry; moving within it (step, view, section) replaces the current one. */
export function useOpenTraceRouting(): OpenTraceRouting {
const [params, setParams] = useQueryStates(OPEN_TRACE_PARSERS, { history: "push" });
const openTrace = useCallback(
(ref: TraceRef | null) => {
const run = { trace: ref?.traceId ?? null, trace_ref: ref?.traceRef || null };
void setParams(ref === null ? { ...FRESH_RUN, ...run, fullscreen: null } : { ...FRESH_RUN, ...run });
},
[setParams],
);
const selectSpan = useCallback(
(id: string) => void setParams({ span: id, span_tab: null }, { history: "replace" }),
[setParams],
);
const setView = useCallback((view: TraceView) => void setParams({ view }, { history: "replace" }), [setParams]);
const setSpanTab = useCallback(
(span_tab: SpanTab) => void setParams({ span_tab }, { history: "replace" }),
[setParams],
);
const setStepQuery = useCallback(
(steps_q: string) => void setParams({ steps_q }, { history: "replace" }),
[setParams],
);
const setErrorsOnly = useCallback(
(errors: boolean) => void setParams({ errors }, { history: "replace" }),
[setParams],
);
const setFullScreen = useCallback(
(fullscreen: boolean) => void setParams({ fullscreen }, { history: "replace" }),
[setParams],
);
return {
trace: params.trace === null ? null : { traceId: params.trace, traceRef: params.trace_ref ?? undefined },
openTrace,
selection: {
spanId: params.span,
view: params.view,
spanTab: params.span_tab,
stepQuery: params.steps_q,
errorsOnly: params.errors,
selectSpan,
setView,
setSpanTab,
setStepQuery,
setErrorsOnly,
},
fullScreen: params.fullscreen,
setFullScreen,
};
}
/** Same shape as the routed selection, for run views opened inside another surface (a finding's evidence sheet). */
export function useLocalRunSelection(initialSpanId: string | null): RunSelection {
const [spanId, setSpanId] = useState(initialSpanId);
const [view, setView] = useState<TraceView>("steps");
const [spanTab, setSpanTab] = useState<SpanTab>("content");
const [stepQuery, setStepQuery] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
const selectSpan = useCallback((id: string) => {
setSpanId(id);
setSpanTab("content");
}, []);
return { spanId, view, spanTab, stepQuery, errorsOnly, selectSpan, setView, setSpanTab, setStepQuery, setErrorsOnly };
}
export interface RunFilterRouting {
query: string;
agent: string;
status: RunStatusFilter;
setQuery: (query: string) => void;
setAgent: (agent: string) => void;
setStatus: (status: RunStatusFilter) => void;
}
export function useRunFilterRouting(): RunFilterRouting {
const [{ q, agent, status }, setParams] = useQueryStates(RUN_FILTER_PARSERS);
return {
query: q,
agent,
status,
setQuery: useCallback((q: string) => void setParams({ q }), [setParams]),
setAgent: useCallback((agent: string) => void setParams({ agent }), [setParams]),
setStatus: useCallback((status: RunStatusFilter) => void setParams({ status }), [setParams]),
};
}
export function useRangeHoursRouting(): [number, (hours: number) => void] {
const [hours, setHours] = useQueryState("hours", RUN_FILTER_PARSERS.hours);
const setRangeHours = useCallback((next: number) => void (isRangeHours(next) && setHours(next)), [setHours]);
return [hours, setRangeHours];
}
/** A timeline brush narrows the list to a window inside the range; it is dropped whenever the range changes. */
export function useZoomRouting(): [TimeWindow | null, (zoom: TimeWindow | null) => void] {
const [{ from, to }, setParams] = useQueryStates({ from: RUN_FILTER_PARSERS.from, to: RUN_FILTER_PARSERS.to });
const setZoom = useCallback(
(zoom: TimeWindow | null) => void setParams({ from: zoom?.startMs ?? null, to: zoom?.endMs ?? null }),
[setParams],
);
return [from !== null && to !== null && from < to ? { startMs: from, endMs: to } : null, setZoom];
}

View file

@ -1,9 +1,13 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import {
agentTraceCall,
agentTraceListCall,
agentTraceSpanCall,
agentTraceSpanErrorCall,
apiClient,
getProxyBaseUrl,
} from "../../networking";
import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./traceTypes";
@ -13,7 +17,15 @@ export interface TraceWindow {
readonly cursor?: string | null;
}
export interface TraceHandoff {
readonly text: string;
readonly copied: string;
}
export interface TracesApi {
/** False for a fixed snapshot: nothing new arrives, so live tail and tracing setup don't apply. */
readonly live: boolean;
handoff(traceId: string, spanId?: string | null, traceRef?: string): TraceHandoff;
list(window: TraceWindow): Promise<TracePage>;
anyRecorded(): Promise<boolean>;
trace(traceId: string, traceRef?: string, cursor?: string | null): Promise<Trace>;
@ -25,8 +37,20 @@ export interface TracesApi {
): Promise<SpanErrorPage>;
}
/** A one-liner Claude Code / Codex can run to read the trace. */
export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => {
const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}${traceRef ? `&trace_ref=${traceRef}` : ""}`;
const what = spanId ? "this step of a LiteLLM agent trace" : "this LiteLLM agent trace";
return `Read ${what} and explain what happened and why it failed:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`;
};
export function liveTracesApi(accessToken: string): TracesApi {
return {
live: true,
handoff: (traceId, spanId, traceRef) => ({
text: agentHandoffText(traceId, spanId, traceRef),
copied: "Command copied",
}),
list: (window) => agentTraceListCall({ accessToken, ...window }),
anyRecorded: async () => {
const page = await apiClient.get<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } });
@ -37,3 +61,14 @@ export function liveTracesApi(accessToken: string): TracesApi {
spanError: (traceId, spanId, options) => agentTraceSpanErrorCall(accessToken, traceId, spanId, options),
};
}
export const TracesApiContext = createContext<TracesApi | null>(null);
/** Without a provider, falls back to the live HTTP implementation for the caller's token. */
export function useTracesApi(accessToken: string): TracesApi {
const provided = useContext(TracesApiContext);
const live = useMemo(() => liveTracesApi(accessToken), [accessToken]);
return provided ?? live;
}
export const useTracesLive = (): boolean => useContext(TracesApiContext)?.live ?? true;

View file

@ -1,4 +1,4 @@
import { useTracesApi } from "@/components/lens/services";
import { useTracesApi } from "./tracesApi";
import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import moment from "moment";
import { useMemo } from "react";
@ -59,7 +59,7 @@ export const traceWindowStartMs = (startTime: string, endTime: string, isCustomD
isCustomDate ? moment(startTime).valueOf() : nowMs - (moment(endTime).valueOf() - moment(startTime).valueOf());
/**
* GET /v1/traces for the Logs page time range, cursor-paginated ("Load more").
* GET /v1/traces for the Logs page time range, cursor-paginated as the runs list scrolls.
* Preset ranges roll on refresh; subsequent pages keep the first page's window.
*/
export function useAgentTraces({
@ -106,7 +106,7 @@ export function useAgentTraces({
error: notEnabled ? null : displayError(query.error),
hasMore: query.hasNextPage,
loadMore: () => {
if (!query.isFetching) void query.fetchNextPage();
if (query.hasNextPage && !query.isFetching) void query.fetchNextPage({ cancelRefetch: false });
},
refetch: () => void query.refetch(),
};

View file

@ -0,0 +1,23 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import { shouldRetry } from "./ReactQueryProvider";
const attempts = async (error: Error) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry, retryDelay: 0 } } });
const queryFn = vi.fn().mockRejectedValue(error);
await expect(client.fetchQuery({ queryKey: ["q"], queryFn })).rejects.toBe(error);
return queryFn.mock.calls.length;
};
describe("root query retry policy", () => {
it("does not retry client errors", async () => {
expect(await attempts(new ApiError("missing", 404, null))).toBe(1);
expect(await attempts(new ApiError("forbidden", 403, null))).toBe(1);
});
it("retries server and network errors", async () => {
expect(await attempts(new ApiError("down", 503, null))).toBe(4);
expect(await attempts(new TypeError("Failed to fetch"))).toBe(4);
});
});

View file

@ -1,8 +1,14 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/http/client";
const queryClient = new QueryClient();
const MAX_RETRIES = 3;
export const shouldRetry = (failureCount: number, error: unknown): boolean =>
!(error instanceof ApiError && error.status >= 400 && error.status < 500) && failureCount < MAX_RETRIES;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } });
export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatActivityTimestamp } from "./activityTimestamp";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "./activityTimestamp";
describe("Activity timestamps", () => {
it("shows the date, seconds and timezone for the same instant across storage formats", () => {
@ -23,4 +23,20 @@ describe("Activity timestamps", () => {
it("preserves an invalid timestamp rather than inventing a date", () => {
expect(formatActivityTimestamp("unavailable")).toBe("unavailable");
});
it("renders run timestamps as one compact 24h line with milliseconds and no timezone", () => {
const value = "2026-10-01T20:42:17.123Z";
const formatted = formatRunTimestamp(value);
const date = new Date(value);
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
expect(formatted).toMatch(/^[A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/);
expect(formatted).toContain(`${hh}:${mm}:17.123`);
expect(formatted).not.toContain(localTimeZoneAbbreviation(date));
expect(formatRunTimestamp("2026-10-01 20:42:17.123")).toBe(formatted);
expect(formatRunTimestamp("unavailable")).toBe("unavailable");
});
it("exposes the timezone once, matching what the long form prints", () => {
const value = "2026-10-01T20:42:17.123Z";
expect(formatActivityTimestamp(value)).toContain(localTimeZoneAbbreviation(new Date(value)));
});
});

View file

@ -1,6 +1,12 @@
export function formatActivityTimestamp(value: string): string {
import moment from "moment";
const parseStoredTimestamp = (value: string): Date => {
const normalized = value.includes("T") ? value : value.replace(" ", "T").slice(0, 23) + "Z";
const date = new Date(normalized);
return new Date(normalized);
};
export function formatActivityTimestamp(value: string): string {
const date = parseStoredTimestamp(value);
if (Number.isNaN(date.getTime())) return value;
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
@ -13,3 +19,18 @@ export function formatActivityTimestamp(value: string): string {
};
return date.toLocaleString(undefined, options);
}
export const RUN_TIMESTAMP_FORMAT = "MMM DD HH:mm:ss.SSS";
export function formatRunTimestamp(value: string): string {
const date = parseStoredTimestamp(value);
if (Number.isNaN(date.getTime())) return value;
return moment(date).format(RUN_TIMESTAMP_FORMAT);
}
export function localTimeZoneAbbreviation(at: Date = new Date()): string {
const part = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" })
.formatToParts(at)
.find((p) => p.type === "timeZoneName");
return part?.value ?? "";
}