Skip to content
106 changes: 75 additions & 31 deletions src/sentry/seer/explorer/index_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
normalize_description,
)
from sentry.seer.sentry_data_models import (
EvidenceSpan,
EvidenceTraceData,
IssueDetails,
ProfileData,
Span,
Expand Down Expand Up @@ -113,7 +115,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 | EvidenceTraceData | None:
"""
Get a sample trace for a given transaction, choosing the one with median span count.

Expand Down Expand Up @@ -177,16 +181,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,31 +203,67 @@ def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceDa
sampling_mode="NORMAL",
)

# Step 4: 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")

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 "",
# Step 3: Build span objects
if llm_issue_detection:
evidence_spans: list[EvidenceSpan] = []
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")
span_timestamp = row.get("precise.start_ts")

if span_id:
evidence_spans.append(
EvidenceSpan(
span_id=span_id,
parent_span_id=parent_span_id,
op=span_op,
description=span_description or "",
exclusive_time=span_exclusive_time,
timestamp=span_timestamp,
data={
"duration": span_duration,
"status": span_status,
},
)
)
)

return TraceData(
trace_id=trace_id,
project_id=project_id,
transaction_name=transaction_name,
total_spans=len(spans),
spans=spans,
)
return EvidenceTraceData(
trace_id=trace_id,
project_id=project_id,
transaction_name=transaction_name,
total_spans=len(evidence_spans),
spans=evidence_spans,
)
else:
spans: list[Span] = []
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")

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

return TraceData(
trace_id=trace_id,
project_id=project_id,
transaction_name=transaction_name,
total_spans=len(spans),
spans=spans,
)


def _fetch_and_process_profile(
Expand Down
18 changes: 18 additions & 0 deletions src/sentry/seer/sentry_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ class Span(BaseModel):
span_description: str | None


class EvidenceSpan(BaseModel):
span_id: str | None = None
parent_span_id: str | None = None
timestamp: float | None = None
op: str | None = None
description: str | None = None
exclusive_time: float | None = None # duration in milliseconds
data: dict[str, Any] | None = None


class TraceData(BaseModel):
trace_id: str
project_id: int
Expand All @@ -30,6 +40,14 @@ class TraceData(BaseModel):
spans: list[Span]


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


class EAPTrace(BaseModel):
"""
Based on the Seer model. `trace` can contain both span and error events (see `SerializedEvent`).
Expand Down
10 changes: 6 additions & 4 deletions src/sentry/tasks/llm_issue_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from sentry.net.http import connection_from_url
from sentry.seer.explorer.index_data import get_trace_for_transaction, get_transactions_for_project
from sentry.seer.models import SeerApiError
from sentry.seer.sentry_data_models import TraceData
from sentry.seer.sentry_data_models import EvidenceTraceData
from sentry.seer.signed_seer_api import make_signed_seer_api_request
from sentry.tasks.base import instrumented_task
from sentry.taskworker.namespaces import issues_tasks
Expand Down Expand Up @@ -73,7 +73,7 @@ def __init__(

def create_issue_occurrence_from_detection(
detected_issue: DetectedIssue,
trace: TraceData,
trace: EvidenceTraceData,
project_id: int,
transaction_name: str,
) -> None:
Expand Down Expand Up @@ -197,11 +197,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 not isinstance(trace, EvidenceTraceData)
or trace.total_spans < LOWER_SPAN_LIMIT
or trace.total_spans > UPPER_SPAN_LIMIT
):
Expand Down
69 changes: 69 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, EvidenceTraceData
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,74 @@ 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 EvidenceTraceData with EvidenceSpan objects
assert isinstance(result, EvidenceTraceData)
for result_span in result.spans:
assert isinstance(result_span, EvidenceSpan)
assert result_span.span_id is not None
assert result_span.description is not None
assert result_span.description.startswith("span-")
assert "trace-medium" in result_span.description
assert hasattr(result_span, "op")
assert hasattr(result_span, "exclusive_time")
assert hasattr(result_span, "data")
assert result_span.data is not None
assert "duration" in result_span.data
assert "status" in result_span.data

# 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
27 changes: 20 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, EvidenceTraceData
from sentry.tasks.llm_issue_detection import (
DetectedIssue,
create_issue_occurrence_from_detection,
Expand Down Expand Up @@ -160,13 +161,25 @@ 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,
op="db.query",
description="SELECT * FROM users",
exclusive_time=150.5,
data={
"duration": 200.0,
"status": "ok",
},
)

mock_trace = EvidenceTraceData(
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