diff --git a/litellm-rust/crates/traces-cache/src/cache.rs b/litellm-rust/crates/traces-cache/src/cache.rs index f37db2c1c90..287160df96b 100644 --- a/litellm-rust/crates/traces-cache/src/cache.rs +++ b/litellm-rust/crates/traces-cache/src/cache.rs @@ -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(), diff --git a/litellm-rust/crates/traces-cache/tests/read.rs b/litellm-rust/crates/traces-cache/tests/read.rs index 18a83329107..ade70b71800 100644 --- a/litellm-rust/crates/traces-cache/tests/read.rs +++ b/litellm-rust/crates/traces-cache/tests/read.rs @@ -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(), diff --git a/litellm-rust/crates/traces-cache/tests/snapshots.rs b/litellm-rust/crates/traces-cache/tests/snapshots.rs index 0a206004080..bebff0bb1aa 100644 --- a/litellm-rust/crates/traces-cache/tests/snapshots.rs +++ b/litellm-rust/crates/traces-cache/tests/snapshots.rs @@ -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(), 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 d6ab416dfd3..b764005024e 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 @@ -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}) 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 27a8e2ac0da..5af30920df9 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_page_spans.sql @@ -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}) 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 a084325feaa..c7d50a44544 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_span_batch.sql @@ -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} diff --git a/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql b/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql index 306692e9709..974a6d050a4 100644 --- a/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql +++ b/litellm-rust/crates/traces-clickhouse/query/trace_spans.sql @@ -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} diff --git a/litellm-rust/crates/traces-clickhouse/src/query/named.rs b/litellm-rust/crates/traces-clickhouse/src/query/named.rs index b7e70632480..1b98ad39912 100644 --- a/litellm-rust/crates/traces-clickhouse/src/query/named.rs +++ b/litellm-rust/crates/traces-clickhouse/src/query/named.rs @@ -128,6 +128,12 @@ struct TraceSpansRowEncoding { pub call_evidence: Option, #[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::( - 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::( diff --git a/litellm-rust/crates/traces/src/lib.rs b/litellm-rust/crates/traces/src/lib.rs index b51701638e8..42aa06c8994 100644 --- a/litellm-rust/crates/traces/src/lib.rs +++ b/litellm-rust/crates/traces/src/lib.rs @@ -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, }; diff --git a/litellm-rust/crates/traces/src/query/named.rs b/litellm-rust/crates/traces/src/query/named.rs index 757012de16f..03459645ca9 100644 --- a/litellm-rust/crates/traces/src/query/named.rs +++ b/litellm-rust/crates/traces/src/query/named.rs @@ -110,6 +110,12 @@ pub struct TraceSpansRow { pub call_evidence: Option, #[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, diff --git a/litellm-rust/crates/traces/src/resolve/view.rs b/litellm-rust/crates/traces/src/resolve/view.rs index 9e9edd51bf4..97f83c255f8 100644 --- a/litellm-rust/crates/traces/src/resolve/view.rs +++ b/litellm-rust/crates/traces/src/resolve/view.rs @@ -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 { + 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) -> Vec { 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, } } diff --git a/litellm-rust/crates/traces/src/view.rs b/litellm-rust/crates/traces/src/view.rs index 5a214d33da7..1613d96d232 100644 --- a/litellm-rust/crates/traces/src/view.rs +++ b/litellm-rust/crates/traces/src/view.rs @@ -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, pub spend: Option, pub priced_calls: u64, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schemars(extend("x-python-optional" = true)))] + pub source: Option, } #[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 ff20db617f6..922e28f97ac 100644 --- a/litellm-rust/crates/traces/tests/captures.rs +++ b/litellm-rust/crates/traces/tests/captures.rs @@ -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(), diff --git a/litellm-rust/crates/traces/tests/query/named.rs b/litellm-rust/crates/traces/tests/query/named.rs index 3ac9668e42a..25cfd7083a5 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", "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::( 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 8d65adb0c0f..baaa4c338cf 100644 --- a/litellm-rust/crates/traces/tests/resolve.rs +++ b/litellm-rust/crates/traces/tests/resolve.rs @@ -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, + #[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(); diff --git a/litellm/rust_bridge/trace/generated/types.py b/litellm/rust_bridge/trace/generated/types.py index 4a09e731228..620255016c5 100644 --- a/litellm/rust_bridge/trace/generated/types.py +++ b/litellm/rust_bridge/trace/generated/types.py @@ -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 diff --git a/scripts/trace_codegen/schemas/traces/Trace.json b/scripts/trace_codegen/schemas/traces/Trace.json index ebd99880956..3fcbf654101 100644 --- a/scripts/trace_codegen/schemas/traces/Trace.json +++ b/scripts/trace_codegen/schemas/traces/Trace.json @@ -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, diff --git a/scripts/trace_codegen/schemas/traces/TracePage.json b/scripts/trace_codegen/schemas/traces/TracePage.json index 429635ec3b1..9d437bd60a1 100644 --- a/scripts/trace_codegen/schemas/traces/TracePage.json +++ b/scripts/trace_codegen/schemas/traces/TracePage.json @@ -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, 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 90127f53c3a..cfe8674a6c5 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 @@ -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({
{signals.length > 0 && } + {summary.source && } diff --git a/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.test.ts b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.test.ts new file mode 100644 index 00000000000..f70acb6b497 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx new file mode 100644 index 00000000000..fa4ad9e6f8a --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/ui/RunSource.tsx @@ -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; +type SourceType = Source["type"]; + +interface SourceApp { + readonly label: string; + readonly logo: string | null; +} + +interface BrandedApp extends SourceApp { + readonly domains: readonly string[]; +} + +const BRANDED: Readonly, 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): 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 ? ( + + ) : ( + + ); +} + +/** 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 ( + + + Source + + + {app.label} + + + + + + {source.title || `Open in ${app.label}`} + + + + {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 76048eda8b4..aeffcbfe230 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */