Skip to content

Commit 1dadf6c

Browse files
committed
feat(investigations): add contracts and serializers
1 parent 3bdb176 commit 1dadf6c

10 files changed

Lines changed: 1230 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from django.utils.dateparse import parse_datetime
6+
from rest_framework import serializers
7+
8+
from sentry.utils import json
9+
10+
MAX_TABLE_ROWS = 100
11+
MAX_CHART_SERIES = 5
12+
MAX_POINTS_PER_SERIES = 200
13+
MAX_ARTIFACT_BYTES = 1024 * 1024
14+
MAX_MARKDOWN_CHARS = 100_000
15+
16+
17+
class StrictContractSerializer(serializers.Serializer[Any]):
18+
def to_internal_value(self, data: Any) -> dict[str, Any]:
19+
if isinstance(data, dict):
20+
unknown = sorted(set(data) - set(self.fields))
21+
if unknown:
22+
raise serializers.ValidationError({field: "Unknown field." for field in unknown})
23+
return super().to_internal_value(data)
24+
25+
26+
class QueryTimeRangeSerializer(StrictContractSerializer):
27+
statsPeriod = serializers.CharField(required=False, allow_null=True)
28+
start = serializers.CharField(required=False, allow_null=True)
29+
end = serializers.CharField(required=False, allow_null=True)
30+
31+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
32+
if bool(attrs.get("start")) != bool(attrs.get("end")):
33+
raise serializers.ValidationError("start and end must be provided together")
34+
if attrs.get("statsPeriod") and attrs.get("start"):
35+
raise serializers.ValidationError("Use a relative or absolute time range, not both.")
36+
return attrs
37+
38+
39+
class CanonicalQuerySerializer(StrictContractSerializer):
40+
dataset = serializers.ChoiceField(choices=("spans", "issues", "errors", "logs", "metrics"))
41+
query = serializers.CharField(allow_blank=True)
42+
mode = serializers.CharField(allow_blank=True)
43+
fields = serializers.ListField( # type: ignore[assignment]
44+
child=serializers.CharField(), required=False, default=list
45+
)
46+
yAxes = serializers.ListField(child=serializers.CharField(), required=False, default=list)
47+
groupBy = serializers.ListField(child=serializers.CharField(), required=False, default=list)
48+
sort = serializers.CharField(required=False, allow_blank=True, default="")
49+
interval = serializers.CharField(required=False, allow_null=True)
50+
timeRange = QueryTimeRangeSerializer()
51+
projectIds = serializers.ListField(
52+
child=serializers.IntegerField(min_value=1), required=False, default=list
53+
)
54+
projectSlugs = serializers.ListField(
55+
child=serializers.CharField(), required=False, default=list
56+
)
57+
spanQuery = serializers.CharField(required=False, allow_null=True)
58+
logQuery = serializers.CharField(required=False, allow_null=True)
59+
metricQuery = serializers.CharField(required=False, allow_null=True)
60+
linkParams = serializers.DictField(required=False, default=dict)
61+
62+
63+
class TableColumnSerializer(StrictContractSerializer):
64+
key = serializers.CharField()
65+
label = serializers.CharField() # type: ignore[assignment]
66+
type = serializers.ChoiceField(
67+
choices=(
68+
"string",
69+
"number",
70+
"boolean",
71+
"datetime",
72+
"duration",
73+
"percentage",
74+
"bytes",
75+
"issue",
76+
"trace",
77+
"event",
78+
"project",
79+
"release",
80+
),
81+
default="string",
82+
)
83+
unit = serializers.CharField(required=False, allow_null=True)
84+
85+
86+
class TableResultSerializer(StrictContractSerializer):
87+
columns = serializers.ListField(child=TableColumnSerializer(), min_length=1)
88+
rows = serializers.ListField(child=serializers.ListField(), max_length=MAX_TABLE_ROWS)
89+
totalRows = serializers.IntegerField(min_value=0)
90+
returnedRows = serializers.IntegerField(min_value=0)
91+
truncated = serializers.BooleanField(required=False, default=False)
92+
93+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
94+
width = len(attrs["columns"])
95+
if any(len(row) != width for row in attrs["rows"]):
96+
raise serializers.ValidationError("Every table row must match the column count.")
97+
if attrs["returnedRows"] != len(attrs["rows"]):
98+
raise serializers.ValidationError("returnedRows must match the number of rows.")
99+
if attrs["totalRows"] < attrs["returnedRows"]:
100+
raise serializers.ValidationError("totalRows cannot be smaller than returnedRows.")
101+
if any(
102+
not isinstance(value, (str, int, float, bool)) and value is not None
103+
for row in attrs["rows"]
104+
for value in row
105+
):
106+
raise serializers.ValidationError("Table cells must be JSON scalar values.")
107+
return attrs
108+
109+
110+
class ChartPointSerializer(StrictContractSerializer):
111+
x = serializers.JSONField()
112+
y = serializers.FloatField()
113+
114+
def validate_x(self, value: Any) -> str | int | float:
115+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
116+
raise serializers.ValidationError("Chart x values must be strings or numbers.")
117+
return value
118+
119+
120+
class ChartSeriesSerializer(StrictContractSerializer):
121+
name = serializers.CharField()
122+
data = serializers.ListField( # type: ignore[assignment]
123+
child=ChartPointSerializer(), min_length=1, max_length=MAX_POINTS_PER_SERIES
124+
)
125+
126+
127+
class ChartResultSerializer(StrictContractSerializer):
128+
xAxis = serializers.ChoiceField(choices=("time", "category"))
129+
series = serializers.ListField(
130+
child=ChartSeriesSerializer(), min_length=1, max_length=MAX_CHART_SERIES
131+
)
132+
truncated = serializers.BooleanField(required=False, default=False)
133+
134+
135+
class SeerChartPointSerializer(StrictContractSerializer):
136+
x = serializers.JSONField()
137+
y = serializers.FloatField()
138+
139+
def validate_x(self, value: Any) -> str | int | float:
140+
if isinstance(value, bool) or not isinstance(value, str | int | float):
141+
raise serializers.ValidationError("Chart x values must be strings or numbers.")
142+
return value
143+
144+
145+
class SeerChartSeriesSerializer(StrictContractSerializer):
146+
name = serializers.CharField()
147+
data = serializers.ListField( # type: ignore[assignment]
148+
child=SeerChartPointSerializer(), min_length=1, max_length=MAX_POINTS_PER_SERIES
149+
)
150+
151+
152+
class SeerChartEmbedSerializer(StrictContractSerializer):
153+
title = serializers.CharField()
154+
subtitle = serializers.CharField(required=False, allow_null=True)
155+
visualization = serializers.ChoiceField(choices=("line", "area", "bar"), default="line")
156+
x_axis = serializers.ChoiceField(choices=("time", "category"), default="time")
157+
y_axis_unit = serializers.ChoiceField(
158+
choices=("number", "percentage", "duration", "bytes"), default="number"
159+
)
160+
y_axis_label = serializers.CharField(required=False, allow_null=True)
161+
stacked = serializers.BooleanField(required=False, default=True)
162+
show_legend = serializers.BooleanField(required=False, default=True)
163+
show_title = serializers.BooleanField(required=False, default=True)
164+
frameless = serializers.BooleanField(required=False, default=False)
165+
series = serializers.ListField(
166+
child=SeerChartSeriesSerializer(), min_length=1, max_length=MAX_CHART_SERIES
167+
)
168+
169+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
170+
if attrs["x_axis"] != "time":
171+
return attrs
172+
for series in attrs["series"]:
173+
for point in series["data"]:
174+
value = point["x"]
175+
parsed = parse_datetime(value) if isinstance(value, str) else None
176+
if parsed is None or parsed.utcoffset() is None:
177+
raise serializers.ValidationError(
178+
"Time-axis values must be offset-bearing ISO timestamps."
179+
)
180+
return attrs
181+
182+
183+
class VisualizationSerializer(StrictContractSerializer):
184+
type = serializers.ChoiceField(choices=("line", "area", "bar"))
185+
title = serializers.CharField()
186+
subtitle = serializers.CharField(required=False, allow_null=True)
187+
xField = serializers.CharField()
188+
yFields = serializers.ListField(
189+
child=serializers.CharField(), min_length=1, max_length=MAX_CHART_SERIES
190+
)
191+
seriesField = serializers.CharField(required=False, allow_null=True)
192+
unit = serializers.ChoiceField(
193+
choices=("number", "percentage", "duration", "bytes"), default="number"
194+
)
195+
axisLabel = serializers.CharField(required=False, allow_null=True)
196+
stacked = serializers.BooleanField(required=False, default=False)
197+
showLegend = serializers.BooleanField(required=False, default=True)
198+
sort = serializers.ChoiceField(
199+
choices=("none", "ascending", "descending"), required=False, default="none"
200+
)
201+
topN = serializers.IntegerField(required=False, allow_null=True, min_value=1, max_value=20)
202+
203+
204+
class InvestigationQueryResultSerializer(StrictContractSerializer):
205+
schemaVersion = serializers.IntegerField(min_value=1, max_value=1)
206+
tableMarkdown = serializers.CharField(max_length=MAX_MARKDOWN_CHARS, trim_whitespace=False)
207+
chart = SeerChartEmbedSerializer(required=False, allow_null=True)
208+
preferredView = serializers.ChoiceField(choices=("table", "chart"), default="table")
209+
isEmpty = serializers.BooleanField(default=False)
210+
chartUnavailableReason = serializers.CharField(required=False, allow_null=True)
211+
queryLinks = serializers.ListField(child=serializers.JSONField(), required=False, default=list)
212+
213+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
214+
if attrs["preferredView"] == "chart" and attrs.get("chart") is None:
215+
attrs["preferredView"] = "table"
216+
if attrs.get("chart") is None and not attrs.get("chartUnavailableReason"):
217+
attrs["chartUnavailableReason"] = "No chart was generated for this result."
218+
return attrs
219+
220+
221+
class InvestigationTextResultSerializer(StrictContractSerializer):
222+
schemaVersion = serializers.IntegerField(min_value=1, max_value=1)
223+
markdown = serializers.CharField(max_length=MAX_MARKDOWN_CHARS, trim_whitespace=False)
224+
225+
226+
def validate_query_result(value: Any) -> dict[str, Any]:
227+
if len(json.dumps(value).encode()) > MAX_ARTIFACT_BYTES:
228+
raise serializers.ValidationError("Query result exceeds the maximum artifact size.")
229+
serializer = InvestigationQueryResultSerializer(data=value)
230+
serializer.is_valid(raise_exception=True)
231+
return dict(serializer.validated_data)
232+
233+
234+
def validate_text_result(value: Any) -> dict[str, Any]:
235+
if len(json.dumps(value).encode()) > MAX_ARTIFACT_BYTES:
236+
raise serializers.ValidationError("Text result exceeds the maximum artifact size.")
237+
serializer = InvestigationTextResultSerializer(data=value)
238+
serializer.is_valid(raise_exception=True)
239+
return dict(serializer.validated_data)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .investigation import * # NOQA

0 commit comments

Comments
 (0)