Skip to content

Commit 1ea0020

Browse files
committed
Add trace stats snapshot functionality
Implement trace stats snapshotting so that trace stats are included in snapshots.
1 parent 4318bfd commit 1ea0020

File tree

7 files changed

+370
-9
lines changed

7 files changed

+370
-9
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ Test session token for a test case. **Ensure this value is unique to avoid confl
195195

196196
### /test/session/snapshot
197197

198+
Perform a snapshot generation or comparison on the data received during the session.
199+
200+
Snapshots are generated when the test agent is not in CI mode and there is no snapshot file present. Otherwise a
201+
snapshot comparison will be performed.
202+
203+
198204
#### [optional\*] `?test_session_token=`
199205
#### [optional\*] `X-Datadog-Test-Session-Token`
200206
To run test cases in parallel this HTTP header must be specified. All test
@@ -231,6 +237,8 @@ Warning: it is an error to specify both `file` and `dir`.
231237

232238
Note: the file extension will be appended to the filename.
233239

240+
`_tracestats` will be appended to the filename for trace stats requests.
241+
234242

235243
### /test/session/requests
236244

ddapm_test_agent/agent.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from . import _get_version
2222
from . import trace_snapshot
23+
from . import tracestats_snapshot
2324
from .checks import CheckTrace
2425
from .checks import Checks
2526
from .checks import start_trace
@@ -274,6 +275,7 @@ async def handle_session_start(self, request: Request) -> web.Response:
274275
return web.HTTPOk()
275276

276277
async def handle_snapshot(self, request: Request) -> web.Response:
278+
"""Generate a snapshot or perform a snapshot test."""
277279
token = request["session_token"]
278280
snap_dir = request.url.query.get("dir", request.app["snapshot_dir"])
279281
snap_ci_mode = request.app["snapshot_ci_mode"]
@@ -301,34 +303,76 @@ async def handle_snapshot(self, request: Request) -> web.Response:
301303
else:
302304
snap_file = os.path.join(snap_dir, token)
303305

306+
# The logic from here is mostly duplicated for traces and trace stats.
307+
# If another data type is to be snapshotted then it probably makes sense to abstract away
308+
# the required pieces of snapshotting (loading, generating and comparing).
309+
310+
# For backwards compatibility traces don't have a postfix of `_trace.json`
304311
trace_snap_file = f"{snap_file}.json"
312+
tracestats_snap_file = f"{snap_file}_tracestats.json"
313+
305314
frame.add_item(f"Trace File: {trace_snap_file}")
306-
log.info("using snapshot file %r", trace_snap_file)
315+
frame.add_item(f"Stats File: {tracestats_snap_file}")
316+
log.info(
317+
"using snapshot files %r and %r", trace_snap_file, tracestats_snap_file
318+
)
307319

308320
trace_snap_path_exists = os.path.exists(trace_snap_file)
309-
if snap_ci_mode and not trace_snap_path_exists:
321+
322+
received_traces = await self._traces_by_session(token)
323+
if snap_ci_mode and received_traces and not trace_snap_path_exists:
310324
raise AssertionError(
311325
f"Trace snapshot file '{trace_snap_file}' not found. "
312326
"Perhaps the file was not checked into source control? "
313327
"The snapshot file is automatically generated when the test case is run when not in CI mode."
314328
)
315329
elif trace_snap_path_exists:
316330
# Do the snapshot comparison
317-
received_traces = await self._traces_by_session(token)
318331
with open(trace_snap_file, mode="r") as f:
319332
raw_snapshot = json.load(f)
320-
321333
trace_snapshot.snapshot(
322334
expected_traces=raw_snapshot,
323335
received_traces=received_traces,
324336
ignored=span_ignores,
325337
)
326-
else:
338+
elif received_traces:
327339
# Create a new snapshot for the data received
328-
traces = await self._traces_by_session(token)
329340
with open(trace_snap_file, mode="w") as f:
330-
f.write(trace_snapshot.generate_snapshot(traces))
331-
log.info("wrote new snapshot to %r", os.path.abspath(trace_snap_file))
341+
f.write(trace_snapshot.generate_snapshot(received_traces))
342+
log.info(
343+
"wrote new trace snapshot to %r", os.path.abspath(trace_snap_file)
344+
)
345+
346+
# Get all stats buckets from the payloads since we don't care about the other fields (hostname, env, etc)
347+
# in the payload.
348+
received_stats = [
349+
bucket
350+
for p in (await self._tracestats_by_session(token))
351+
for bucket in p["Stats"]
352+
]
353+
tracestats_snap_path_exists = os.path.exists(tracestats_snap_file)
354+
if snap_ci_mode and received_stats and not tracestats_snap_path_exists:
355+
raise AssertionError(
356+
f"Trace stats snapshot file '{tracestats_snap_file}' not found. "
357+
"Perhaps the file was not checked into source control? "
358+
"The snapshot file is automatically generated when the test case is run when not in CI mode."
359+
)
360+
elif tracestats_snap_path_exists:
361+
# Do the snapshot comparison
362+
with open(tracestats_snap_file, mode="r") as f:
363+
raw_snapshot = json.load(f)
364+
tracestats_snapshot.snapshot(
365+
expected_stats=raw_snapshot,
366+
received_stats=received_stats,
367+
)
368+
elif received_stats:
369+
# Create a new snapshot for the data received
370+
with open(tracestats_snap_file, mode="w") as f:
371+
f.write(tracestats_snapshot.generate(received_stats))
372+
log.info(
373+
"wrote new tracestats snapshot to %r",
374+
os.path.abspath(tracestats_snap_file),
375+
)
332376
return web.HTTPOk()
333377

334378
async def handle_session_traces(self, request: Request) -> web.Response:
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import json
2+
from typing import List
3+
4+
from .checks import CheckTrace
5+
from .tracestats import StatsBucket
6+
7+
8+
def _normalize_statsbuckets(buckets: List[StatsBucket]) -> List[StatsBucket]:
9+
"""Normalize the stats bucket by normalizing the time buckets."""
10+
# Make a copy of the buckets, note that the sketches are not copied.
11+
normed_buckets = []
12+
for bucket in buckets:
13+
bcopy = bucket.copy()
14+
bcopy["Stats"] = [
15+
aggr.copy() for aggr in bucket["Stats"]
16+
] # Copy the aggregations
17+
normed_buckets.append(bcopy)
18+
19+
# Order the buckets by time
20+
normed_buckets = sorted(normed_buckets, key=lambda b: b["Start"])
21+
22+
# Sort aggr for a bucket alphanumerically
23+
for bucket in normed_buckets:
24+
# Sort aggrs by name then resource then hits
25+
bucket["Stats"] = sorted(
26+
bucket["Stats"], key=lambda b: (b["Name"], b["Resource"], ["Hits"])
27+
)
28+
29+
start = normed_buckets[0]["Start"]
30+
for b in normed_buckets:
31+
b["Start"] -= start
32+
33+
return normed_buckets
34+
35+
36+
def snapshot(
37+
expected_stats: List[StatsBucket], received_stats: List[StatsBucket]
38+
) -> None:
39+
# Normalize the stats buckets by making them independent of time. Only ordering matters.
40+
41+
# Sort the buckets by start time.
42+
normed_expected = _normalize_statsbuckets(expected_stats)
43+
normed_received = _normalize_statsbuckets(received_stats)
44+
45+
# TODO: do better matching and comparing to aid in debugging
46+
assert len(normed_received) == len(
47+
normed_expected
48+
), f"Number of stats buckets ({len(normed_received)}) doesn't match expected ({len(normed_expected)})."
49+
50+
with CheckTrace.add_frame(
51+
f"snapshot compare of {len(normed_received)} stats buckets"
52+
):
53+
# Do a really rough comparison.
54+
for i, (exp_bucket, rec_bucket) in enumerate(
55+
zip(normed_expected, normed_received)
56+
):
57+
exp_aggrs = exp_bucket["Stats"]
58+
rec_aggrs = rec_bucket["Stats"]
59+
assert len(exp_aggrs) == len(
60+
rec_aggrs
61+
), f"Number of aggregations ({len(rec_aggrs)}) in bucket {i} doesn't match expected ({len(exp_aggrs)})."
62+
63+
for j, (exp_aggr, rec_aggr) in enumerate(zip(exp_aggrs, rec_aggrs)):
64+
# Omit duration and sketches for now
65+
# Duration and sketches will be noisy
66+
for attr in (
67+
"Name",
68+
"Resource",
69+
"Type",
70+
"Synthetics",
71+
"Hits",
72+
"TopLevelHits",
73+
"Errors",
74+
):
75+
exp_value, rec_value = exp_aggr[attr], rec_aggr[attr] # type: ignore
76+
if exp_value != rec_value:
77+
raise AssertionError(
78+
f"Expected value ('{exp_value}') for field '{attr}' does not match received value '{rec_value}'"
79+
)
80+
81+
82+
def generate(received_stats: List[StatsBucket]) -> str:
83+
return f"{json.dumps(_normalize_statsbuckets(received_stats), indent=2)}\n"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
features:
3+
- |
4+
Implement trace stats snapshotting.
5+
6+
Trace stats are now included in the snapshot behaviour provided by the testagent.
7+
8+
Similar to traces, trace stats snapshots are output to a json file.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"Start": 0,
4+
"Duration": 10000000000,
5+
"Stats": [
6+
{
7+
"Name": "http.request",
8+
"Resource": "/users/view",
9+
"Type": null,
10+
"Synthetics": false,
11+
"Hits": 5,
12+
"TopLevelHits": 5,
13+
"Duration": 293000,
14+
"Errors": 1,
15+
"OkSummary": 1046,
16+
"ErrorSummary": 1046
17+
}
18+
]
19+
}
20+
]

tests/test_snapshot.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import pytest
44

55
from ddapm_test_agent import trace_snapshot
6+
from ddapm_test_agent import tracestats_snapshot
67
from ddapm_test_agent.trace import copy_span
78
from ddapm_test_agent.trace import set_attr
89
from ddapm_test_agent.trace import set_meta_tag
910
from ddapm_test_agent.trace import set_metric_tag
11+
from ddapm_test_agent.tracestats import StatsAggr
12+
from ddapm_test_agent.tracestats import StatsBucket
1013

1114
from .conftest import v04_trace
1215
from .trace_utils import random_trace
@@ -243,6 +246,88 @@ def test_generate_trace_snapshot(trace, expected):
243246
assert trace_snapshot.generate_snapshot(trace) == expected
244247

245248

249+
@pytest.mark.parametrize(
250+
"buckets,expected",
251+
[
252+
(
253+
[
254+
StatsBucket( # noqa
255+
Start=1000,
256+
Duration=10,
257+
Stats=[
258+
# Not using all the fields of StatsAggr, hence the ignores
259+
StatsAggr( # type: ignore
260+
Name="http.request",
261+
Resource="/users/list",
262+
Hits=10,
263+
TopLevelHits=10,
264+
Duration=100,
265+
), # noqa
266+
StatsAggr( # type: ignore
267+
Name="http.request",
268+
Resource="/users/create",
269+
Hits=5,
270+
TopLevelHits=5,
271+
Duration=10,
272+
), # noqa
273+
],
274+
),
275+
StatsBucket(
276+
Start=1010,
277+
Duration=10,
278+
Stats=[
279+
StatsAggr( # type: ignore
280+
Name="http.request",
281+
Resource="/users/list",
282+
Hits=20,
283+
TopLevelHits=20,
284+
Duration=200,
285+
),
286+
],
287+
),
288+
],
289+
"""[
290+
{
291+
"Start": 0,
292+
"Duration": 10,
293+
"Stats": [
294+
{
295+
"Name": "http.request",
296+
"Resource": "/users/create",
297+
"Hits": 5,
298+
"TopLevelHits": 5,
299+
"Duration": 10
300+
},
301+
{
302+
"Name": "http.request",
303+
"Resource": "/users/list",
304+
"Hits": 10,
305+
"TopLevelHits": 10,
306+
"Duration": 100
307+
}
308+
]
309+
},
310+
{
311+
"Start": 10,
312+
"Duration": 10,
313+
"Stats": [
314+
{
315+
"Name": "http.request",
316+
"Resource": "/users/list",
317+
"Hits": 20,
318+
"TopLevelHits": 20,
319+
"Duration": 200
320+
}
321+
]
322+
}
323+
]\n""",
324+
),
325+
],
326+
)
327+
def test_generate_tracestats_snapshot(buckets, expected):
328+
assert tracestats_snapshot.generate(buckets) == expected
329+
330+
246331
async def test_snapshot_custom_dir(agent, tmp_path, do_reference_v04_http_trace):
247332
resp = await do_reference_v04_http_trace(token="test_case")
248333
assert resp.status == 200
@@ -278,3 +363,40 @@ async def test_snapshot_custom_file(agent, tmp_path, do_reference_v04_http_trace
278363
assert os.path.exists(custom_file), custom_file
279364
with open(custom_file, mode="r") as f:
280365
assert "".join(f.readlines()) != ""
366+
367+
368+
@pytest.mark.parametrize("snapshot_ci_mode", [False, True])
369+
async def test_snapshot_tracestats(
370+
agent, tmp_path, snapshot_ci_mode, do_reference_v06_http_stats, snapshot_dir
371+
):
372+
resp = await do_reference_v06_http_stats(token="test_case")
373+
assert resp.status == 200
374+
375+
snap_path = snapshot_dir / "test_case_tracestats.json"
376+
resp = await agent.get(
377+
"/test/session/snapshot", params={"test_session_token": "test_case"}
378+
)
379+
resp_clear = await agent.get(
380+
"/test/session/clear", params={"test_session_token": "test_case"}
381+
)
382+
assert resp_clear.status == 200, await resp_clear.text()
383+
384+
if snapshot_ci_mode:
385+
# No previous snapshot file exists so this should fail
386+
assert resp.status == 400
387+
assert f"Trace stats snapshot file '{snap_path}' not found" in await resp.text()
388+
else:
389+
# First invocation the snapshot, file should be created
390+
assert resp.status == 200
391+
assert os.path.exists(snap_path)
392+
with open(snap_path, mode="r") as f:
393+
assert "".join(f.readlines()) != ""
394+
395+
# Do the snapshot again to actually perform a comparison
396+
resp = await do_reference_v06_http_stats(token="test_case")
397+
assert resp.status == 200, await resp.text()
398+
399+
resp = await agent.get(
400+
"/test/session/snapshot", params={"test_session_token": "test_case"}
401+
)
402+
assert resp.status == 200, await resp.text()

0 commit comments

Comments
 (0)