diff --git a/litellm-rust/crates/traces-cache/src/cache.rs b/litellm-rust/crates/traces-cache/src/cache.rs index 287160df96b..eda054e6c7d 100644 --- a/litellm-rust/crates/traces-cache/src/cache.rs +++ b/litellm-rust/crates/traces-cache/src/cache.rs @@ -328,6 +328,7 @@ mod tests { source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: String::new(), api_key_hash: String::new(), user_id: String::new(), diff --git a/litellm-rust/crates/traces-cache/tests/read.rs b/litellm-rust/crates/traces-cache/tests/read.rs index ade70b71800..22293db98e8 100644 --- a/litellm-rust/crates/traces-cache/tests/read.rs +++ b/litellm-rust/crates/traces-cache/tests/read.rs @@ -289,6 +289,7 @@ fn span(index: usize) -> TraceSpansRow { source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: "team".into(), api_key_hash: "key".into(), user_id: "user".into(), diff --git a/litellm-rust/crates/traces-cache/tests/snapshots.rs b/litellm-rust/crates/traces-cache/tests/snapshots.rs index bebff0bb1aa..4ad4e2b7545 100644 --- a/litellm-rust/crates/traces-cache/tests/snapshots.rs +++ b/litellm-rust/crates/traces-cache/tests/snapshots.rs @@ -42,6 +42,7 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: String::new(), api_key_hash: String::new(), user_id: String::new(), diff --git a/litellm-rust/crates/traces-clickhouse/query/trace_list_span_batch.sql b/litellm-rust/crates/traces-clickhouse/query/trace_list_span_batch.sql index b764005024e..327483d7a45 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_list_span_batch.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_list_span_batch.sql @@ -14,7 +14,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), '')) AS tool_call_id, o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url, - o.SpanAttributes['agent.source.title'] AS source_title, + o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user, o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash FROM otel_traces AS o WHERE o.Timestamp >= fromUnixTimestamp64Milli({start_ms:Int64}) diff --git a/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql b/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql index 5af30920df9..dd8cdee7e80 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql @@ -13,7 +13,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), '')) AS tool_call_id, o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url, - o.SpanAttributes['agent.source.title'] AS source_title, + o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user, o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash FROM otel_traces AS o WHERE o.Timestamp >= fromUnixTimestamp64Milli({start_ms:Int64}) diff --git a/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql b/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql index c7d50a44544..85345cb05c1 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql @@ -14,7 +14,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), '')) AS tool_call_id, o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url, - o.SpanAttributes['agent.source.title'] AS source_title, + o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user, o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash FROM otel_traces AS o WHERE o.TraceId = {trace_id:String} diff --git a/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql b/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql index 974a6d050a4..a2327e57660 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql @@ -13,7 +13,7 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig coalesce(nullIf(o.SpanAttributes['gen_ai.tool.call.id'], ''), nullIf(o.SpanAttributes['tool.id'], ''), '')) AS tool_call_id, o.SpanAttributes['agent.source.type'] AS source_type, o.SpanAttributes['agent.source.url'] AS source_url, - o.SpanAttributes['agent.source.title'] AS source_title, + o.SpanAttributes['agent.source.title'] AS source_title, o.SpanAttributes['agent.source.user'] AS source_user, o.UserId AS user_id, o.TeamId AS team_id, o.ApiKeyHash AS api_key_hash FROM otel_traces AS o WHERE o.TraceId = {trace_id:String} diff --git a/litellm-rust/crates/traces-clickhouse/src/query/named.rs b/litellm-rust/crates/traces-clickhouse/src/query/named.rs index 1b98ad39912..b7de28646e4 100644 --- a/litellm-rust/crates/traces-clickhouse/src/query/named.rs +++ b/litellm-rust/crates/traces-clickhouse/src/query/named.rs @@ -134,6 +134,8 @@ struct TraceSpansRowEncoding { pub source_url: String, #[serde(default)] pub source_title: String, + #[serde(default)] + pub source_user: String, pub team_id: String, pub api_key_hash: String, pub user_id: String, @@ -355,7 +357,7 @@ mod tests { quoted, ); round_trip::( - json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "team_id": "team", "api_key_hash": "key", "user_id": "user"}), + json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "source_user": "tin@berri.ai", "team_id": "team", "api_key_hash": "key", "user_id": "user"}), quoted, ); round_trip::( diff --git a/litellm-rust/crates/traces/src/query/named.rs b/litellm-rust/crates/traces/src/query/named.rs index 03459645ca9..dfa7ac2f2cd 100644 --- a/litellm-rust/crates/traces/src/query/named.rs +++ b/litellm-rust/crates/traces/src/query/named.rs @@ -116,6 +116,8 @@ pub struct TraceSpansRow { pub source_url: String, #[serde(default)] pub source_title: String, + #[serde(default)] + pub source_user: String, pub team_id: String, pub api_key_hash: String, pub user_id: String, diff --git a/litellm-rust/crates/traces/src/resolve/view.rs b/litellm-rust/crates/traces/src/resolve/view.rs index 97f83c255f8..62c27676605 100644 --- a/litellm-rust/crates/traces/src/resolve/view.rs +++ b/litellm-rust/crates/traces/src/resolve/view.rs @@ -146,6 +146,7 @@ fn source(row: &TraceSpansRow) -> Option { .unwrap_or(RunSourceType::Custom), url: row.source_url.clone(), title: row.source_title.clone(), + user: row.source_user.clone(), }) } diff --git a/litellm-rust/crates/traces/src/view.rs b/litellm-rust/crates/traces/src/view.rs index 1613d96d232..729ab5ef1c3 100644 --- a/litellm-rust/crates/traces/src/view.rs +++ b/litellm-rust/crates/traces/src/view.rs @@ -88,6 +88,10 @@ pub struct RunSource { pub kind: RunSourceType, pub url: String, pub title: String, + /// Who started the conversation, e.g. the Slack user's email. + #[serde(default, skip_serializing_if = "String::is_empty")] + #[cfg_attr(feature = "schema", schemars(extend("x-python-optional" = true)))] + pub user: String, } #[macro_rules_attribute::apply(response_type)] diff --git a/litellm-rust/crates/traces/tests/captures.rs b/litellm-rust/crates/traces/tests/captures.rs index 922e28f97ac..a81bca609ea 100644 --- a/litellm-rust/crates/traces/tests/captures.rs +++ b/litellm-rust/crates/traces/tests/captures.rs @@ -210,6 +210,7 @@ fn trace_span(span: DecodedSpan) -> TraceSpansRow { source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: "fixture-team".into(), api_key_hash: "fixture-key".into(), user_id: "fixture-user".into(), @@ -297,6 +298,7 @@ fn unrelated_transport(call: &TraceSpansRow) -> TraceSpansRow { source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: call.team_id.clone(), api_key_hash: call.api_key_hash.clone(), user_id: call.user_id.clone(), diff --git a/litellm-rust/crates/traces/tests/query/named.rs b/litellm-rust/crates/traces/tests/query/named.rs index 25cfd7083a5..af33ee3fe85 100644 --- a/litellm-rust/crates/traces/tests/query/named.rs +++ b/litellm-rust/crates/traces/tests/query/named.rs @@ -53,7 +53,7 @@ fn result_contracts_preserve_public_field_names() { json!({"trace_id": "trace", "trace_ref": "ref", "team_id": "team", "api_key_hash": "key", "user_id": "user", "name": "agent", "service": "service", "input_preview": "input", "status": "STATUS_CODE_OK", "start_ms": -1, "duration_ms": 20, "span_count": u64::MAX, "agent_count": 1, "agent_invocations": 2, "agent_names": ["agent"], "frameworks": ["framework"], "llm_calls": 3, "tool_calls": 4, "input_tokens": 5, "output_tokens": 6, "models": ["model"], "error_count": 0, "request_ids": ["request"]}), ); round_trip::( - json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "team_id": "team", "api_key_hash": "key", "user_id": "user"}), + json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "source_type": "slack", "source_url": "https://acme.slack.com/archives/C1/p1", "source_title": "thread", "source_user": "tin@berri.ai", "team_id": "team", "api_key_hash": "key", "user_id": "user"}), ); round_trip::( json!({"span_id": "span", "input": "input", "output": "output", "attributes": {"count": "42"}}), diff --git a/litellm-rust/crates/traces/tests/resolve.rs b/litellm-rust/crates/traces/tests/resolve.rs index baaa4c338cf..73f691cb2a6 100644 --- a/litellm-rust/crates/traces/tests/resolve.rs +++ b/litellm-rust/crates/traces/tests/resolve.rs @@ -36,6 +36,7 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac source_type: String::new(), source_url: String::new(), source_title: String::new(), + source_user: String::new(), team_id: "team".into(), api_key_hash: "key".into(), user_id: String::new(), @@ -227,6 +228,16 @@ fn summary_source_type_picks_the_app(#[case] source_type: &str, #[case] expected assert_eq!(source.map(|source| source.kind), Some(expected)); } +#[rstest] +#[case::set("tin@berri.ai")] +#[case::missing("")] +fn summary_source_carries_who_started_it(#[case] user: &str) { + let mut root = sourced(row("root", "", "agent", "agent", "agent"), THREAD, "t"); + root.source_user = user.into(); + let source = resolve_trace("t", "", &[root], &[]).unwrap().summary.source; + assert_eq!(source.map(|source| source.user), Some(user.to_owned())); +} + #[rstest] fn spans_are_offset_from_the_trace_start() { let trace = resolve_trace("t1", "", &deep_agent(1), &[]).unwrap(); diff --git a/litellm/rust_bridge/trace/generated/types.py b/litellm/rust_bridge/trace/generated/types.py index 4234cc5e6fa..12f38da6e28 100644 --- a/litellm/rust_bridge/trace/generated/types.py +++ b/litellm/rust_bridge/trace/generated/types.py @@ -109,6 +109,7 @@ class RunSource(typing_extensions.TypedDict): type: ReadOnly[RunSourceType] url: ReadOnly[str] title: ReadOnly[str] + user: ReadOnly[NotRequired[str]] class Span(typing_extensions.TypedDict): diff --git a/scripts/trace_codegen/schemas/traces/Trace.json b/scripts/trace_codegen/schemas/traces/Trace.json index 3fcbf654101..b8b893607dc 100644 --- a/scripts/trace_codegen/schemas/traces/Trace.json +++ b/scripts/trace_codegen/schemas/traces/Trace.json @@ -71,6 +71,11 @@ }, "url": { "type": "string" + }, + "user": { + "description": "Who started the conversation, e.g. the Slack user's email.", + "type": "string", + "x-python-optional": true } }, "required": [ diff --git a/scripts/trace_codegen/schemas/traces/TracePage.json b/scripts/trace_codegen/schemas/traces/TracePage.json index 9d437bd60a1..47f48a676de 100644 --- a/scripts/trace_codegen/schemas/traces/TracePage.json +++ b/scripts/trace_codegen/schemas/traces/TracePage.json @@ -11,6 +11,11 @@ }, "url": { "type": "string" + }, + "user": { + "description": "Who started the conversation, e.g. the Slack user's email.", + "type": "string", + "x-python-optional": true } }, "required": [ diff --git a/ui/litellm-dashboard/src/components/lens/data/demo/fixtures.ts b/ui/litellm-dashboard/src/components/lens/data/demo/fixtures.ts index f33f86cd280..c90853dfbad 100644 --- a/ui/litellm-dashboard/src/components/lens/data/demo/fixtures.ts +++ b/ui/litellm-dashboard/src/components/lens/data/demo/fixtures.ts @@ -5,6 +5,15 @@ import { scenarios, type Scenario } from "./scenarios"; const executionId = (traceId: string) => btoa(JSON.stringify(["traces", "", traceId])); const iso = (time: number) => new Date(time).toISOString(); +const DEMO_ASKERS = ["maya@acme.dev", "jordan@acme.dev", "priya@acme.dev"]; + +/** Demo runs start from a Slack thread so the run header shows who asked and where. */ +const demoSource = (scene: Scenario, index: number): NonNullable => ({ + type: "slack", + url: `https://acme.slack.com/archives/C0DEMO/p${1_700_000_000_000 + index}`, + title: scene.question, + user: DEMO_ASKERS[index % DEMO_ASKERS.length], +}); function makeTrace(scene: Scenario, index: number, now: number) { const traceId = (index + 1).toString(16).padStart(32, "0"); @@ -79,6 +88,7 @@ function makeTrace(scene: Scenario, index: number, now: number) { status: scene.failed ? "error" : "ok", tool_calls: toolCount, trace_id: traceId, + source: demoSource(scene, index), }, agents: [ { diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx index cfe8674a6c5..29b6051ddeb 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx @@ -1,58 +1,55 @@ "use client"; -import { ArrowLeft, Check, Copy, Link, ListTree, MessagesSquare, RefreshCw } from "lucide-react"; +import { + ArrowLeft, + Check, + ChevronDown, + Copy, + DatabaseZap, + Link, + ListTree, + MessagesSquare, + RefreshCw, +} from "lucide-react"; import { useState } from "react"; import { useTimeout } from "usehooks-ts"; import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -import { AddToDatasetButton } from "../../../datasets/AddToDatasetDialog"; +import { AddToDatasetDialog, useCanAddToDataset } from "../../../datasets/AddToDatasetDialog"; import type { TraceHandoff } from "../../api"; import { runCost } from "../../list/AgentTracesTable"; import { traceRefOf, traceShareUrl } from "../../routing"; import { IdChip } from "../../ui/IdChip"; -import { RunSourceLink } from "../../ui/RunSource"; +import { RunSourceLink, RunUser } from "../../ui/RunSource"; import { SpanIcon } from "../../ui/SpanIcon"; import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework"; import type { SignalFlag, Trace } from "../../types"; import { SignalPills } from "../../ui/SignalPills"; import { fmtMs, fmtTok, traceAgentNames, traceDisplayName } from "../../utils"; -interface CopyButtonProps { - label: string; - icon: typeof Copy; - text: () => string; - toast: string; +type Summary = Trace["summary"]; + +function statusLabel(failed: boolean, errors: number): string { + if (errors === 0) return failed ? "Errors recorded" : "Recorded"; + const stepErrors = `${errors.toLocaleString()} step ${errors === 1 ? "error" : "errors"}`; + return failed ? stepErrors : `Recorded · ${stepErrors}`; } -function CopyButton({ label, icon: Icon, text, toast }: CopyButtonProps) { - const [copied, setCopied] = useState(false); - useTimeout(() => setCopied(false), copied ? 1600 : null); - return ( - - ); -} - -function Stat({ label, value, error = false }: { label: string; value: string; error?: boolean }) { - return ( - - {label} {value} - - ); -} - -function StatusPill({ failed }: { failed: boolean }) { +/** One pill for how the run went, so a failure is stated once instead of as a pill, a stat and a red icon. */ +function StatusPill({ summary }: { summary: Summary }) { + const failed = summary.status === "error"; + const label = statusLabel(failed, summary.error_count); return ( - {failed ? "Errors recorded" : "Recorded"} + {label} ); } -function RunIcon({ summary, failed }: { summary: Trace["summary"]; failed: boolean }) { +/** Duration, steps, tokens and cost as one quiet line; cost is left out when nothing was priced. */ +function Totals({ summary }: { summary: Summary }) { + const cost = runCost(summary); + const parts = [ + fmtMs(summary.duration_ms), + `${summary.span_count.toLocaleString()} steps`, + `${fmtTok(summary.input_tokens + summary.output_tokens)} tokens`, + cost && [cost.label, cost.partial?.long].filter(Boolean).join(" · "), + ].filter(Boolean); + return {parts.join(" · ")}; +} + +function RunIcon({ summary }: { summary: Summary }) { const framework = traceFramework(summary); - if (!framework) return ; + if (!framework) return ; return ( setCopied(false), copied ? 1600 : null); + return ( + <> + + + + + } + > + + + + + void copyToClipboard(traceShareUrl(traceRefOf(summary), window.location), "Trace link copied") + } + > + + Copy link + + {canAddToDataset && ( + setAddingToDataset(true)}> + + Add run to dataset + + )} + + + + {addingToDataset && ( + setAddingToDataset(false)} + /> + )} + + ); +} + interface RunHeaderProps { trace: Trace; handoff: TraceHandoff; @@ -95,7 +159,7 @@ interface RunHeaderProps { signals?: readonly SignalFlag[]; } -/** Run identity, view switch and totals in two tight rows. */ +/** Row one says which agent ran, for whom and from where; row two says how it went. */ export function RunHeader({ trace, handoff, @@ -109,73 +173,66 @@ export function RunHeader({ signals = [], }: RunHeaderProps) { const { summary } = trace; - const failed = summary.status === "error"; - const cost = runCost(summary); return ( -
-
-
- {!embedded && ( - - )} -

{traceDisplayName(summary)}

- - -
-
- - - - Steps - - - - Thread - - -
- - - +
+ {!embedded && ( + + )} + +

{traceDisplayName(summary)}

+ {summary.source?.user && } + {summary.source && } + +
+
+ Live + + +
-
- +
+ {signals.length > 0 && } - {summary.source && } - - - - - {summary.error_count > 0 && } + + + + + Steps + + + + Thread + +
); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx index f25517e2909..b4107143487 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx @@ -78,8 +78,8 @@ describe("RunView", () => { "title", research.summary.trace_id, ); - expect(header).toHaveTextContent("Duration 40.20s"); - expect(header).toHaveTextContent(`Steps ${research.summary.span_count}`); + expect(header).toHaveTextContent("40.20s"); + expect(header).toHaveTextContent(`${research.summary.span_count} steps`); expect(header).toHaveTextContent("Recorded"); expect(header).not.toHaveTextContent("Completed"); }); @@ -97,6 +97,37 @@ describe("RunView", () => { expect(within(header).queryByTestId("span-icon")).not.toBeInTheDocument(); }); + it("puts who started the run and its Slack thread next to the agent name", async () => { + const thread = "https://acme.slack.com/archives/C1/p1"; + renderRun({ + ...research, + summary: { + ...research.summary, + spend: null, + source: { type: "slack", url: thread, title: "why is the deploy failing?", user: "tin@berri.ai" }, + }, + }); + + const header = await screen.findByRole("banner"); + expect(within(header).getByTestId("run-user")).toHaveTextContent("tin@berri.ai"); + expect(within(header).getByRole("link", { name: "Open Slack thread" })).toHaveAttribute("href", thread); + expect(header).not.toHaveTextContent("Not reported"); + }); + + it("leaves the user out when the source does not say who started it", async () => { + renderRun({ + ...research, + summary: { + ...research.summary, + source: { type: "slack", url: "https://acme.slack.com/archives/C1/p1", title: "" }, + }, + }); + + const header = await screen.findByRole("banner"); + expect(within(header).queryByTestId("run-user")).not.toBeInTheDocument(); + expect(within(header).getByRole("link", { name: "Open Slack thread" })).toBeInTheDocument(); + }); + it("keeps the generic agent icon when the trace has no known SDK", async () => { renderRun({ ...research, summary: { ...research.summary, frameworks: ["some-other-sdk"] } }); @@ -110,7 +141,7 @@ describe("RunView", () => { const tree = await screen.findByRole("tree", { name: "Spans in time order" }); expect(tree).toHaveTextContent("researcher×12"); - expect(screen.getByRole("banner")).toHaveTextContent(`Step errors ${swarm.summary.error_count}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${swarm.summary.error_count} step errors`); }); it.each([ @@ -395,7 +426,7 @@ describe("RunView", () => { expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); expect(await screen.findByText("newly received step")).toBeVisible(); - expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 1}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count + 1} steps`); expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); }); @@ -413,11 +444,11 @@ describe("RunView", () => { await act(async () => { await vi.advanceTimersByTimeAsync(100); }); - expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count} steps`); await act(async () => { await vi.advanceTimersByTimeAsync(30_100); }); - expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 7}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count + 7} steps`); expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); } finally { vi.useRealTimers(); @@ -476,7 +507,7 @@ describe("RunView", () => { await act(async () => { await vi.advanceTimersByTimeAsync(60_100); }); - expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${research.summary.span_count} steps`); expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); @@ -517,7 +548,7 @@ describe("RunView", () => { expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument(); expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); if (action === "Refresh run") { - expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${updated.summary.span_count}`); + expect(screen.getByRole("banner")).toHaveTextContent(`${updated.summary.span_count} steps`); } } finally { vi.useRealTimers(); @@ -642,7 +673,7 @@ describe("RunView", () => { renderRun({ ...research, summary: { ...research.summary, status: "ok", error_count: 2 } }); const header = await screen.findByRole("banner"); expect(header).toHaveTextContent("Recorded"); - expect(header).toHaveTextContent("Step errors 2"); + expect(header).toHaveTextContent("Recorded · 2 step errors"); expect(header).not.toHaveTextContent("Failed"); }); @@ -684,7 +715,8 @@ describe("RunView", () => { const user = userEvent.setup(); renderRun(research); - await user.click(await screen.findByRole("button", { name: /copy link/i })); + fireEvent.click(await screen.findByRole("button", { name: "More run actions" })); + await user.click(await screen.findByRole("menuitem", { name: /copy link/i })); const url = new URL(vi.mocked(copyToClipboard).mock.calls[0][0] as string); expect(url.pathname).toBe(window.location.pathname); expect(url.searchParams.get("trace")).toBe(research.summary.trace_id); diff --git a/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx index fa4ad9e6f8a..1dc7ec99b92 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx @@ -1,6 +1,6 @@ "use client"; -import { MessagesSquare } from "lucide-react"; +import { ArrowUpRight, MessagesSquare, UserRound } from "lucide-react"; import githubLogo from "../../../../../public/assets/logos/github.svg"; import jiraLogo from "../../../../../public/assets/logos/jira.svg"; @@ -52,25 +52,36 @@ function AppMark({ app, className }: { app: SourceApp; className: string }) { ); } -/** Links a run back to the conversation that started it, e.g. a Slack thread. */ +const chip = + "inline-flex h-6 min-w-0 shrink items-center gap-1.5 rounded-full border border-border px-2 text-xs text-foreground"; + +/** Who started the run, e.g. the person who asked in Slack. */ +export function RunUser({ user }: { user: string }) { + return ( + + + {user} + + ); +} + +/** Links a run back to the conversation that started it, e.g. a Slack thread; hover previews its title. */ export function RunSourceLink({ source }: { source: Source }) { const app = sourceApp(source); if (!app) return null; return ( - - Source - - - {app.label} - - + + + {app.label} thread + + @@ -78,7 +89,7 @@ export function RunSourceLink({ source }: { source: Source }) { - {app.label} + {source.user ? `${app.label} · ${source.user}` : app.label} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 5060fc3575e..bf711498307 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -44979,6 +44979,8 @@ export interface components { type: "slack" | "teams" | "discord" | "linear" | "github" | "jira" | "custom"; /** Url */ url: string; + /** User */ + user?: string; }; /** SCIMEnterpriseUser */ SCIMEnterpriseUser: {