Skip to content
55 changes: 39 additions & 16 deletions src/sentry/seer/explorer/index_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
normalize_description,
)
from sentry.seer.sentry_data_models import (
EvidenceSpan,
IssueDetails,
ProfileData,
Span,
Expand Down Expand Up @@ -113,7 +114,9 @@ def get_transactions_for_project(
return transactions


def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceData | None:
def get_trace_for_transaction(
transaction_name: str, project_id: int, llm_issue_detection: bool = False
) -> TraceData | None:
"""
Get a sample trace for a given transaction, choosing the one with median span count.

Expand Down Expand Up @@ -177,16 +180,20 @@ def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceDa
return None

# Step 2: Get all spans in the chosen trace
selected_columns = [
"span_id",
"parent_span",
"span.op",
"span.description",
"precise.start_ts",
]
if llm_issue_detection:
selected_columns.extend(["span.self_time", "span.duration", "span.status"])

spans_result = Spans.run_table_query(
params=snuba_params,
query_string=f"trace:{trace_id}",
selected_columns=[
"span_id",
"parent_span",
"span.op",
"span.description",
"precise.start_ts",
],
selected_columns=selected_columns,
orderby=["precise.start_ts"],
offset=0,
limit=1000,
Expand All @@ -195,23 +202,39 @@ def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceDa
sampling_mode="NORMAL",
)

# Step 4: Build span objects
# Step 3: Build span objects
spans = []
for row in spans_result.get("data", []):
span_id = row.get("span_id")
parent_span_id = row.get("parent_span")
span_op = row.get("span.op")
span_description = row.get("span.description")
span_exclusive_time = row.get("span.self_time")
span_duration = row.get("span.duration")
span_status = row.get("span.status")

if span_id:
spans.append(
Span(
span_id=span_id,
parent_span_id=parent_span_id,
span_op=span_op,
span_description=span_description or "",
if llm_issue_detection:
spans.append(
EvidenceSpan(
span_id=span_id,
parent_span_id=parent_span_id,
span_op=span_op,
span_description=span_description or "",
span_exclusive_time=span_exclusive_time,
span_duration=span_duration,
span_status=span_status,
)
)
else:
spans.append(
Span(
span_id=span_id,
parent_span_id=parent_span_id,
span_op=span_op,
span_description=span_description or "",
)
)
)

return TraceData(
trace_id=trace_id,
Expand Down
8 changes: 7 additions & 1 deletion src/sentry/seer/sentry_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ class Span(BaseModel):
span_description: str | None


class EvidenceSpan(Span):
span_exclusive_time: float | None = None
span_duration: float | None = None
span_status: str | None = None


class TraceData(BaseModel):
trace_id: str
project_id: int
transaction_name: str
total_spans: int
spans: list[Span]
spans: list[Span] | list[EvidenceSpan]


class EAPTrace(BaseModel):
Expand Down
15 changes: 8 additions & 7 deletions src/sentry/tasks/llm_issue_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class DetectedIssue(BaseModel):
evidence: str
missing_telemetry: str | None = None
title: str
confidence_score: float | None = None


class IssueDetectionResponse(BaseModel):
Expand Down Expand Up @@ -94,6 +95,7 @@ def create_issue_occurrence_from_detection(
"impact": detected_issue.impact,
"evidence": detected_issue.evidence,
"missing_telemetry": detected_issue.missing_telemetry,
"confidence_score": detected_issue.confidence_score,
}

evidence_display = [
Expand Down Expand Up @@ -197,14 +199,13 @@ def detect_llm_issues_for_project(project_id: int) -> None:
break

try:
trace: TraceData | None = get_trace_for_transaction(
transaction.name, transaction.project_id
trace = get_trace_for_transaction(
transaction.name, transaction.project_id, llm_issue_detection=True
)
if (
not trace
or trace.total_spans < LOWER_SPAN_LIMIT
or trace.total_spans > UPPER_SPAN_LIMIT
):
if not trace:
continue

if trace.total_spans < LOWER_SPAN_LIMIT or trace.total_spans > UPPER_SPAN_LIMIT:
continue

processed_count += 1
Expand Down
67 changes: 67 additions & 0 deletions tests/sentry/seer/explorer/test_index_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
get_trace_for_transaction,
get_transactions_for_project,
)
from sentry.seer.sentry_data_models import EvidenceSpan, LLMDetectionTraceData
from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase
from sentry.testutils.helpers.datetime import before_now
from tests.snuba.search.test_backend import SharedSnubaMixin
Expand Down Expand Up @@ -146,6 +147,72 @@ def test_get_trace_for_transaction(self) -> None:
root_spans = [s for s in result.spans if s.parent_span_id is None]
assert len(root_spans) == 1 # Should have exactly one root span

def test_get_trace_for_transaction_llm_detection(self) -> None:
transaction_name = "api/users/profile"

# Create multiple traces with different span counts (same as test_get_trace_for_transaction)
traces_data = [
(5, "trace-medium", 0),
(2, "trace-small", 10),
(8, "trace-large", 20),
]

spans = []
trace_ids = []
expected_trace_id = None

for span_count, trace_suffix, start_offset_minutes in traces_data:
trace_id = uuid.uuid4().hex
trace_ids.append(trace_id)
if trace_suffix == "trace-medium":
expected_trace_id = trace_id

for i in range(span_count):
span = self.create_span(
{
"description": f"span-{i}-{trace_suffix}",
"sentry_tags": {"transaction": transaction_name},
"trace_id": trace_id,
"parent_span_id": None if i == 0 else f"parent-{i-1}",
"is_segment": i == 0,
},
start_ts=self.ten_mins_ago + timedelta(minutes=start_offset_minutes + i),
)
spans.append(span)

self.store_spans(spans, is_eap=True)

# Call with llm_detection=True
result = get_trace_for_transaction(
transaction_name, self.project.id, llm_issue_detection=True
)

# Verify basic structure (same checks as regular test)
assert result is not None
assert result.transaction_name == transaction_name
assert result.project_id == self.project.id
assert result.trace_id in trace_ids
assert result.trace_id == expected_trace_id
assert result.total_spans == 5
assert len(result.spans) == 5

# Verify it's LLMDetectionTraceData with EvidenceSpan objects
assert isinstance(result, LLMDetectionTraceData)
for result_span in result.spans:
assert isinstance(result_span, EvidenceSpan)
assert result_span.span_id is not None
assert result_span.span_description is not None
assert result_span.span_description.startswith("span-")
assert "trace-medium" in result_span.span_description
assert hasattr(result_span, "span_op")
assert hasattr(result_span, "span_exclusive_time")
assert hasattr(result_span, "span_status")
assert hasattr(result_span, "span_duration")

# Verify parent-child relationships are preserved
root_spans = [s for s in result.spans if s.parent_span_id is None]
assert len(root_spans) == 1


class TestGetProfilesForTrace(APITransactionTestCase, SnubaTestCase, SpanTestCase):
def setUp(self) -> None:
Expand Down
25 changes: 18 additions & 7 deletions tests/sentry/tasks/test_llm_issue_detection.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from unittest.mock import Mock, patch

from sentry.issues.grouptype import LLMDetectedExperimentalGroupType
from sentry.seer.sentry_data_models import EvidenceSpan, TraceData
from sentry.tasks.llm_issue_detection import (
DetectedIssue,
create_issue_occurrence_from_detection,
Expand Down Expand Up @@ -160,13 +161,23 @@ def test_detect_llm_issues_full_flow(
mock_get_transactions.return_value = [mock_transaction]
mock_sample.side_effect = lambda x, n: x

mock_trace = Mock()
mock_trace.trace_id = "trace-abc-123"
mock_trace.total_spans = 100
mock_trace.dict.return_value = {
"trace_id": "trace-abc-123",
"spans": [{"op": "db.query", "duration": 1.5}],
}
mock_span = EvidenceSpan(
span_id="span123",
parent_span_id=None,
span_op="db.query",
span_description="SELECT * FROM users",
span_exclusive_time=150.5,
span_duration=200.0,
span_status="ok",
)

mock_trace = TraceData(
trace_id="trace-abc-123",
project_id=self.project.id,
transaction_name="api/users/list",
total_spans=100,
spans=[mock_span],
)
mock_get_trace.return_value = mock_trace

seer_response_data = {
Expand Down
Loading