ddapm-test-agent 1.33.1__py3-none-any.whl → 1.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ddapm_test_agent/agent.py CHANGED
@@ -9,10 +9,12 @@ from dataclasses import field
9
9
  import json
10
10
  import logging
11
11
  import os
12
+ import platform
12
13
  import pprint
13
14
  import re
14
15
  import socket
15
16
  import sys
17
+ import threading
16
18
  from typing import Any
17
19
  from typing import Awaitable
18
20
  from typing import Callable
@@ -240,6 +242,59 @@ def default_value_trace_results_summary():
240
242
  }
241
243
 
242
244
 
245
+ class MockQuery:
246
+ """Mock query object that behaves like a dict."""
247
+
248
+ def __init__(self):
249
+ self._data = {} # Empty query params for named pipe processing
250
+
251
+ def get(self, key, default=None):
252
+ return self._data.get(key, default)
253
+
254
+ def __getitem__(self, key):
255
+ return self._data[key]
256
+
257
+ def __contains__(self, key):
258
+ return key in self._data
259
+
260
+
261
+ class MockURL:
262
+ """Mock URL object for named pipe processing."""
263
+
264
+ def __init__(self, path: str):
265
+ self.path = path
266
+ self.query = MockQuery()
267
+
268
+
269
+ class MockRequest:
270
+ """Mock Request object for named pipe processing."""
271
+
272
+ def __init__(
273
+ self, method: str, path: str, headers: Dict[str, str], body: bytes, agent: "Agent", app: web.Application
274
+ ):
275
+ self.method = method
276
+ self.path = path
277
+ self.headers = headers
278
+ self._body = body
279
+ self._data: Dict[str, Any] = {}
280
+ self.url = MockURL(path)
281
+ self.content_type = headers.get("Content-Type", "application/msgpack")
282
+ self.app = app
283
+
284
+ async def read(self) -> bytes:
285
+ """Mock read() method that returns the body data."""
286
+ return self._body
287
+
288
+ def __getitem__(self, key):
289
+ return self._data.get(key)
290
+
291
+ def __setitem__(self, key, value):
292
+ self._data[key] = value
293
+
294
+ def get(self, key, default=None):
295
+ return self._data.get(key, default)
296
+
297
+
243
298
  @dataclass
244
299
  class _AgentSession:
245
300
  """Maintain Agent state across requests."""
@@ -831,7 +886,6 @@ class Agent:
831
886
  "peer_tags": ["db.name", "mongodb.db", "messaging.system"],
832
887
  "span_events": True, # Advertise support for the top-level Span field for Span Events
833
888
  },
834
- headers={"Datadog-Agent-State": "03e868b3ecdd62a91423cc4c3917d0d151fb9fa486736911ab7f5a0750c63824"},
835
889
  )
836
890
 
837
891
  async def _handle_traces(self, request: Request, version: Literal["v0.4", "v0.5", "v0.7", "v1"]) -> web.Response:
@@ -1288,6 +1342,174 @@ class Agent:
1288
1342
  raise web.HTTPBadRequest(body=msg)
1289
1343
  return response
1290
1344
 
1345
+ def _parse_http_request(self, data: bytes) -> tuple[str, str, Dict[str, str], bytes]:
1346
+ """Parse HTTP request from raw bytes.
1347
+
1348
+ Returns:
1349
+ tuple: (method, path, headers_dict, body)
1350
+ """
1351
+ try:
1352
+ # Split request into headers and body
1353
+ if b"\r\n\r\n" in data:
1354
+ header_data, body = data.split(b"\r\n\r\n", 1)
1355
+ else:
1356
+ header_data, body = data, b""
1357
+
1358
+ # Parse headers
1359
+ header_lines = header_data.decode("utf-8", errors="ignore").split("\r\n")
1360
+ if not header_lines:
1361
+ raise ValueError("No request line found")
1362
+
1363
+ # Parse request line (e.g., "POST /v0.4/traces HTTP/1.1")
1364
+ request_line = header_lines[0]
1365
+ parts = request_line.split(" ")
1366
+ if len(parts) < 2:
1367
+ raise ValueError(f"Invalid request line: {request_line}")
1368
+
1369
+ method = parts[0]
1370
+ path = parts[1]
1371
+
1372
+ # Parse headers
1373
+ headers: Dict[str, str] = {}
1374
+ for line in header_lines[1:]:
1375
+ if ":" in line:
1376
+ key, value = line.split(":", 1)
1377
+ headers[key.strip()] = value.strip()
1378
+
1379
+ return method, path, headers, body
1380
+
1381
+ except Exception as e:
1382
+ log.error(f"Error parsing HTTP request: {e}")
1383
+ raise ValueError(f"Failed to parse HTTP request: {e}") from e
1384
+
1385
+ def _process_named_pipe_request(self, data: bytes, app: web.Application) -> bytes:
1386
+ """Process a request using the existing Agent infrastructure."""
1387
+ try:
1388
+ # Parse the HTTP request
1389
+ method, path, headers, body = self._parse_http_request(data)
1390
+
1391
+ log.info(f"Processing Named Pipe request: {method} {path}")
1392
+
1393
+ # Create a mock Request object
1394
+ mock_request = MockRequest(method, path, headers, body, self, app)
1395
+
1396
+ # Extract session token like the middleware does
1397
+ token = None
1398
+ if "X-Datadog-Test-Session-Token" in headers:
1399
+ token = headers["X-Datadog-Test-Session-Token"]
1400
+ mock_request["session_token"] = token
1401
+
1402
+ # Store request data for agent processing
1403
+ mock_request["_testagent_data"] = body
1404
+
1405
+ # Route to appropriate handler based on path using dictionary lookup
1406
+ path_handlers = {
1407
+ "/v0.4/traces": self.handle_v04_traces,
1408
+ "/v0.5/traces": self.handle_v05_traces,
1409
+ "/v0.7/traces": self.handle_v07_traces,
1410
+ "/v1.0/traces": self.handle_v1_traces,
1411
+ "/v0.6/stats": self.handle_v06_tracestats,
1412
+ "/v0.1/pipeline_stats": self.handle_v01_pipelinestats,
1413
+ "/v0.7/config": self.handle_v07_remoteconfig,
1414
+ "/telemetry/proxy/api/v2/apmtelemetry": self.handle_v2_apmtelemetry,
1415
+ "/profiling/v1/input": self.handle_v1_profiling,
1416
+ "/tracer_flare/v1": self.handle_v1_tracer_flare,
1417
+ "/evp_proxy/v2/api/v2/llmobs": self.handle_evp_proxy_v2_api_v2_llmobs,
1418
+ "/evp_proxy/v2/api/intake/llm-obs/v1/eval-metric": self.handle_evp_proxy_v2_llmobs_eval_metric,
1419
+ "/evp_proxy/v2/api/intake/llm-obs/v2/eval-metric": self.handle_evp_proxy_v2_llmobs_eval_metric,
1420
+ "/info": self.handle_info,
1421
+ # Test endpoints
1422
+ "/test/session/start": self.handle_session_start,
1423
+ "/test/session/clear": self.handle_session_clear,
1424
+ "/test/session/snapshot": self.handle_snapshot,
1425
+ "/test/session/traces": self.handle_session_traces,
1426
+ "/test/session/apmtelemetry": self.handle_session_apmtelemetry,
1427
+ "/test/session/tracerflares": self.handle_session_tracerflares,
1428
+ "/test/session/stats": self.handle_session_tracestats,
1429
+ "/test/session/requests": self.handle_session_requests,
1430
+ "/test/session/responses/config": self.handle_v07_remoteconfig_create,
1431
+ "/test/session/responses/config/path": self.handle_v07_remoteconfig_path_create,
1432
+ "/test/traces": self.handle_test_traces,
1433
+ "/test/apmtelemetry": self.handle_test_apmtelemetry,
1434
+ "/test/trace/analyze": self.handle_trace_analyze,
1435
+ "/test/trace_check/failures": self.get_trace_check_failures,
1436
+ "/test/trace_check/clear": self.clear_trace_check_failures,
1437
+ "/test/trace_check/summary": self.get_trace_check_summary,
1438
+ "/test/integrations/tested_versions": self.handle_get_tested_integrations,
1439
+ "/test/settings": self.handle_settings,
1440
+ }
1441
+
1442
+ # Get handler from dictionary lookup
1443
+ handler = path_handlers.get(path)
1444
+ if not handler:
1445
+ return self._create_error_response(404, "Not Found")
1446
+
1447
+ try:
1448
+ # Create a new event loop for this thread if one doesn't exist
1449
+ loop = asyncio.get_event_loop()
1450
+ except RuntimeError:
1451
+ loop = asyncio.new_event_loop()
1452
+ asyncio.set_event_loop(loop)
1453
+
1454
+ # Initialize the CheckTrace context like middleware does
1455
+ start_trace("named_pipe_request %s %s" % (method, path))
1456
+
1457
+ # Run the handler
1458
+ response = loop.run_until_complete(handler(mock_request)) # type: ignore[arg-type]
1459
+
1460
+ # Convert aiohttp response to HTTP bytes
1461
+ return self._convert_response_to_http(response)
1462
+
1463
+ except Exception as e:
1464
+ log.error(f"Error processing Named Pipe request: {e}", exc_info=True)
1465
+ return self._create_error_response(500, "Internal Server Error")
1466
+
1467
+ def _convert_response_to_http(self, response: web.Response) -> bytes:
1468
+ """Convert aiohttp Response to HTTP response bytes."""
1469
+ try:
1470
+ # Build HTTP response
1471
+ status_line = f"HTTP/1.1 {response.status} {response.reason}\r\n"
1472
+
1473
+ # Build headers
1474
+ headers_lines = []
1475
+ for key, value in response.headers.items():
1476
+ headers_lines.append(f"{key}: {value}\r\n")
1477
+
1478
+ # Get response body
1479
+ body_data: bytes
1480
+ if hasattr(response, "body") and response.body:
1481
+ if isinstance(response.body, bytes):
1482
+ body_data = response.body
1483
+ elif isinstance(response.body, str):
1484
+ body_data = response.body.encode()
1485
+ else:
1486
+ # Handle Payload or other types by converting to string first
1487
+ body_data = str(response.body).encode()
1488
+ else:
1489
+ body_data = b""
1490
+
1491
+ # Add Content-Length header if not present
1492
+ if "Content-Length" not in response.headers:
1493
+ headers_lines.append(f"Content-Length: {len(body_data)}\r\n")
1494
+
1495
+ # Combine all parts
1496
+ headers_str = "".join(headers_lines)
1497
+ http_response = status_line + headers_str + "\r\n"
1498
+ return http_response.encode("utf-8") + body_data
1499
+
1500
+ except Exception as e:
1501
+ log.error(f"Error converting response to HTTP: {e}")
1502
+ return self._create_error_response(500, "Internal Server Error")
1503
+
1504
+ def _create_error_response(self, status_code: int, reason: str) -> bytes:
1505
+ """Create an HTTP error response."""
1506
+ body = f"{status_code} {reason}".encode("utf-8")
1507
+ response = f"HTTP/1.1 {status_code} {reason}\r\n"
1508
+ response += f"Content-Length: {len(body)}\r\n"
1509
+ response += "Content-Type: text/plain\r\n"
1510
+ response += "\r\n"
1511
+ return response.encode("utf-8") + body
1512
+
1291
1513
 
1292
1514
  def make_otlp_http_app(agent: Agent) -> web.Application:
1293
1515
  """Create a separate HTTP application for OTLP endpoints using the shared agent instance."""
@@ -1356,6 +1578,9 @@ def make_app(
1356
1578
  snapshot_removed_attrs: List[str],
1357
1579
  snapshot_regex_placeholders: Dict[str, str],
1358
1580
  vcr_cassettes_directory: str,
1581
+ vcr_ci_mode: bool,
1582
+ vcr_provider_map: str,
1583
+ vcr_ignore_headers: str,
1359
1584
  ) -> web.Application:
1360
1585
  agent = Agent()
1361
1586
  app = web.Application(
@@ -1417,7 +1642,9 @@ def make_app(
1417
1642
  web.route(
1418
1643
  "*",
1419
1644
  "/vcr/{path:.*}",
1420
- lambda request: proxy_request(request, vcr_cassettes_directory),
1645
+ lambda request: proxy_request(
1646
+ request, vcr_cassettes_directory, vcr_ci_mode, vcr_provider_map, vcr_ignore_headers
1647
+ ),
1421
1648
  ),
1422
1649
  ]
1423
1650
  )
@@ -1449,6 +1676,137 @@ def make_app(
1449
1676
  return app
1450
1677
 
1451
1678
 
1679
+ def _start_named_pipe_server(pipe_path: str, agent: "Agent", app: web.Application) -> None:
1680
+ """Start Windows named pipe server."""
1681
+ if platform.system() != "Windows":
1682
+ log.warning("Named pipes are only supported on Windows, ignoring --trace-named-pipe")
1683
+ return
1684
+
1685
+ # Import Windows-specific modules here to avoid import errors on other platforms
1686
+ try:
1687
+ import win32file
1688
+ import win32pipe
1689
+ except ImportError as e:
1690
+ log.error(f"Failed to import Windows modules for named pipes: {e}")
1691
+ return
1692
+
1693
+ _start_windows_named_pipe_server(pipe_path, agent, app, win32pipe, win32file)
1694
+
1695
+
1696
+ def _create_and_wait_for_client(
1697
+ pipe_path: str, agent: "Agent", app: web.Application, win32pipe: Any, win32file: Any
1698
+ ) -> None:
1699
+ """Create a single pipe instance and wait for a client connection."""
1700
+ while True:
1701
+ try:
1702
+ # Create named pipe instance
1703
+ pipe_handle = win32pipe.CreateNamedPipe(
1704
+ pipe_path,
1705
+ win32pipe.PIPE_ACCESS_DUPLEX,
1706
+ win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT,
1707
+ win32pipe.PIPE_UNLIMITED_INSTANCES, # allow multiple concurrent connections
1708
+ 65536, # output buffer size
1709
+ 65536, # input buffer size
1710
+ 0, # default timeout
1711
+ None, # security attributes
1712
+ )
1713
+
1714
+ if pipe_handle == win32file.INVALID_HANDLE_VALUE:
1715
+ log.error("Failed to create named pipe instance")
1716
+ import time
1717
+
1718
+ time.sleep(1) # Wait before retrying
1719
+ continue
1720
+
1721
+ log.debug("Named pipe instance created, waiting for client...")
1722
+
1723
+ # Wait for client connection
1724
+ win32pipe.ConnectNamedPipe(pipe_handle, None)
1725
+ log.info("Client connected to named pipe instance")
1726
+
1727
+ # Handle the client request
1728
+ _handle_windows_named_pipe_client(pipe_handle, agent, app, win32pipe, win32file)
1729
+
1730
+ except Exception as e:
1731
+ log.error(f"Error in named pipe instance: {e}")
1732
+ import time
1733
+
1734
+ time.sleep(1) # Wait before retrying
1735
+
1736
+
1737
+ def _start_windows_named_pipe_server(
1738
+ pipe_path: str, agent: "Agent", app: web.Application, win32pipe: Any, win32file: Any
1739
+ ) -> None:
1740
+ """Start a Windows named pipe server with multiple instances."""
1741
+ if win32pipe is None:
1742
+ log.error("Windows named pipe support not available (pywin32 not installed)")
1743
+ return
1744
+
1745
+ log.info(f"Starting Windows named pipe server on: {pipe_path}")
1746
+
1747
+ # Create multiple pipe instances for better concurrency
1748
+ num_instances = 10 # Support up to 10 concurrent connections
1749
+ threads = []
1750
+
1751
+ for _ in range(num_instances):
1752
+ thread = threading.Thread(
1753
+ target=_create_and_wait_for_client, args=(pipe_path, agent, app, win32pipe, win32file), daemon=True
1754
+ )
1755
+ thread.start()
1756
+ threads.append(thread)
1757
+
1758
+ log.info(f"Started {num_instances} named pipe instances")
1759
+
1760
+ # Keep the main thread alive and monitor instance threads
1761
+ try:
1762
+ while True:
1763
+ import time
1764
+
1765
+ time.sleep(5)
1766
+
1767
+ # Check if any threads have died and restart them
1768
+ for i, thread in enumerate(threads):
1769
+ if not thread.is_alive():
1770
+ log.warning(f"Restarting named pipe instance {i}")
1771
+ new_thread = threading.Thread(
1772
+ target=_create_and_wait_for_client,
1773
+ args=(pipe_path, agent, app, win32pipe, win32file),
1774
+ daemon=True,
1775
+ )
1776
+ new_thread.start()
1777
+ threads[i] = new_thread
1778
+
1779
+ except KeyboardInterrupt:
1780
+ log.info("Named pipe server shutting down")
1781
+
1782
+
1783
+ def _handle_windows_named_pipe_client(
1784
+ pipe_handle: Any, agent: "Agent", app: web.Application, win32pipe: Any, win32file: Any
1785
+ ) -> None:
1786
+ """Handle a Windows named pipe client connection."""
1787
+ try:
1788
+ # Read request data
1789
+ result, data = win32file.ReadFile(pipe_handle, 65536)
1790
+ if result == 0: # SUCCESS
1791
+ log.info(f"Received {len(data)} bytes from named pipe client")
1792
+
1793
+ # Process request
1794
+ response = agent._process_named_pipe_request(data, app)
1795
+
1796
+ # Write response
1797
+ win32file.WriteFile(pipe_handle, response)
1798
+ log.info(f"Sent {len(response)} bytes response to named pipe client")
1799
+
1800
+ except Exception as e:
1801
+ log.error(f"Error handling Windows named pipe client: {e}")
1802
+ finally:
1803
+ try:
1804
+ win32pipe.DisconnectNamedPipe(pipe_handle)
1805
+ win32file.CloseHandle(pipe_handle)
1806
+ except Exception:
1807
+ pass
1808
+
1809
+
1452
1810
  def main(args: Optional[List[str]] = None) -> None:
1453
1811
  if args is None:
1454
1812
  args = sys.argv[1:]
@@ -1550,6 +1908,12 @@ def main(args: Optional[List[str]] = None) -> None:
1550
1908
  default=os.environ.get("DD_APM_RECEIVER_SOCKET", None),
1551
1909
  help=("Will listen for traces on the specified socket path"),
1552
1910
  )
1911
+ parser.add_argument(
1912
+ "--trace-named-pipe",
1913
+ type=str,
1914
+ default=os.environ.get("DD_APM_RECEIVER_NAMED_PIPE", None),
1915
+ help=("Will listen for traces on the specified named pipe path"),
1916
+ )
1553
1917
  parser.add_argument(
1554
1918
  "--trace-request-delay",
1555
1919
  type=float,
@@ -1582,6 +1946,24 @@ def main(args: Optional[List[str]] = None) -> None:
1582
1946
  default=os.environ.get("VCR_CASSETTES_DIRECTORY", os.path.join(os.getcwd(), "vcr-cassettes")),
1583
1947
  help="Directory to read and store third party API cassettes.",
1584
1948
  )
1949
+ parser.add_argument(
1950
+ "--vcr-ci-mode",
1951
+ type=bool,
1952
+ default=os.environ.get("VCR_CI_MODE", False),
1953
+ help="Will change the test agent to record VCR cassettes in CI mode, throwing an error if a cassette is not found on /vcr/{provider}",
1954
+ )
1955
+ parser.add_argument(
1956
+ "--vcr-provider-map",
1957
+ type=str,
1958
+ default=os.environ.get("VCR_PROVIDER_MAP", ""),
1959
+ help="Comma-separated list of provider=base_url tuples to map providers to paths. Used in addition to the default provider paths.",
1960
+ )
1961
+ parser.add_argument(
1962
+ "--vcr-ignore-headers",
1963
+ type=str,
1964
+ default=os.environ.get("VCR_IGNORE_HEADERS", ""),
1965
+ help="Comma-separated list of headers to ignore when recording VCR cassettes.",
1966
+ )
1585
1967
  parsed_args = parser.parse_args(args=args)
1586
1968
  logging.basicConfig(level=parsed_args.log_level)
1587
1969
 
@@ -1625,6 +2007,9 @@ def main(args: Optional[List[str]] = None) -> None:
1625
2007
  snapshot_removed_attrs=parsed_args.snapshot_removed_attrs,
1626
2008
  snapshot_regex_placeholders=parsed_args.snapshot_regex_placeholders,
1627
2009
  vcr_cassettes_directory=parsed_args.vcr_cassettes_directory,
2010
+ vcr_ci_mode=parsed_args.vcr_ci_mode,
2011
+ vcr_provider_map=parsed_args.vcr_provider_map,
2012
+ vcr_ignore_headers=parsed_args.vcr_ignore_headers,
1628
2013
  )
1629
2014
 
1630
2015
  # Validate port configuration
@@ -1637,6 +2022,18 @@ def main(args: Optional[List[str]] = None) -> None:
1637
2022
 
1638
2023
  # Get the shared agent instance from the main app
1639
2024
  agent = app["agent"]
2025
+
2026
+ # Named pipe setup (after agent is available)
2027
+ named_pipe_thread = None
2028
+ if parsed_args.trace_named_pipe is not None:
2029
+
2030
+ def start_named_pipe_server():
2031
+ _start_named_pipe_server(parsed_args.trace_named_pipe, agent, app)
2032
+
2033
+ named_pipe_thread = threading.Thread(target=start_named_pipe_server, daemon=True)
2034
+ named_pipe_thread.start()
2035
+ log.info(f"Started named pipe server on: {parsed_args.trace_named_pipe}")
2036
+
1640
2037
  otlp_http_app = make_otlp_http_app(agent)
1641
2038
 
1642
2039
  async def run_servers():
@@ -34,6 +34,27 @@ log = logging.getLogger(__name__)
34
34
  DEFAULT_SNAPSHOT_IGNORES = "span_id,trace_id,parent_id,duration,start,metrics.system.pid,metrics.system.process_id,metrics.process_id,metrics._dd.tracer_kr,meta.runtime-id,span_links.trace_id_high,span_events.time_unix_nano,meta.pathway.hash,meta._dd.p.tid"
35
35
 
36
36
 
37
+ def _normalize_span_for_comparison(span: Span) -> Span:
38
+ """Normalize span for cross-platform comparison (Windows vs Unix).
39
+
40
+ Handles differences in how ddtrace creates spans on different platforms:
41
+ - Windows may omit fields with default values (error, type)
42
+ - Windows may use None instead of empty string for service
43
+ """
44
+ normalized = dict(span)
45
+
46
+ if "error" not in normalized:
47
+ normalized["error"] = 0
48
+
49
+ if "type" not in normalized:
50
+ normalized["type"] = ""
51
+
52
+ if normalized.get("service") is None:
53
+ normalized["service"] = ""
54
+
55
+ return cast(Span, normalized)
56
+
57
+
37
58
  def _key_match(d1: Dict[str, Any], d2: Dict[str, Any], key: str) -> bool:
38
59
  """
39
60
  >>> _key_match({"a": 1}, {"a": 2}, "a")
@@ -347,6 +368,10 @@ def _compare_traces(expected: Trace, received: Trace, ignored: Set[str]) -> None
347
368
  )
348
369
 
349
370
  for s_exp, s_rec in zip(expected, received):
371
+ # Normalize spans for cross-platform comparison (Windows vs Unix)
372
+ s_exp_norm = _normalize_span_for_comparison(s_exp)
373
+ s_rec_norm = _normalize_span_for_comparison(s_rec)
374
+
350
375
  with CheckTrace.add_frame(
351
376
  f"snapshot compare of span '{s_exp['name']}' at position {s_exp['span_id']} in trace"
352
377
  ) as frame:
@@ -358,12 +383,12 @@ def _compare_traces(expected: Trace, received: Trace, ignored: Set[str]) -> None
358
383
  metrics_diffs,
359
384
  span_link_diffs,
360
385
  span_event_diffs,
361
- ) = _diff_spans(s_exp, s_rec, ignored)
386
+ ) = _diff_spans(s_exp_norm, s_rec_norm, ignored)
362
387
 
363
388
  for diffs, diff_type, d_exp, d_rec in [
364
- (top_level_diffs, "span", s_exp, s_rec),
365
- (meta_diffs, "meta", s_exp["meta"], s_rec["meta"]),
366
- (metrics_diffs, "metrics", s_exp["metrics"], s_rec["metrics"]),
389
+ (top_level_diffs, "span", s_exp_norm, s_rec_norm),
390
+ (meta_diffs, "meta", s_exp_norm["meta"], s_rec_norm["meta"]),
391
+ (metrics_diffs, "metrics", s_exp_norm["metrics"], s_rec_norm["metrics"]),
367
392
  ]:
368
393
  for diff_key in diffs:
369
394
  if diff_key not in d_exp:
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import hashlib
2
3
  import json
3
4
  import logging
@@ -75,6 +76,16 @@ def _file_safe_string(s: str) -> str:
75
76
  return "".join(c if c.isalnum() or c in ".-" else "_" for c in s)
76
77
 
77
78
 
79
+ def get_custom_vcr_providers(vcr_provider_map: str) -> Dict[str, str]:
80
+ return dict(
81
+ [
82
+ vcr_provider_map.strip().split("=", 1)
83
+ for vcr_provider_map in vcr_provider_map.split(",")
84
+ if vcr_provider_map.strip()
85
+ ]
86
+ )
87
+
88
+
78
89
  def normalize_multipart_body(body: bytes) -> str:
79
90
  if not body:
80
91
  return ""
@@ -114,14 +125,15 @@ def parse_authorization_header(auth_header: str) -> Dict[str, str]:
114
125
  return parsed
115
126
 
116
127
 
117
- def get_vcr(subdirectory: str, vcr_cassettes_directory: str) -> vcr.VCR:
128
+ def get_vcr(subdirectory: str, vcr_cassettes_directory: str, vcr_ignore_headers: str) -> vcr.VCR:
118
129
  cassette_dir = os.path.join(vcr_cassettes_directory, subdirectory)
130
+ extra_ignore_headers = vcr_ignore_headers.split(",")
119
131
 
120
132
  return vcr.VCR(
121
133
  cassette_library_dir=cassette_dir,
122
134
  record_mode="once",
123
135
  match_on=["path", "method"],
124
- filter_headers=CASSETTE_FILTER_HEADERS,
136
+ filter_headers=CASSETTE_FILTER_HEADERS + extra_ignore_headers,
125
137
  )
126
138
 
127
139
 
@@ -146,7 +158,12 @@ def generate_cassette_name(path: str, method: str, body: bytes, vcr_cassette_pre
146
158
  )
147
159
 
148
160
 
149
- async def proxy_request(request: Request, vcr_cassettes_directory: str) -> Response:
161
+ async def proxy_request(
162
+ request: Request, vcr_cassettes_directory: str, vcr_ci_mode: bool, vcr_provider_map: str, vcr_ignore_headers: str
163
+ ) -> Response:
164
+ provider_base_urls = PROVIDER_BASE_URLS.copy()
165
+ provider_base_urls.update(get_custom_vcr_providers(vcr_provider_map))
166
+
150
167
  path = request.match_info["path"]
151
168
  if request.query_string:
152
169
  path = path + "?" + request.query_string
@@ -156,18 +173,25 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str) -> Respo
156
173
  return Response(body="Invalid path format. Expected /{provider}/...", status=400)
157
174
 
158
175
  provider, remaining_path = parts
159
- if provider not in PROVIDER_BASE_URLS:
176
+ if provider not in provider_base_urls:
160
177
  return Response(body=f"Unsupported provider: {provider}", status=400)
161
178
 
162
- target_url = url_path_join(PROVIDER_BASE_URLS[provider], remaining_path)
163
-
164
- headers = {key: value for key, value in request.headers.items() if key != "Host"}
165
-
166
179
  body_bytes = await request.read()
167
180
 
168
181
  vcr_cassette_prefix = request.pop("vcr_cassette_prefix", None)
169
182
  cassette_name = generate_cassette_name(path, request.method, body_bytes, vcr_cassette_prefix)
170
183
  cassette_file_name = f"{cassette_name}.yaml"
184
+ cassette_file_path = os.path.join(vcr_cassettes_directory, provider, cassette_file_name)
185
+ cassette_exists = os.path.exists(cassette_file_path)
186
+
187
+ if vcr_ci_mode and not cassette_exists:
188
+ return Response(
189
+ body=f"Cassette {cassette_file_name} not found while running in CI mode. Please generate the cassette locally and commit it.",
190
+ status=500,
191
+ )
192
+
193
+ target_url = url_path_join(provider_base_urls[provider], remaining_path)
194
+ headers = {key: value for key, value in request.headers.items() if key != "Host"}
171
195
 
172
196
  request_kwargs: Dict[str, Any] = {
173
197
  "method": request.method,
@@ -179,9 +203,7 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str) -> Respo
179
203
  "stream": True,
180
204
  }
181
205
 
182
- if provider in AWS_SERVICES and not os.path.exists(
183
- os.path.join(vcr_cassettes_directory, provider, cassette_file_name)
184
- ):
206
+ if provider in AWS_SERVICES and not cassette_exists:
185
207
  if not AWS_SECRET_ACCESS_KEY:
186
208
  return Response(
187
209
  body="AWS_SECRET_ACCESS_KEY environment variable not set for aws signature recalculation",
@@ -195,8 +217,11 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str) -> Respo
195
217
  auth = AWS4Auth(aws_access_key, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_SERVICES[provider])
196
218
  request_kwargs["auth"] = auth
197
219
 
198
- with get_vcr(provider, vcr_cassettes_directory).use_cassette(cassette_file_name):
199
- provider_response = requests.request(**request_kwargs)
220
+ def _make_request():
221
+ with get_vcr(provider, vcr_cassettes_directory, vcr_ignore_headers).use_cassette(cassette_file_name):
222
+ return requests.request(**request_kwargs)
223
+
224
+ provider_response = await asyncio.to_thread(_make_request)
200
225
 
201
226
  # Extract content type without charset
202
227
  content_type = provider_response.headers.get("content-type", "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddapm-test-agent
3
- Version: 1.33.1
3
+ Version: 1.35.0
4
4
  Summary: Test agent for Datadog APM client libraries
5
5
  Home-page: https://github.com/Datadog/dd-apm-test-agent
6
6
  Author: Kyle Verhoog
@@ -25,10 +25,12 @@ Requires-Dist: requests-aws4auth
25
25
  Requires-Dist: opentelemetry-proto<1.37.0,>1.33.0
26
26
  Requires-Dist: protobuf>=3.19.0
27
27
  Requires-Dist: grpcio<2.0,>=1.66.2
28
+ Requires-Dist: pywin32; sys_platform == "win32"
28
29
  Provides-Extra: testing
29
30
  Requires-Dist: ddtrace==3.11.0; extra == "testing"
30
31
  Requires-Dist: pytest; extra == "testing"
31
32
  Requires-Dist: riot==0.20.1; extra == "testing"
33
+ Requires-Dist: PyYAML==6.0.3; extra == "testing"
32
34
  Dynamic: author
33
35
  Dynamic: author-email
34
36
  Dynamic: classifier
@@ -183,7 +185,33 @@ The cassettes are matched based on the path, method, and body of the request. To
183
185
  -v $PWD/vcr-cassettes:/vcr-cassettes
184
186
  ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:latest
185
187
 
186
- Optionally specifying whatever mounted path is used for the cassettes directory. The test agent comes with a default set of cassettes for OpenAI, Azure OpenAI, and DeepSeek.
188
+ Optionally specifying whatever mounted path is used for the cassettes directory. The test agent comes with a default set of cassettes for OpenAI, Azure OpenAI, DeepSeek, Anthropic, Google GenAI, and AWS Bedrock Runtime.
189
+
190
+ #### Custom 3rd Party Providers
191
+
192
+ The test agent can be configured to also register custom 3rd party providers. This is done by setting the `VCR_PROVIDER_MAP` environment variable or the `--vcr-provider-map` command-line option to a comma-separated list of provider names and their corresponding base URLs.
193
+
194
+ ```shell
195
+ VCR_PROVIDER_MAP="provider1=http://provider1.com/,provider2=http://provider2.com/"
196
+ ```
197
+
198
+ or
199
+
200
+ ```shell
201
+ --vcr-provider-map="provider1=http://provider1.com/,provider2=http://provider2.com/"
202
+ ```
203
+
204
+ The provider names are used to match the provider name in the request path, and the base URLs are used to proxy the request to the corresponding provider API endpoint.
205
+
206
+ With this configuration set, you can make the following request to the test agent without error:
207
+
208
+ ```shell
209
+ curl -X POST 'http://127.0.0.1:9126/vcr/provider1/some/path'
210
+ ```
211
+
212
+ #### Ignoring Headers in Recorded Cassettes
213
+
214
+ To ignore headers in recorded cassettes, you can use the `--vcr-ignore-headers` flag or `VCR_IGNORE_HEADERS` environment variable. The list should take the form of `header1,header2,header3`, and will be omitted from the recorded cassettes.
187
215
 
188
216
  #### AWS Services
189
217
  AWS service proxying, specifically recording cassettes for the first time, requires a `AWS_SECRET_ACCESS_KEY` environment variable to be set for the container running the test agent. This is used to recalculate the AWS signature for the request, as the one generated client-side likely used `{test-agent-host}:{test-agent-port}/vcr/{aws-service}` as the host, and the signature will mismatch that on the actual AWS service.
@@ -240,6 +268,10 @@ And pass in a valid API key (if needed) in the way that provider expects.
240
268
 
241
269
  To redact api keys, modify the `filter_headers` list in the `get_vcr` function in `ddapm_test_agent/vcr_proxy.py`. This can be confirmed by viewing cassettes in the `vcr-cassettes` directory (or the otherwise specified directory), and verifying that any new cassettes do not contain the api key.
242
270
 
271
+ #### Running in CI
272
+
273
+ To have the vcr proxy throw a 404 error if a cassette is not found in CI mode to ensure that all cassettes are generated locally and committed, set the `VCR_CI_MODE` environment variable or the `--vcr-ci-mode` flag in the cli tool to `true` (this value defaults to `false`).
274
+
243
275
  ## Configuration
244
276
 
245
277
  The test agent can be configured via command-line options or via environment variables.
@@ -1,5 +1,5 @@
1
1
  ddapm_test_agent/__init__.py,sha256=IEYMDM-xI0IoHYSYw4Eva5263puB_crrrbLstOCScRw,106
2
- ddapm_test_agent/agent.py,sha256=296wweDNsF7cw5QuGdSzbKa4vWrVpea3rvZU4Laddkc,74000
2
+ ddapm_test_agent/agent.py,sha256=EIIIAPYvz6NyJqgiNDWQqHE0TWnoQ_HCqzYUmOM8Mz0,89626
3
3
  ddapm_test_agent/apmtelemetry.py,sha256=w_9-yUDh1dgox-FfLqeOHU2C14GcjOjen-_SVagiZrc,861
4
4
  ddapm_test_agent/checks.py,sha256=pBa4YKZQVA8qaTVJ_XgMA6TmlUZNh99YOrCFJA7fwo0,6865
5
5
  ddapm_test_agent/client.py,sha256=ViEmiRX9Y3SQ-KBhSc-FdzBmIVIe8Ij9jj-Q6VGyzLY,7359
@@ -12,15 +12,15 @@ ddapm_test_agent/metrics.py,sha256=EZo7lSec2oAiH7tUqavKZ2MJM7TwbuFGE3AT3cXwmSM,3
12
12
  ddapm_test_agent/remoteconfig.py,sha256=_QjYUKc3JF31DxdvISDXgslm5WVnYWAw0hyckWuLc1c,3606
13
13
  ddapm_test_agent/trace.py,sha256=t0OR8w3NcZK-EOOoadgPITiZqS5tAJGtxqLVGLEw7Kg,45816
14
14
  ddapm_test_agent/trace_checks.py,sha256=bRg2eLKoHROXIFJRbujMUn0T3x1X8pZso-j8wXNomec,9972
15
- ddapm_test_agent/trace_snapshot.py,sha256=ayOUcCFo6xyotFRm0fSNdeA91_T447W5ShlkRvi0nZE,22932
15
+ ddapm_test_agent/trace_snapshot.py,sha256=vcz9uCgtpnInKl32nq1n62shhsVdMQPzOWfV3-RjTVM,23781
16
16
  ddapm_test_agent/tracerflare.py,sha256=uoSjhPCOKZflgJn5JLv1Unh4gUdAR1-YbC9_1n1iH9w,954
17
17
  ddapm_test_agent/tracestats.py,sha256=q_WQZnh2kXSSN3fRIBe_0jMYCBQHcaS3fZmJTge4lWc,2073
18
18
  ddapm_test_agent/tracestats_snapshot.py,sha256=VsB6MVnHPjPWHVWnnDdCXJcVKL_izKXEf9lvJ0qbjNQ,3609
19
- ddapm_test_agent/vcr_proxy.py,sha256=jTm9Q0LeWOUJrwlDrG632WrQB6ScWvjb3dLR7oSFa6o,6808
20
- ddapm_test_agent-1.33.1.dist-info/licenses/LICENSE.BSD3,sha256=J9S_Tq-hhvteDV2W8R0rqht5DZHkmvgdx3gnLZg4j6Q,1493
21
- ddapm_test_agent-1.33.1.dist-info/licenses/LICENSE.apache2,sha256=5V2RruBHZQIcPyceiv51DjjvdvhgsgS4pnXAOHDuZkQ,11342
22
- ddapm_test_agent-1.33.1.dist-info/METADATA,sha256=fPwXRZgry4efp28Ft0jEWhzxwW8rZx134I01GBFHt0c,28104
23
- ddapm_test_agent-1.33.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- ddapm_test_agent-1.33.1.dist-info/entry_points.txt,sha256=ulayVs6YJ-0Ej2kxbwn39wOHDVXbyQgFgsbRQmXydcs,250
25
- ddapm_test_agent-1.33.1.dist-info/top_level.txt,sha256=A9jiKOrrg6VjFAk-mtlSVYN4wr0VsZe58ehGR6IW47U,17
26
- ddapm_test_agent-1.33.1.dist-info/RECORD,,
19
+ ddapm_test_agent/vcr_proxy.py,sha256=dEJ5xXxxvFHAbvN-OgXEaOlqz54Vh1k_1eQBgf146lU,7812
20
+ ddapm_test_agent-1.35.0.dist-info/licenses/LICENSE.BSD3,sha256=J9S_Tq-hhvteDV2W8R0rqht5DZHkmvgdx3gnLZg4j6Q,1493
21
+ ddapm_test_agent-1.35.0.dist-info/licenses/LICENSE.apache2,sha256=5V2RruBHZQIcPyceiv51DjjvdvhgsgS4pnXAOHDuZkQ,11342
22
+ ddapm_test_agent-1.35.0.dist-info/METADATA,sha256=1MNta2Fe5Bl7amhHjPBTcDt9purf0FpgsVD_BVy1ok8,29678
23
+ ddapm_test_agent-1.35.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ ddapm_test_agent-1.35.0.dist-info/entry_points.txt,sha256=ulayVs6YJ-0Ej2kxbwn39wOHDVXbyQgFgsbRQmXydcs,250
25
+ ddapm_test_agent-1.35.0.dist-info/top_level.txt,sha256=A9jiKOrrg6VjFAk-mtlSVYN4wr0VsZe58ehGR6IW47U,17
26
+ ddapm_test_agent-1.35.0.dist-info/RECORD,,