-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy path_backend.py
More file actions
544 lines (458 loc) · 22 KB
/
Copy path_backend.py
File metadata and controls
544 lines (458 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# Unless explicitly stated otherwise all files in this repository are licensed under the the Apache License Version 2.0.
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2021 Datadog, Inc.
"""Validate data flow between agent and backend"""
import json
from http import HTTPStatus
import os
import time
import requests
from requests.exceptions import JSONDecodeError
from utils.interfaces._core import ProxyBasedInterfaceValidator
from utils.interfaces._library.core import LibraryInterfaceValidator
from utils._logger import logger
from utils._weblog import HttpResponse
class _BackendInterfaceValidator(ProxyBasedInterfaceValidator):
"""Validate backend data processors"""
def __init__(self, library_interface: LibraryInterfaceValidator):
super().__init__("backend")
# Mapping from request ID to the root span trace IDs submitted from tracers to agent.
self.rid_to_library_trace_ids: dict[str | None, list[int]] = {}
self.dd_site_url = self._get_dd_site_api_host()
self.message_count = 0
self.library_interface = library_interface
@staticmethod
def _get_dd_site_api_host() -> str:
# https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site
# DD_SITE => API HOST
# datad0g.com => dd.datad0g.com
# datadoghq.com => app.datadoghq.com
# datadoghq.eu => app.datadoghq.eu
# ddog-gov.com => app.ddog-gov.com
# XYZ.datadoghq.com => XYZ.datadoghq.com
dd_site = os.environ.get("DD_SITE", "datad0g.com")
dd_site_to_app = {
"datad0g.com": "https://dd.datad0g.com",
"datadoghq.com": "https://app.datadoghq.com",
"datadoghq.eu": "https://app.datadoghq.eu",
"ddog-gov.com": "https://app.ddog-gov.com",
"us3.datadoghq.com": "https://us3.datadoghq.com",
"us5.datadoghq.com": "https://us5.datadoghq.com",
}
dd_app_url = dd_site_to_app.get(dd_site)
assert dd_app_url is not None, f"We could not resolve a proper Datadog API URL given DD_SITE[{dd_site}]!"
logger.debug(f"Using Datadog API URL[{dd_app_url}] as resolved from DD_SITE[{dd_site}].")
return dd_app_url
# Called by the test setup to make sure the interface is ready.
def wait(self, timeout: int):
super().wait(timeout)
self._init_rid_to_library_trace_ids()
def load_data_from_logs(self):
super().load_data_from_logs()
self._init_rid_to_library_trace_ids()
def _init_rid_to_library_trace_ids(self):
# Map each request ID to the spans created and submitted during that request call.
for _, span in self.library_interface.get_root_spans():
rid = span.get_rid()
if not self.rid_to_library_trace_ids.get(rid):
self.rid_to_library_trace_ids[rid] = [span["trace_id"]]
else:
self.rid_to_library_trace_ids[rid].append(span["trace_id"])
#################################
######### API for tests #########
#################################
def assert_library_traces_exist(self, request: HttpResponse, min_traces_len: int = 1):
"""Attempts to fetch from the backend, ALL the traces that the library tracers sent to the agent
during the execution of the given request.
The assosiation of the traces with a request is done through propagating the request ID (inside user agent)
on all the submitted traces. This is done automatically, unless you create root spans manually, which in
that case you need to manually propagate the user agent to the new spans.
It will assert that at least `min_traces_len` were received from the backend before
returning the list of traces.
"""
rid = request.get_rid()
traces_data = list(self._wait_for_request_traces(rid))
traces = [self._extract_trace_from_backend_response(data["response"]) for data in traces_data]
assert len(traces) >= min_traces_len, (
f"We only found {len(traces)} traces in the library (tracers), but we expected {min_traces_len}!"
)
return traces
def assert_otlp_trace_exist(
self, request: HttpResponse, dd_trace_id: int, dd_api_key: str | None = None, dd_app_key: str | None = None
) -> dict:
"""Attempts to fetch from the backend, ALL the traces that the OpenTelemetry SDKs sent to Datadog
during the execution of the given request.
The assosiation of the traces with a request is done through propagating the request ID (inside user agent)
on all the submitted traces. This is done automatically, unless you create root spans manually, which in
that case you need to manually propagate the user agent to the new spans.
"""
rid = request.get_rid()
data = self._wait_for_trace(
rid=rid,
trace_id=dd_trace_id,
retries=10,
sleep_interval_multiplier=2.0,
dd_api_key=dd_api_key,
dd_app_key=dd_app_key,
)
return data["response"]["content"]["trace"]
def assert_single_spans_exist(self, request: HttpResponse, min_spans_len: int = 1, limit: int = 100):
"""Attempts to fetch single span events using the given `query_filter` as part of the search query.
The query should be what you would use in the `/apm/traces` page in the UI.
When a valid request is provided we will restrict the single span search to span events
that include the request ID in their tags.
It will assert that at least `min_spans_len` were received from the backend before
returning the list of span events.
"""
rid = request.get_rid()
query_filter = f"service:weblog @single_span:true @http.useragent:*{rid}"
return self.assert_request_spans_exist(request, query_filter, min_spans_len, limit)
def assert_request_spans_exist(
self, request: HttpResponse, query_filter: str, min_spans_len: int = 1, limit: int = 100, retries: int = 5
):
"""Attempts to fetch span events from the Event Platform using the given `query_filter`
as part of the search query. The query should be what you would use in the `/apm/traces`
page in the UI. When a valid request is provided we will restrict the span search to span
events that include the request ID in their tags.
It will assert that at least `min_spans_len` were received from the backend before
returning the list of span events.
"""
rid = request.get_rid()
if rid:
query_filter = f"{query_filter} @http.useragent:*{rid}"
return self.assert_spans_exist(query_filter, min_spans_len, limit, retries)
def assert_spans_exist(self, query_filter: str, min_spans_len: int = 1, limit: int = 100, retries: int = 5):
"""Attempts to fetch span events from the Event Platform using the given `query_filter`
as part of the search query. The query should be what you would use in the `/apm/traces`
page in the UI.
It will assert that at least `min_spans_len` were received from the backend before
returning the list of span events.
"""
logger.debug(f"We will attempt to fetch span events with query filter: {query_filter}")
data = self._wait_for_event_platform_spans(query_filter, limit, retries)
result = data["response"]["content"]["result"]
assert result["count"] >= min_spans_len, f"Did not have the expected number of spans ({min_spans_len}): {data}"
return [item["event"] for item in result["events"]]
############################################
######### Internal implementation ##########
############################################
def _get_trace_ids(self, rid: str):
if rid not in self.rid_to_library_trace_ids:
raise ValueError("There is no trace id related to this request ")
return self.rid_to_library_trace_ids[rid]
def _request(
self,
method: str,
path: str,
host: str | None = None,
json_payload: dict | None = None,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
):
while True:
data = self._request_one(
method=method,
path=path,
host=host,
json_payload=json_payload,
dd_api_key=dd_api_key,
dd_app_key=dd_app_key,
)
status_code = data["response"]["status_code"]
if status_code == HTTPStatus.TOO_MANY_REQUESTS:
# https://docs.datadoghq.com/api/latest/rate-limits/
logger.debug(f"Got rate limit error: {data['response']}")
sleep_time_s = int(data["response"]["headers"]["x-ratelimit-reset"])
logger.warning(f"Rate limit hit, sleeping {sleep_time_s}")
time.sleep(sleep_time_s)
continue
return data
def _request_one(
self,
method: str,
path: str,
host: str | None = None,
json_payload: dict | None = None,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
):
if dd_api_key is None:
dd_api_key = os.environ.get("DD_API_KEY")
if dd_app_key is None:
dd_app_key = os.environ.get("DD_APP_KEY", os.environ.get("DD_APPLICATION_KEY"))
assert dd_api_key is not None, "DD_API_KEY environment variable is not set"
assert dd_app_key is not None, "DD_APP_KEY environment variable is not set"
headers = {
"DD-API-KEY": dd_api_key,
"DD-APPLICATION-KEY": dd_app_key,
}
if host is None:
host = self.dd_site_url
r = requests.request(method, url=f"{host}{path}", headers=headers, json=json_payload, timeout=10)
if r.status_code == HTTPStatus.FORBIDDEN:
raise ValueError(
"Request to the backend returned error 403: check DD_API_KEY and DD_APP_KEY environment variables"
)
if "?" in path:
path, query = path.split("?", 1)
else:
query = ""
try:
response_content = r.json()
except JSONDecodeError:
response_content = r.text
data = {
"host": host,
"path": path,
"query": query,
"request": {"content": json_payload},
"response": {"status_code": r.status_code, "content": response_content, "headers": dict(r.headers)},
"log_filename": f"{self.log_folder}/{self.message_count:03d}_{path.replace('/', '_')}.json",
}
self.message_count += 1
with open(str(data["log_filename"]), mode="w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return data
def _get_backend_trace_data(
self, rid: str, trace_id: int, dd_api_key: str | None = None, dd_app_key: str | None = None
):
path = f"/api/v1/trace/{trace_id}"
result = self._request("GET", path=path, dd_api_key=dd_api_key, dd_app_key=dd_app_key)
result["rid"] = rid
return result
def _wait_for_trace(
self,
rid: str,
trace_id: int,
retries: int,
sleep_interval_multiplier: float,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
):
sleep_interval_s = 1.0
current_retry = 1
while current_retry <= retries:
logger.info(f"Retry {current_retry}")
current_retry += 1
data = self._get_backend_trace_data(rid, trace_id, dd_api_key, dd_app_key)
# We should retry fetching from the backend as long as the response is 404.
status_code = data["response"]["status_code"]
if status_code not in (HTTPStatus.NOT_FOUND, HTTPStatus.OK):
raise ValueError(f"Backend did not provide trace: {data['path']}. Status is {status_code}.")
if status_code != HTTPStatus.NOT_FOUND:
return data
logger.debug(f"Sleeping {sleep_interval_s} seconds")
time.sleep(sleep_interval_s)
sleep_interval_s *= sleep_interval_multiplier # increase the sleep time with each retry
raise ValueError(
f"Backend did not provide trace after {retries} retries: {data['path']}. Status is {status_code}."
)
def _wait_for_request_traces(self, rid: str, retries: int = 5, sleep_interval_multiplier: float = 2.0):
trace_ids = self._get_trace_ids(rid)
logger.info(
f"Waiting for {len(trace_ids)} traces to become available from request {rid} with {retries} retries..."
)
for trace_id in trace_ids:
logger.info(
f"Waiting for trace {trace_id} to become available from request {rid} with {retries} retries..."
)
yield self._wait_for_trace(rid, trace_id, retries, sleep_interval_multiplier)
def _extract_trace_from_backend_response(self, response: dict):
trace = response["content"].get("trace")
if not trace:
raise ValueError(f"The response does not contain valid trace content:\n{json.dumps(response, indent=2)}")
return trace
def _wait_for_event_platform_spans(
self, query_filter: str, limit: int, retries: int = 5, sleep_interval_multiplier: float = 2.0
):
logger.info(
f"Waiting until spans (non-empty response) become available with "
f"query '{query_filter}' with {retries} retries..."
)
sleep_interval_s = 1.0
current_retry = 1
while current_retry <= retries:
logger.info(f"Retry {current_retry}")
current_retry += 1
data = self._get_event_platform_spans(query_filter, limit)
# We should retry fetching from the backend as long as the response has empty data.
status_code = data["response"]["status_code"]
if status_code != HTTPStatus.OK:
raise ValueError(f"Fetching spans from Event Platform failed: {data['path']}. Status is {status_code}.")
parsed = data["response"]["content"]
if parsed["result"]["count"] > 0:
return data
time.sleep(sleep_interval_s)
sleep_interval_s *= sleep_interval_multiplier # increase the sleep time with each retry
# We always try once so `data` should have not be None.
return data
def _get_event_platform_spans(self, query_filter: str, limit: int):
# Example of this query can be seen in the `events-ui` internal website (see Jira ATI-2419).
path = "/api/unstable/event-platform/analytics/list?type=trace"
request_data = {
"list": {
"search": {"query": f"env:system-tests {query_filter}"},
"indexes": ["trace-search"],
"time": {
# 30 min of window should be plenty
"from": "now-1800s",
"to": "now",
},
"limit": limit,
"columns": [],
"computeCount": True,
"includeEventContents": True,
}
}
return self._request("POST", path, json_payload=request_data)
# Queries the backend metric (non UI) timeseries API and returns the matched series.
def query_timeseries(
self,
rid: str,
start: int,
end: int,
metric: str,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
retries: int = 12,
sleep_interval_multiplier: float = 2.0,
initial_delay_s: float = 10.0,
):
query = metric + "{rid:" + rid + "}"
path = f"/api/v1/query?from={start}&to={end}&query={query}"
sleep_interval_s = 1.0
current_retry = 1
# It takes very long for metric timeseries to be query-able.
time.sleep(initial_delay_s)
while current_retry <= retries:
logger.info(f"Retry {current_retry}")
current_retry += 1
data = self._request(
"GET", host=self._get_logs_metrics_api_host(), path=path, dd_api_key=dd_api_key, dd_app_key=dd_app_key
)
# We should retry fetching from the backend as long as the response is 404.
status_code = data["response"]["status_code"]
if status_code not in (HTTPStatus.NOT_FOUND, HTTPStatus.OK):
raise ValueError(f"Backend did not provide metric: {data['path']}. Status is {status_code}.")
if status_code != HTTPStatus.NOT_FOUND:
resp_content = data["response"]["content"]
# There may be delay in metric query, retry when series are not present
if len(resp_content["series"]) > 0:
return resp_content
time.sleep(sleep_interval_s)
sleep_interval_s *= sleep_interval_multiplier # increase the sleep time with each retry
raise ValueError(
f"Backend did not provide metric series after {retries} retries: {data['path']}. Status is {status_code}."
)
# Queries the backend metric UI timeseries API and returns the matched series.
def query_ui_timeseries(
self,
query: str,
start: int,
end: int,
semantic_mode: str = "combined", # "native" or "combined"
interval: int = 5000,
minimum_interval: int = 1000,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
retries: int = 12,
sleep_interval_multiplier: float = 2.0,
initial_delay_s: float = 10.0,
):
path = "/api/ui/query/timeseries"
request_payload = {
"meta": {
"dd_extra_usage_params": {},
"use_multi_step": True,
"use_frontend_step_interval": True,
"include_interval_data": True,
"enable_incremental_materialized_view": False,
},
"data": [
{
"type": "timeseries_request",
"attributes": {
"queries": [
{"name": "query1", "data_source": "metrics", "query": query, "semantic_mode": semantic_mode}
],
"from": start,
"to": end,
"interval": interval,
"minimum_interval": minimum_interval,
"formulas": [{"formula": "query1"}],
},
}
],
}
sleep_interval_s = 1.0
current_retry = 1
# It takes very long for metric timeseries to be query-able.
time.sleep(initial_delay_s)
while current_retry <= retries:
logger.info(f"UI Timeseries Query Retry {current_retry}")
current_retry += 1
data = self._request(
"POST",
host=self.dd_site_url,
path=path,
json_payload=request_payload,
dd_api_key=dd_api_key,
dd_app_key=dd_app_key,
)
# We should retry fetching from the backend as long as the response is 404.
status_code = data["response"]["status_code"]
if status_code not in (HTTPStatus.NOT_FOUND, HTTPStatus.OK):
raise ValueError(f"Backend UI timeseries query failed: {data['path']}. Status is {status_code}.")
if status_code != HTTPStatus.NOT_FOUND:
resp_content = data["response"]["content"]
if (
resp_content.get("data")
and len(resp_content["data"]) > 0
and resp_content["data"][0].get("attributes", {}).get("series")
):
return resp_content
time.sleep(sleep_interval_s)
sleep_interval_s *= sleep_interval_multiplier # increase the sleep time with each retry
raise ValueError(
f"Backend UI timeseries did not provide data after {retries} retries: {data['path']}. "
"Status is {status_code}."
)
# Queries the backend log search API and returns the log matching the given query.
def get_logs(
self,
query: str,
rid: str,
dd_api_key: str | None = None,
dd_app_key: str | None = None,
retries: int = 10,
sleep_interval_multiplier: float = 2.0,
):
path = f"/api/v2/logs/events?query={query}"
sleep_interval_s = 1.0
current_retry = 1
while current_retry <= retries:
logger.info(f"Getting logs from {path}, retry {current_retry}")
current_retry += 1
data = self._request(
"GET", host=self._get_logs_metrics_api_host(), path=path, dd_api_key=dd_api_key, dd_app_key=dd_app_key
)
# We should retry fetching from the backend as long as the response is 404.
status_code = data["response"]["status_code"]
if status_code not in (404, 200):
logger.error(f"Backend response: {data['response']}")
raise ValueError(f"Backend did not provide logs: {data['path']}. Status is {status_code}.")
if status_code != HTTPStatus.NOT_FOUND:
logs = data["response"]["content"]["data"]
# Log search can sometimes return wrong results. Retry if expected log is not present.
for log in logs:
if log["attributes"].get("message") == f"Handle request with user agent: system_tests rid/{rid}":
return log
time.sleep(sleep_interval_s)
sleep_interval_s *= sleep_interval_multiplier # increase the sleep time with each retry
raise ValueError(
f"Backend did not provide logs after {retries} retries: {data['path']}. Status is {status_code}."
)
@staticmethod
def _get_logs_metrics_api_host() -> str:
dd_site = os.environ.get("DD_SITE", "datad0g.com")
return f"https://api.{dd_site}"