Skip to content

Commit 36405ad

Browse files
shellmayrsergical
authored andcommitted
fix(nextjs-insights): add extraction pattern type and adapt for new SDK versions (#112456)
- The Next.js SDK >= 10.32.0 changed span descriptions for function.nextjs spans from `Page Server Component (/route)` to `resolve page server component "/route"`. The tree endpoint regex only matched the old format, causing the SSR File Tree widget to be empty. - Add a second regex pattern to handle the new format and normalize both to the same component_type + path output. --------- Co-authored-by: Sergiy Dybskiy <sergiy.dybskiy@sentry.io>
1 parent 0604267 commit 36405ad

2 files changed

Lines changed: 114 additions & 15 deletions

File tree

src/sentry/api/endpoints/organization_insights_tree.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import re
3+
from typing import NamedTuple
34

45
from rest_framework.request import Request
56
from rest_framework.response import Response
@@ -12,6 +13,33 @@
1213
logger = logging.getLogger(__name__)
1314

1415

16+
class _ExtractionPattern(NamedTuple):
17+
"""Extraction pattern for component type and path from span.description.
18+
group(1) = kind/type (mapped through kind_map when present), group(2) = path.
19+
"""
20+
21+
regex: re.Pattern[str]
22+
kind_map: dict[str, str] | None = None
23+
24+
25+
# SDK <10.32.0: '{component_type} ({path})'
26+
# e.g. 'Page Server Component (/dashboard)'
27+
_ParenthesisPattern = _ExtractionPattern(
28+
regex=re.compile(r"^(.*?)\s+\((.*?)\)$"),
29+
)
30+
31+
# SDK >=10.32.0: 'resolve {page|root layout|layout} server component "{route_or_segment}"'
32+
# e.g. 'resolve page server component "/dashboard"'
33+
_ResolvePattern = _ExtractionPattern(
34+
regex=re.compile(r'^resolve (page|root layout|layout) server component(?:\s+"(.*)")?$'),
35+
kind_map={
36+
"page": "Page Server Component",
37+
"layout": "Layout Server Component",
38+
"root layout": "Layout Server Component",
39+
},
40+
)
41+
42+
1543
@cell_silo_endpoint
1644
class OrganizationInsightsTreeEndpoint(OrganizationEventsEndpoint):
1745
"""
@@ -41,22 +69,27 @@ def get(self, request: Request, organization: Organization) -> Response:
4169
response = super().get(request, organization)
4270
return self._separate_span_description_info(response)
4371

44-
def _separate_span_description_info(self, response):
45-
# Regex to split string into '{component_type}{space}({path})'
46-
pattern = re.compile(r"^(.*?)\s+\((.*?)\)$")
72+
_EXTRACTION_PATTERNS = (_ParenthesisPattern, _ResolvePattern)
73+
74+
@staticmethod
75+
def _parse_path(path: str | None) -> list[str]:
76+
if not path:
77+
return []
78+
parts = path.strip("/").split("/")
79+
return parts if parts != [""] else []
4780

81+
def _separate_span_description_info(self, response):
4882
for line in response.data["data"]:
49-
match = pattern.match(line["span.description"])
50-
if match:
51-
component_type = match.group(1)
52-
path = match.group(2)
53-
path_components = path.strip("/").split("/")
54-
if not path_components or (len(path_components) == 1 and path_components[0] == ""):
55-
path_components = [] # Handle root path case
56-
57-
else:
58-
component_type = None
59-
path_components = []
83+
component_type = None
84+
path_components: list[str] = []
85+
86+
for pattern, kind_map in self._EXTRACTION_PATTERNS:
87+
match = pattern.match(line["span.description"])
88+
if match:
89+
component_type = kind_map[match.group(1)] if kind_map else match.group(1)
90+
path_components = self._parse_path(match.group(2))
91+
break
92+
6093
line["function.nextjs.component_type"] = component_type
6194
line["function.nextjs.path"] = path_components
6295

tests/sentry/api/endpoints/test_organization_insights_tree.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
22
from django.urls import reverse
33

4-
from sentry.testutils.cases import SnubaTestCase, SpanTestCase
4+
from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint
5+
from sentry.testutils.cases import SnubaTestCase, SpanTestCase, TestCase
56
from sentry.testutils.helpers.datetime import before_now
67
from sentry.testutils.skips import requires_snuba
78
from tests.snuba.api.endpoints.test_organization_events import OrganizationEventsEndpointTestBase
@@ -30,6 +31,7 @@ def setUp(self) -> None:
3031
self._store_unrelated_spans()
3132

3233
def _store_nextjs_function_spans(self) -> None:
34+
# Old SDK format (<10.32.0): '{ComponentType} Server Component ({route})'
3335
descriptions = [
3436
"Page Server Component (/app/dashboard/)",
3537
"Loading Server Component (/app/dashboard/)",
@@ -50,6 +52,13 @@ def _store_nextjs_function_spans(self) -> None:
5052
"Page Server Component (/app/[id]/)",
5153
"Page Server Component (/app/[...slug]/)",
5254
"Page Server Component (/app/[[...optional]]/)",
55+
# New SDK format (>=10.32.0): 'resolve {type} server component "{route_or_segment}"'
56+
'resolve page server component "/dashboard"',
57+
'resolve page server component "/nested-layout/[dynamic]"',
58+
'resolve layout server component "nested-layout"',
59+
'resolve layout server component "(route-group)"',
60+
'resolve layout server component "[dynamic]"',
61+
"resolve root layout server component",
5362
"unrelated description",
5463
]
5564
spans = []
@@ -125,4 +134,61 @@ def test_get_nextjs_function_data(self) -> None:
125134
assert element["function.nextjs.component_type"] == "Page Server Component"
126135
assert element["function.nextjs.path"] == ["app", "[...slug]"]
127136

137+
# New SDK format: resolve page server component with full route
138+
resolve_page_idx = span_descriptions.index(
139+
'resolve page server component "/nested-layout/[dynamic]"'
140+
)
141+
element = response.data["data"][resolve_page_idx]
142+
assert element["function.nextjs.component_type"] == "Page Server Component"
143+
assert element["function.nextjs.path"] == ["nested-layout", "[dynamic]"]
144+
145+
# New SDK format: resolve layout server component with segment
146+
resolve_layout_idx = span_descriptions.index(
147+
'resolve layout server component "nested-layout"'
148+
)
149+
element = response.data["data"][resolve_layout_idx]
150+
assert element["function.nextjs.component_type"] == "Layout Server Component"
151+
assert element["function.nextjs.path"] == ["nested-layout"]
152+
153+
# New SDK format: resolve root layout server component (no path)
154+
resolve_root_idx = span_descriptions.index("resolve root layout server component")
155+
element = response.data["data"][resolve_root_idx]
156+
assert element["function.nextjs.component_type"] == "Layout Server Component"
157+
assert element["function.nextjs.path"] == []
158+
159+
# New SDK format: route group segment
160+
resolve_group_idx = span_descriptions.index(
161+
'resolve layout server component "(route-group)"'
162+
)
163+
element = response.data["data"][resolve_group_idx]
164+
assert element["function.nextjs.component_type"] == "Layout Server Component"
165+
assert element["function.nextjs.path"] == ["(route-group)"]
166+
128167
assert "INSERT value INTO table" not in span_descriptions
168+
169+
170+
class DescriptionParsingTest(TestCase):
171+
"""Verifies that old (<10.32.0) and new (>=10.32.0) SDK formats produce identical parsed output."""
172+
173+
_endpoint = OrganizationInsightsTreeEndpoint()
174+
175+
def _parse(self, desc):
176+
response = type("R", (), {"data": {"data": [{"span.description": desc}]}})()
177+
self._endpoint._separate_span_description_info(response)
178+
row = response.data["data"][0]
179+
return row["function.nextjs.component_type"], row["function.nextjs.path"]
180+
181+
def test_page_simple_route(self):
182+
assert self._parse("Page Server Component (/dashboard)") == self._parse(
183+
'resolve page server component "/dashboard"'
184+
)
185+
186+
def test_page_nested_route(self):
187+
assert self._parse("Page Server Component (/nested/route)") == self._parse(
188+
'resolve page server component "/nested/route"'
189+
)
190+
191+
def test_layout_simple_route(self):
192+
assert self._parse("Layout Server Component (/settings)") == self._parse(
193+
'resolve layout server component "/settings"'
194+
)

0 commit comments

Comments
 (0)