Skip to content

Commit 331109b

Browse files
committed
feat(autofix): Attempt to fetch logs (#92111)
Attempts to collect logs around an event and send them to Seer. Wrapped with a try-except in case it breaks.
1 parent 3c35fc4 commit 331109b

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed

src/sentry/seer/autofix.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
from sentry.models.group import Group
2020
from sentry.models.project import Project
2121
from sentry.profiles.utils import get_from_profiling_service
22+
from sentry.search.events.types import EventsResponse, SnubaParams
2223
from sentry.seer.seer_setup import get_seer_org_acknowledgement
2324
from sentry.seer.signed_seer_api import sign_with_seer_secret
25+
from sentry.snuba import ourlogs
2426
from sentry.snuba.dataset import Dataset
2527
from sentry.snuba.referrer import Referrer
2628
from sentry.tasks.autofix import check_autofix_status
@@ -33,6 +35,110 @@
3335
TIMEOUT_SECONDS = 60 * 30 # 30 minutes
3436

3537

38+
def _get_logs_for_event(event: Event | GroupEvent, project: Project) -> list[dict] | None:
39+
trace_id = event.trace_id
40+
if not trace_id:
41+
return None
42+
43+
projects_qs = Project.objects.filter(
44+
organization=project.organization, status=ObjectStatus.ACTIVE
45+
)
46+
projects = list(projects_qs)
47+
project_id_to_slug = dict(projects_qs.values_list("id", "slug"))
48+
start = event.datetime - timedelta(days=1)
49+
end = event.datetime + timedelta(days=1)
50+
51+
snuba_params = SnubaParams(
52+
start=start,
53+
end=end,
54+
projects=projects,
55+
organization=project.organization,
56+
)
57+
58+
results: EventsResponse = ourlogs.query(
59+
selected_columns=[
60+
"project.id",
61+
"timestamp",
62+
"message",
63+
"severity",
64+
"code.file.path",
65+
"code.function.name",
66+
],
67+
query=f"trace_id:{trace_id}",
68+
snuba_params=snuba_params,
69+
orderby=["-timestamp"],
70+
offset=0,
71+
limit=100,
72+
referrer=Referrer.API_GROUP_AI_AUTOFIX,
73+
)
74+
data = results["data"]
75+
76+
# Convert log timestamps to datetime and sort by timestamp ascending (oldest first)
77+
for log in data:
78+
ts = log.get("timestamp")
79+
if ts:
80+
try:
81+
log["_parsed_ts"] = datetime.fromisoformat(ts)
82+
except Exception:
83+
log["_parsed_ts"] = None
84+
else:
85+
log["_parsed_ts"] = None
86+
87+
# Sort logs by timestamp ascending (oldest first)
88+
data.sort(key=lambda x: x.get("_parsed_ts") or datetime.min)
89+
90+
# Find the index of the log closest to the event timestamp (faster with min and enumerate)
91+
closest_idx = 0
92+
if data:
93+
valid_logs = [(i, log) for i, log in enumerate(data) if log.get("_parsed_ts") is not None]
94+
if valid_logs:
95+
closest_idx, _ = min(
96+
(
97+
(i, abs((log["_parsed_ts"] - event.datetime).total_seconds()))
98+
for i, log in valid_logs
99+
),
100+
key=lambda x: x[1],
101+
default=(0, None),
102+
)
103+
104+
# Select up to 80 logs before and up to 20 logs after (including the closest)
105+
start_idx = max(0, closest_idx - 80)
106+
end_idx = min(len(data), closest_idx + 21)
107+
window = data[start_idx:end_idx]
108+
109+
# Merge and count consecutive logs with identical message and severity
110+
merged_logs = []
111+
prev_log = None
112+
count = 0
113+
for log in window:
114+
project_id = log.get("project.id")
115+
log["project_slug"] = project_id_to_slug.get(project_id) if project_id else None
116+
log["code_file_path"] = log.get("code.file.path")
117+
log["code_function_name"] = log.get("code.function.name")
118+
log.pop("code.file.path", None)
119+
log.pop("code.function.name", None)
120+
log.pop("_parsed_ts", None)
121+
log.pop("project.id", None)
122+
123+
msg = log.get("message")
124+
sev = log.get("severity")
125+
if prev_log and msg == prev_log["message"] and sev == prev_log["severity"]:
126+
count += 1
127+
else:
128+
if prev_log:
129+
if count > 1:
130+
prev_log["consecutive_count"] = count
131+
merged_logs.append(prev_log)
132+
prev_log = log.copy()
133+
count = 1
134+
if prev_log:
135+
if count > 1:
136+
prev_log["consecutive_count"] = count
137+
merged_logs.append(prev_log)
138+
139+
return merged_logs
140+
141+
36142
def build_spans_tree(spans_data: list[dict]) -> list[dict]:
37143
"""
38144
Builds a hierarchical tree structure from a flat list of spans.
@@ -676,6 +782,7 @@ def _call_autofix(
676782
serialized_event: dict[str, Any],
677783
profile: dict[str, Any] | None,
678784
trace_tree: dict[str, Any] | None,
785+
logs: list[dict] | None,
679786
instruction: str | None = None,
680787
timeout_secs: int = TIMEOUT_SECONDS,
681788
pr_to_comment_on_url: str | None = None,
@@ -696,6 +803,7 @@ def _call_autofix(
696803
},
697804
"profile": profile,
698805
"trace_tree": trace_tree,
806+
"logs": logs,
699807
"instruction": instruction,
700808
"timeout_secs": timeout_secs,
701809
"last_updated": datetime.now().isoformat(),
@@ -791,6 +899,13 @@ def trigger_autofix(
791899
logger.exception("Failed to get profile from trace tree")
792900
profile = None
793901

902+
# get logs for this event
903+
try:
904+
logs = _get_logs_for_event(event, group.project) if event else None
905+
except Exception:
906+
logger.exception("Failed to get logs for event")
907+
logs = None
908+
794909
try:
795910
run_id = _call_autofix(
796911
user=user,
@@ -799,6 +914,7 @@ def trigger_autofix(
799914
serialized_event=serialized_event,
800915
profile=profile,
801916
trace_tree=trace_tree,
917+
logs=logs,
802918
instruction=instruction,
803919
timeout_secs=TIMEOUT_SECONDS,
804920
pr_to_comment_on_url=pr_to_comment_on_url,

tests/sentry/seer/test_autofix.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
TIMEOUT_SECONDS,
1010
_call_autofix,
1111
_convert_profile_to_execution_tree,
12+
_get_logs_for_event,
1213
_get_profile_from_trace_tree,
1314
_get_trace_tree_for_event,
1415
_respond_with_error,
@@ -1335,6 +1336,7 @@ def test_call_autofix(self, mock_sign, mock_post):
13351336
serialized_event = {"event_id": "test-event"}
13361337
profile = {"profile_data": "test"}
13371338
trace_tree = {"trace_data": "test"}
1339+
logs = [{"message": "test-log"}]
13381340
instruction = "Test instruction"
13391341

13401342
# Call the function with keyword arguments
@@ -1345,6 +1347,7 @@ def test_call_autofix(self, mock_sign, mock_post):
13451347
serialized_event=serialized_event,
13461348
profile=profile,
13471349
trace_tree=trace_tree,
1350+
logs=logs,
13481351
instruction=instruction,
13491352
timeout_secs=TIMEOUT_SECONDS,
13501353
pr_to_comment_on_url="https://github.com/getsentry/sentry/pull/123",
@@ -1587,3 +1590,68 @@ def test_build_spans_tree_duration_sorting(self):
15871590
assert root["children"][0]["span_id"] == "slow-child"
15881591
assert root["children"][1]["span_id"] == "medium-child"
15891592
assert root["children"][2]["span_id"] == "fast-child"
1593+
1594+
1595+
class TestGetLogsForEvent(TestCase):
1596+
def setUp(self):
1597+
super().setUp()
1598+
self.organization = self.create_organization()
1599+
self.project = self.create_project(organization=self.organization)
1600+
self.trace_id = "1234567890abcdef1234567890abcdef"
1601+
self.now = before_now(minutes=0)
1602+
1603+
@patch("sentry.snuba.ourlogs.query")
1604+
def test_merging_consecutive_logs(self, mock_query):
1605+
# Simulate logs with identical message/severity in sequence
1606+
dt = self.now
1607+
logs = [
1608+
{
1609+
"project.id": self.project.id,
1610+
"timestamp": (dt - timedelta(seconds=3)).isoformat(),
1611+
"message": "foo",
1612+
"severity": "info",
1613+
},
1614+
{
1615+
"project.id": self.project.id,
1616+
"timestamp": (dt - timedelta(seconds=2)).isoformat(),
1617+
"message": "foo",
1618+
"severity": "info",
1619+
},
1620+
{
1621+
"project.id": self.project.id,
1622+
"timestamp": (dt - timedelta(seconds=1)).isoformat(),
1623+
"message": "bar",
1624+
"severity": "error",
1625+
},
1626+
{
1627+
"project.id": self.project.id,
1628+
"timestamp": dt.isoformat(),
1629+
"message": "foo",
1630+
"severity": "info",
1631+
},
1632+
]
1633+
mock_query.return_value = {"data": logs}
1634+
# Use a mock event with datetime at dt
1635+
event = Mock()
1636+
event.trace_id = self.trace_id
1637+
event.datetime = dt
1638+
project = self.project
1639+
# Patch project.organization to avoid DB hits
1640+
project.organization = self.organization
1641+
merged = _get_logs_for_event(event, project)
1642+
assert merged is not None
1643+
# The first two "foo" logs should be merged (consecutive), the last "foo" is not consecutive
1644+
foo_merged = [
1645+
log for log in merged if log["message"] == "foo" and log.get("consecutive_count") == 2
1646+
]
1647+
foo_single = [
1648+
log for log in merged if log["message"] == "foo" and "consecutive_count" not in log
1649+
]
1650+
bar = [log for log in merged if log["message"] == "bar"]
1651+
assert len(foo_merged) == 1
1652+
assert len(foo_single) == 1
1653+
assert len(bar) == 1
1654+
# Order: merged foo, bar, single foo
1655+
assert merged[0]["message"] == "foo" and merged[0]["consecutive_count"] == 2
1656+
assert merged[1]["message"] == "bar"
1657+
assert merged[2]["message"] == "foo" and "consecutive_count" not in merged[2]

0 commit comments

Comments
 (0)