mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(lens): link traces to the conversation that started them (#45169)
* feat(lens): carry lens.source attributes on trace span rows * feat(lens): read lens.source attributes in trace_spans query * feat(lens): read lens.source attributes in trace_page_spans query * feat(lens): read lens.source attributes in trace_span_batch query * feat(lens): read lens.source attributes in trace_list_span_batch query * feat(lens): decode lens.source columns from clickhouse span rows * feat(lens): add run source to the trace summary contract * feat(lens): export RunSource from litellm-traces * feat(lens): resolve the https run source from the root span * test(lens): cover run source resolution and https guard * test(lens): add source fields to capture fixture rows * test(lens): round-trip source fields on span row contract * test(lens): add source fields to trace cache test rows * test(lens): add source fields to trace cache read fixtures * test(lens): add source fields to trace cache snapshot fixtures * chore(lens): regenerate python trace types with run source * chore(lens): regenerate trace json schema with run source * chore(lens): regenerate trace page json schema with run source * chore(ui): regenerate api types with trace run source * feat(lens): add run source link with hover card * test(lens): cover run source app detection and url guard * feat(lens): show the run source next to the trace name * feat(lens): label the run source link, e.g. Slack thread * test(lens): assert run source link labels * feat(lens): move the run source link into the trace stats row * feat(lens): add a typed run source, e.g. slack or teams * feat(lens): export RunSourceType * feat(lens): carry the source type on trace span rows * feat(lens): resolve the run source type, defaulting to custom * feat(lens): read agent.source attributes in trace_spans query * feat(lens): read agent.source attributes in trace_page_spans query * feat(lens): read agent.source attributes in trace_span_batch query * feat(lens): read agent.source attributes in trace_list_span_batch query * feat(lens): decode the source type from clickhouse span rows * test(lens): cover run source type parsing * test(lens): add source type to capture fixture rows * test(lens): round-trip source type on span row contract * test(lens): add source type to trace cache test rows * test(lens): add source type to trace cache read fixtures * test(lens): add source type to trace cache snapshot fixtures * chore(lens): regenerate python trace types with source type * chore(lens): regenerate trace json schema with source type * chore(lens): regenerate trace page json schema with source type * chore(ui): regenerate api types with run source type * feat(lens): pick the run source logo and label from its type * test(lens): cover run source labels by type and slack logo * feat(lens): show the run source as a Source stat with the app name * test(lens): assert run source app names * feat(lens): place the Source stat before Duration
This commit is contained in:
parent
679f7e636e
commit
df23f11e9b
22 changed files with 413 additions and 41 deletions
|
|
@ -325,6 +325,9 @@ mod tests {
|
|||
call_keys: Vec::new(),
|
||||
call_evidence: None,
|
||||
tool_call_id: String::new(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: String::new(),
|
||||
api_key_hash: String::new(),
|
||||
user_id: String::new(),
|
||||
|
|
|
|||
|
|
@ -286,6 +286,9 @@ fn span(index: usize) -> TraceSpansRow {
|
|||
call_keys: Vec::new(),
|
||||
call_evidence: None,
|
||||
tool_call_id: String::new(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: "team".into(),
|
||||
api_key_hash: "key".into(),
|
||||
user_id: "user".into(),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac
|
|||
call_keys: Vec::new(),
|
||||
call_evidence: None,
|
||||
tool_call_id: String::new(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: String::new(),
|
||||
api_key_hash: String::new(),
|
||||
user_id: String::new(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
|
|||
if(o.ToolCallId != '' OR o.ObservationType != 'tool', o.ToolCallId,
|
||||
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.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})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
|
|||
if(o.ToolCallId != '' OR o.ObservationType != 'tool', o.ToolCallId,
|
||||
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.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})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
|
|||
if(o.ToolCallId != '' OR o.ObservationType != 'tool', o.ToolCallId,
|
||||
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.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}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS orig
|
|||
if(o.ToolCallId != '' OR o.ObservationType != 'tool', o.ToolCallId,
|
||||
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.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}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ struct TraceSpansRowEncoding {
|
|||
pub call_evidence: Option<litellm_traces::CallEvidenceKind>,
|
||||
#[serde(default)]
|
||||
pub tool_call_id: String,
|
||||
#[serde(default)]
|
||||
pub source_type: String,
|
||||
#[serde(default)]
|
||||
pub source_url: String,
|
||||
#[serde(default)]
|
||||
pub source_title: String,
|
||||
pub team_id: String,
|
||||
pub api_key_hash: String,
|
||||
pub user_id: String,
|
||||
|
|
@ -349,7 +355,7 @@ mod tests {
|
|||
quoted,
|
||||
);
|
||||
round_trip::<TraceSpansRow>(
|
||||
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", "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", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
quoted,
|
||||
);
|
||||
round_trip::<SpanDetailRow>(
|
||||
|
|
|
|||
|
|
@ -44,6 +44,6 @@ pub use tenant::Tenant;
|
|||
pub use truncate::{truncate_messages, truncate_value};
|
||||
pub use ui::{ChatRole, UiContent, UiField, UiMessage, UiToolCall, to_ui_content};
|
||||
pub use view::{
|
||||
AgentNode, Span, SpanDetail, SpanErrorPage, SpanStatus, SpendMatch, Trace, TracePage,
|
||||
TraceSummary,
|
||||
AgentNode, RunSource, RunSourceType, Span, SpanDetail, SpanErrorPage, SpanStatus, SpendMatch,
|
||||
Trace, TracePage, TraceSummary,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ pub struct TraceSpansRow {
|
|||
pub call_evidence: Option<crate::CallEvidenceKind>,
|
||||
#[serde(default)]
|
||||
pub tool_call_id: String,
|
||||
#[serde(default)]
|
||||
pub source_type: String,
|
||||
#[serde(default)]
|
||||
pub source_url: String,
|
||||
#[serde(default)]
|
||||
pub source_title: String,
|
||||
pub team_id: String,
|
||||
pub api_key_hash: String,
|
||||
pub user_id: String,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use time::OffsetDateTime;
|
|||
use crate::{
|
||||
normalize::ObservationType,
|
||||
query::named::{ListTracesRow, SpendByResponseIdsRow as SpendRow, TraceSpansRow},
|
||||
view::{AgentNode, Span, SpanStatus, SpendMatch, Trace, TraceSummary},
|
||||
view::{
|
||||
AgentNode, RunSource, RunSourceType, Span, SpanStatus, SpendMatch, Trace, TraceSummary,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
|
|
@ -138,6 +140,15 @@ pub fn iso_time(ms: i64) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn source(row: &TraceSpansRow) -> Option<RunSource> {
|
||||
row.source_url.starts_with("https://").then(|| RunSource {
|
||||
kind: serde_json::from_value(serde_json::Value::from(row.source_type.as_str()))
|
||||
.unwrap_or(RunSourceType::Custom),
|
||||
url: row.source_url.clone(),
|
||||
title: row.source_title.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sorted_unique<'a>(values: impl Iterator<Item = &'a str>) -> Vec<String> {
|
||||
values
|
||||
.filter(|value| !value.is_empty())
|
||||
|
|
@ -229,6 +240,12 @@ pub fn resolve_trace(
|
|||
models: sorted_unique(calls.iter().map(|call| rows[*call].model.as_str())),
|
||||
spend: priced.spend,
|
||||
priced_calls: priced.priced_calls,
|
||||
source: source(&rows[root]).or_else(|| {
|
||||
rows.iter()
|
||||
.filter_map(|row| Some((row.start_ns, source(row)?)))
|
||||
.min_by_key(|(start_ns, _)| *start_ns)
|
||||
.map(|(_, source)| source)
|
||||
}),
|
||||
};
|
||||
Some(Trace {
|
||||
summary,
|
||||
|
|
@ -266,5 +283,6 @@ pub fn listed_summary(row: &ListTracesRow) -> TraceSummary {
|
|||
models: row.models.clone(),
|
||||
spend: None,
|
||||
priced_calls: 0,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,30 @@ pub struct AgentNode {
|
|||
pub priced_calls: u64,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(wire_type)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunSourceType {
|
||||
Slack,
|
||||
Teams,
|
||||
Discord,
|
||||
Linear,
|
||||
Github,
|
||||
Jira,
|
||||
#[serde(other)]
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// The conversation that started the run, from the `agent.source.*` span attributes.
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RunSource {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: RunSourceType,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TraceSummary {
|
||||
|
|
@ -95,6 +119,9 @@ pub struct TraceSummary {
|
|||
pub models: Vec<String>,
|
||||
pub spend: Option<f64>,
|
||||
pub priced_calls: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "schema", schemars(extend("x-python-optional" = true)))]
|
||||
pub source: Option<RunSource>,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ fn trace_span(span: DecodedSpan) -> TraceSpansRow {
|
|||
call_keys,
|
||||
call_evidence: Some(normalized.calls.kind()),
|
||||
tool_call_id: normalized.tool_call_id.unwrap_or_default(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: "fixture-team".into(),
|
||||
api_key_hash: "fixture-key".into(),
|
||||
user_id: "fixture-user".into(),
|
||||
|
|
@ -291,6 +294,9 @@ fn unrelated_transport(call: &TraceSpansRow) -> TraceSpansRow {
|
|||
call_keys: vec![CallKey::Transport],
|
||||
call_evidence: Some(CallEvidenceKind::Complete),
|
||||
tool_call_id: String::new(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: call.team_id.clone(),
|
||||
api_key_hash: call.api_key_hash.clone(),
|
||||
user_id: call.user_id.clone(),
|
||||
|
|
|
|||
|
|
@ -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::<TraceSpansRow>(
|
||||
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", "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", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
);
|
||||
round_trip::<SpanDetailRow>(
|
||||
json!({"span_id": "span", "input": "input", "output": "output", "attributes": {"count": "42"}}),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use litellm_traces::{
|
||||
AgentNode, SpanStatus, SpendMatch, iso_time, listed_summary,
|
||||
AgentNode, RunSourceType, SpanStatus, SpendMatch, iso_time, listed_summary,
|
||||
query::named::{ListTracesRow, SpendByResponseIdsRow, TraceSpansRow},
|
||||
resolve_trace,
|
||||
};
|
||||
|
|
@ -33,6 +33,9 @@ fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> Trac
|
|||
call_keys: Vec::new(),
|
||||
call_evidence: None,
|
||||
tool_call_id: String::new(),
|
||||
source_type: String::new(),
|
||||
source_url: String::new(),
|
||||
source_title: String::new(),
|
||||
team_id: "team".into(),
|
||||
api_key_hash: "key".into(),
|
||||
user_id: String::new(),
|
||||
|
|
@ -173,6 +176,57 @@ fn summary_counts_model_calls_tools_and_agents() {
|
|||
assert_eq!(summary.spend, None);
|
||||
}
|
||||
|
||||
fn sourced(mut span: TraceSpansRow, url: &str, title: &str) -> TraceSpansRow {
|
||||
span.source_url = url.into();
|
||||
span.source_title = title.into();
|
||||
span
|
||||
}
|
||||
|
||||
const THREAD: &str = "https://acme.slack.com/archives/C1/p1";
|
||||
|
||||
#[rstest]
|
||||
#[case::root_wins(
|
||||
vec![sourced(at(row("root", "", "agent", "agent", "agent"), 5, 10), THREAD, "root thread"),
|
||||
sourced(at(row("tool", "root", "tool", "tool", "agent"), 0, 1), "https://other.example/", "child")],
|
||||
Some((THREAD, "root thread")),
|
||||
)]
|
||||
#[case::earliest_child_when_root_has_none(
|
||||
vec![at(row("root", "", "agent", "agent", "agent"), 0, 10),
|
||||
sourced(at(row("late", "root", "tool", "tool", "agent"), 5, 1), "https://late.example/", "late"),
|
||||
sourced(at(row("early", "root", "tool", "tool", "agent"), 2, 1), THREAD, "early")],
|
||||
Some((THREAD, "early")),
|
||||
)]
|
||||
#[case::non_https_is_dropped(
|
||||
vec![sourced(row("root", "", "agent", "agent", "agent"), "javascript:alert(1)", "x")],
|
||||
None,
|
||||
)]
|
||||
#[case::absent(vec![row("root", "", "agent", "agent", "agent")], None)]
|
||||
fn summary_source_links_where_the_run_started(
|
||||
#[case] rows: Vec<TraceSpansRow>,
|
||||
#[case] expected: Option<(&str, &str)>,
|
||||
) {
|
||||
let source = resolve_trace("t", "", &rows, &[]).unwrap().summary.source;
|
||||
assert_eq!(
|
||||
source
|
||||
.as_ref()
|
||||
.map(|source| (source.url.as_str(), source.title.as_str())),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::slack("slack", RunSourceType::Slack)]
|
||||
#[case::teams("teams", RunSourceType::Teams)]
|
||||
#[case::custom("custom", RunSourceType::Custom)]
|
||||
#[case::unknown_is_custom("my-bot", RunSourceType::Custom)]
|
||||
#[case::missing_is_custom("", RunSourceType::Custom)]
|
||||
fn summary_source_type_picks_the_app(#[case] source_type: &str, #[case] expected: RunSourceType) {
|
||||
let mut root = sourced(row("root", "", "agent", "agent", "agent"), THREAD, "t");
|
||||
root.source_type = source_type.into();
|
||||
let source = resolve_trace("t", "", &[root], &[]).unwrap().summary.source;
|
||||
assert_eq!(source.map(|source| source.kind), Some(expected));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn spans_are_offset_from_the_trace_start() {
|
||||
let trace = resolve_trace("t1", "", &deep_agent(1), &[]).unwrap();
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ class SpanErrorPage(typing_extensions.TypedDict):
|
|||
SpanStatus: TypeAlias = Literal["ok", "error", "unset"]
|
||||
|
||||
|
||||
RunSourceType: TypeAlias = Literal["slack", "teams", "discord", "linear", "github", "jira", "custom"]
|
||||
|
||||
|
||||
class AgentNode(typing_extensions.TypedDict):
|
||||
name: ReadOnly[str]
|
||||
parent_agent: ReadOnly[str | None]
|
||||
|
|
@ -102,29 +105,10 @@ class UIMessage(typing_extensions.TypedDict):
|
|||
tool_calls: ReadOnly[NotRequired[tuple[UIToolCall, ...]]]
|
||||
|
||||
|
||||
class TraceSummary(typing_extensions.TypedDict):
|
||||
resolution_limited: ReadOnly[NotRequired[bool]]
|
||||
trace_id: ReadOnly[str]
|
||||
trace_ref: ReadOnly[NotRequired[str]]
|
||||
name: ReadOnly[str]
|
||||
service: ReadOnly[str]
|
||||
agent_names: ReadOnly[NotRequired[tuple[str, ...]]]
|
||||
frameworks: ReadOnly[NotRequired[tuple[str, ...]]]
|
||||
input_preview: ReadOnly[str]
|
||||
start_time: ReadOnly[str]
|
||||
duration_ms: ReadOnly[float]
|
||||
status: ReadOnly[SpanStatus]
|
||||
span_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
agent_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
agent_invocations: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
llm_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
tool_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
error_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
input_tokens: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
output_tokens: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
models: ReadOnly[tuple[str, ...]]
|
||||
spend: ReadOnly[float | None]
|
||||
priced_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
class RunSource(typing_extensions.TypedDict):
|
||||
type: ReadOnly[RunSourceType]
|
||||
url: ReadOnly[str]
|
||||
title: ReadOnly[str]
|
||||
|
||||
|
||||
class Span(typing_extensions.TypedDict):
|
||||
|
|
@ -149,18 +133,6 @@ class Span(typing_extensions.TypedDict):
|
|||
spend_match: ReadOnly[SpendMatch | None | None]
|
||||
|
||||
|
||||
class Trace(typing_extensions.TypedDict):
|
||||
summary: ReadOnly[TraceSummary]
|
||||
agents: ReadOnly[tuple[AgentNode, ...]]
|
||||
spans: ReadOnly[tuple[Span, ...]]
|
||||
next_cursor: ReadOnly[NotRequired[str | None]]
|
||||
|
||||
|
||||
class TracePage(typing_extensions.TypedDict):
|
||||
data: ReadOnly[tuple[TraceSummary, ...]]
|
||||
next_cursor: ReadOnly[str | None]
|
||||
|
||||
|
||||
class UIMessages(typing_extensions.TypedDict):
|
||||
messages: ReadOnly[tuple[UIMessage, ...]]
|
||||
kind: ReadOnly[Literal["messages"]]
|
||||
|
|
@ -178,4 +150,42 @@ class SpanDetail(typing_extensions.TypedDict):
|
|||
attributes: ReadOnly[Mapping[str, str]]
|
||||
|
||||
|
||||
class TraceSummary(typing_extensions.TypedDict):
|
||||
resolution_limited: ReadOnly[NotRequired[bool]]
|
||||
trace_id: ReadOnly[str]
|
||||
trace_ref: ReadOnly[NotRequired[str]]
|
||||
name: ReadOnly[str]
|
||||
service: ReadOnly[str]
|
||||
agent_names: ReadOnly[NotRequired[tuple[str, ...]]]
|
||||
frameworks: ReadOnly[NotRequired[tuple[str, ...]]]
|
||||
input_preview: ReadOnly[str]
|
||||
start_time: ReadOnly[str]
|
||||
duration_ms: ReadOnly[float]
|
||||
status: ReadOnly[SpanStatus]
|
||||
span_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
agent_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
agent_invocations: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
llm_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
tool_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
error_count: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
input_tokens: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
output_tokens: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
models: ReadOnly[tuple[str, ...]]
|
||||
spend: ReadOnly[float | None]
|
||||
priced_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
source: ReadOnly[NotRequired[RunSource | None | None]]
|
||||
|
||||
|
||||
class Trace(typing_extensions.TypedDict):
|
||||
summary: ReadOnly[TraceSummary]
|
||||
agents: ReadOnly[tuple[AgentNode, ...]]
|
||||
spans: ReadOnly[tuple[Span, ...]]
|
||||
next_cursor: ReadOnly[NotRequired[str | None]]
|
||||
|
||||
|
||||
class TracePage(typing_extensions.TypedDict):
|
||||
data: ReadOnly[tuple[TraceSummary, ...]]
|
||||
next_cursor: ReadOnly[str | None]
|
||||
|
||||
|
||||
TraceWireTypes: TypeAlias = QueryScope | SpanDetail | SpanErrorPage | Trace | TracePage | TraceScope | ReadQueryName
|
||||
|
|
|
|||
|
|
@ -60,6 +60,38 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RunSource": {
|
||||
"description": "The conversation that started the run, from the `agent.source.*` span attributes.",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/$defs/RunSourceType"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RunSourceType": {
|
||||
"enum": [
|
||||
"slack",
|
||||
"teams",
|
||||
"discord",
|
||||
"linear",
|
||||
"github",
|
||||
"jira",
|
||||
"custom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"Span": {
|
||||
"properties": {
|
||||
"agent": {
|
||||
|
|
@ -293,6 +325,17 @@
|
|||
"service": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/RunSource"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"x-python-optional": true
|
||||
},
|
||||
"span_count": {
|
||||
"format": "uint64",
|
||||
"maximum": 18446744073709551615,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,37 @@
|
|||
{
|
||||
"$defs": {
|
||||
"RunSource": {
|
||||
"description": "The conversation that started the run, from the `agent.source.*` span attributes.",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/$defs/RunSourceType"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RunSourceType": {
|
||||
"enum": [
|
||||
"slack",
|
||||
"teams",
|
||||
"discord",
|
||||
"linear",
|
||||
"github",
|
||||
"jira",
|
||||
"custom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpanStatus": {
|
||||
"enum": [
|
||||
"ok",
|
||||
|
|
@ -89,6 +121,17 @@
|
|||
"service": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/RunSource"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"x-python-optional": true
|
||||
},
|
||||
"span_count": {
|
||||
"format": "uint64",
|
||||
"maximum": 18446744073709551615,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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 { SpanIcon } from "../../ui/SpanIcon";
|
||||
import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework";
|
||||
import type { SignalFlag, Trace } from "../../types";
|
||||
|
|
@ -166,6 +167,7 @@ export function RunHeader({
|
|||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<StatusPill failed={failed} />
|
||||
{signals.length > 0 && <SignalPills flags={signals} showScore className="flex-wrap" />}
|
||||
{summary.source && <RunSourceLink source={summary.source} />}
|
||||
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
|
||||
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
|
||||
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import slackLogo from "../../../../../public/assets/logos/slack.svg";
|
||||
import { sourceApp } from "./RunSource";
|
||||
|
||||
describe("sourceApp", () => {
|
||||
it.each([
|
||||
["slack", "https://acme.slack.com/archives/C1/p1", "Slack"],
|
||||
["teams", "https://teams.microsoft.com/l/message/19:abc/1", "Teams"],
|
||||
["discord", "https://discord.com/channels/1/2/3", "Discord"],
|
||||
["linear", "https://linear.app/acme/issue/LIT-1", "Linear"],
|
||||
["github", "https://github.com/BerriAI/litellm/issues/1", "GitHub"],
|
||||
["jira", "https://acme.atlassian.net/browse/LIT-1", "Jira"],
|
||||
] as const)("brands a %s url on its own domain", (type, url, label) => {
|
||||
expect(sourceApp({ type, url })?.label).toBe(label);
|
||||
});
|
||||
|
||||
it("shows the Slack logo for a slack.com thread", () => {
|
||||
expect(sourceApp({ type: "slack", url: "https://acme.slack.com/archives/C1/p1" })?.logo).toBe(slackLogo.src);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["off-domain url", "https://attacker.example/login", "attacker.example"],
|
||||
["lookalike suffix", "https://slack.com.attacker.example/x", "slack.com.attacker.example"],
|
||||
["lookalike prefix", "https://evilslack.com/x", "evilslack.com"],
|
||||
])("does not brand a slack source with an %s", (_, url, hostname) => {
|
||||
expect(sourceApp({ type: "slack", url })).toEqual({ label: hostname, logo: null });
|
||||
});
|
||||
|
||||
it("shows a custom source's hostname", () => {
|
||||
expect(sourceApp({ type: "custom", url: "https://bot.acme.dev/c/42" })).toEqual({
|
||||
label: "bot.acme.dev",
|
||||
logo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["javascript:alert(1)", "http://acme.slack.com/archives/C1/p1", "not a url", ""])("rejects %s", (bad) => {
|
||||
expect(sourceApp({ type: "slack", url: bad })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
|
||||
import githubLogo from "../../../../../public/assets/logos/github.svg";
|
||||
import jiraLogo from "../../../../../public/assets/logos/jira.svg";
|
||||
import linearLogo from "../../../../../public/assets/logos/linear.svg";
|
||||
import slackLogo from "../../../../../public/assets/logos/slack.svg";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
|
||||
import type { TraceSummary } from "../types";
|
||||
|
||||
type Source = NonNullable<TraceSummary["source"]>;
|
||||
type SourceType = Source["type"];
|
||||
|
||||
interface SourceApp {
|
||||
readonly label: string;
|
||||
readonly logo: string | null;
|
||||
}
|
||||
|
||||
interface BrandedApp extends SourceApp {
|
||||
readonly domains: readonly string[];
|
||||
}
|
||||
|
||||
const BRANDED: Readonly<Record<Exclude<SourceType, "custom">, BrandedApp>> = {
|
||||
slack: { label: "Slack", logo: slackLogo.src, domains: ["slack.com"] },
|
||||
teams: { label: "Teams", logo: null, domains: ["teams.microsoft.com", "teams.cloud.microsoft"] },
|
||||
discord: { label: "Discord", logo: null, domains: ["discord.com"] },
|
||||
linear: { label: "Linear", logo: linearLogo.src, domains: ["linear.app"] },
|
||||
github: { label: "GitHub", logo: githubLogo.src, domains: ["github.com"] },
|
||||
jira: { label: "Jira", logo: jiraLogo.src, domains: ["atlassian.net"] },
|
||||
};
|
||||
|
||||
const onDomain = (hostname: string, domain: string): boolean => hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
|
||||
/** Brands a source only when its URL is on that app's domain, so a trace can't dress up any link as Slack. */
|
||||
export function sourceApp(source: Pick<Source, "type" | "url">): SourceApp | null {
|
||||
const parsed = URL.canParse(source.url) ? new URL(source.url) : null;
|
||||
if (parsed?.protocol !== "https:") return null;
|
||||
const app = source.type === "custom" ? undefined : BRANDED[source.type];
|
||||
return app?.domains.some((domain) => onDomain(parsed.hostname, domain))
|
||||
? app
|
||||
: { label: parsed.hostname, logo: null };
|
||||
}
|
||||
|
||||
function AppMark({ app, className }: { app: SourceApp; className: string }) {
|
||||
return app.logo ? (
|
||||
<Logo src={app.logo} label={app.label} className={className} />
|
||||
) : (
|
||||
<MessagesSquare aria-hidden className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Links a run back to the conversation that started it, e.g. a Slack thread. */
|
||||
export function RunSourceLink({ source }: { source: Source }) {
|
||||
const app = sourceApp(source);
|
||||
if (!app) return null;
|
||||
return (
|
||||
<HoverCard>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Source
|
||||
<HoverCardTrigger
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Source: ${app.label}`}
|
||||
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<AppMark app={app} className="size-3 shrink-0" />
|
||||
{app.label}
|
||||
</HoverCardTrigger>
|
||||
</span>
|
||||
<HoverCardContent align="start" className="w-72 p-3">
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer" className="flex flex-col gap-1.5">
|
||||
<span className="line-clamp-2 text-sm font-medium text-foreground">
|
||||
{source.title || `Open in ${app.label}`}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<AppMark app={app} className="size-3 shrink-0" />
|
||||
{app.label}
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -44704,6 +44704,18 @@ export interface components {
|
|||
/** Start */
|
||||
start?: string | null;
|
||||
};
|
||||
/** RunSource */
|
||||
RunSource: {
|
||||
/** Title */
|
||||
title: string;
|
||||
/**
|
||||
* Type
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "slack" | "teams" | "discord" | "linear" | "github" | "jira" | "custom";
|
||||
/** Url */
|
||||
url: string;
|
||||
};
|
||||
/** SCIMEnterpriseUser */
|
||||
SCIMEnterpriseUser: {
|
||||
/** Costcenter */
|
||||
|
|
@ -47951,6 +47963,7 @@ export interface components {
|
|||
resolution_limited?: boolean;
|
||||
/** Service */
|
||||
service: string;
|
||||
source?: components["schemas"]["RunSource"] | null;
|
||||
/** Span Count */
|
||||
span_count: number;
|
||||
/** Spend */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue