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:
ishaan-berri 2026-10-07 15:32:37 -07:00 • committed by GitHub
parent 679f7e636e
commit df23f11e9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 413 additions and 41 deletions

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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})

View file

@ -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})

View file

@ -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}

View file

@ -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}

View file

@ -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>(

View file

@ -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,
};

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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)]

View file

@ -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(),

View file

@ -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"}}),

View file

@ -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();

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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)} />

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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 */