Skip to content

Commit d36af9e

Browse files
authored
Merge branch 'master' into khanayan123/add-ffe-endpoint
2 parents b99891f + ab63282 commit d36af9e

File tree

8 files changed

+316
-26
lines changed

8 files changed

+316
-26
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,33 @@ The cassettes are matched based on the path, method, and body of the request. To
139139
-v $PWD/vcr-cassettes:/vcr-cassettes
140140
ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:latest
141141

142-
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.
142+
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.
143+
144+
#### Custom 3rd Party Providers
145+
146+
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.
147+
148+
```shell
149+
VCR_PROVIDER_MAP="provider1=http://provider1.com/,provider2=http://provider2.com/"
150+
```
151+
152+
or
153+
154+
```shell
155+
--vcr-provider-map="provider1=http://provider1.com/,provider2=http://provider2.com/"
156+
```
157+
158+
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.
159+
160+
With this configuration set, you can make the following request to the test agent without error:
161+
162+
```shell
163+
curl -X POST 'http://127.0.0.1:9126/vcr/provider1/some/path'
164+
```
165+
166+
#### Ignoring Headers in Recorded Cassettes
167+
168+
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.
143169

144170
#### AWS Services
145171
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.

ddapm_test_agent/agent.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,8 @@ def make_app(
15851585
snapshot_regex_placeholders: Dict[str, str],
15861586
vcr_cassettes_directory: str,
15871587
vcr_ci_mode: bool,
1588+
vcr_provider_map: str,
1589+
vcr_ignore_headers: str,
15881590
) -> web.Application:
15891591
agent = Agent()
15901592
app = web.Application(
@@ -1647,7 +1649,9 @@ def make_app(
16471649
web.route(
16481650
"*",
16491651
"/vcr/{path:.*}",
1650-
lambda request: proxy_request(request, vcr_cassettes_directory, vcr_ci_mode),
1652+
lambda request: proxy_request(
1653+
request, vcr_cassettes_directory, vcr_ci_mode, vcr_provider_map, vcr_ignore_headers
1654+
),
16511655
),
16521656
]
16531657
)
@@ -1955,6 +1959,18 @@ def main(args: Optional[List[str]] = None) -> None:
19551959
default=os.environ.get("VCR_CI_MODE", False),
19561960
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}",
19571961
)
1962+
parser.add_argument(
1963+
"--vcr-provider-map",
1964+
type=str,
1965+
default=os.environ.get("VCR_PROVIDER_MAP", ""),
1966+
help="Comma-separated list of provider=base_url tuples to map providers to paths. Used in addition to the default provider paths.",
1967+
)
1968+
parser.add_argument(
1969+
"--vcr-ignore-headers",
1970+
type=str,
1971+
default=os.environ.get("VCR_IGNORE_HEADERS", ""),
1972+
help="Comma-separated list of headers to ignore when recording VCR cassettes.",
1973+
)
19581974
parsed_args = parser.parse_args(args=args)
19591975
logging.basicConfig(level=parsed_args.log_level)
19601976

@@ -1999,6 +2015,8 @@ def main(args: Optional[List[str]] = None) -> None:
19992015
snapshot_regex_placeholders=parsed_args.snapshot_regex_placeholders,
20002016
vcr_cassettes_directory=parsed_args.vcr_cassettes_directory,
20012017
vcr_ci_mode=parsed_args.vcr_ci_mode,
2018+
vcr_provider_map=parsed_args.vcr_provider_map,
2019+
vcr_ignore_headers=parsed_args.vcr_ignore_headers,
20022020
)
20032021

20042022
# Validate port configuration

ddapm_test_agent/vcr_proxy.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import hashlib
23
import json
34
import logging
@@ -75,6 +76,16 @@ def _file_safe_string(s: str) -> str:
7576
return "".join(c if c.isalnum() or c in ".-" else "_" for c in s)
7677

7778

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+
7889
def normalize_multipart_body(body: bytes) -> str:
7990
if not body:
8091
return ""
@@ -114,14 +125,15 @@ def parse_authorization_header(auth_header: str) -> Dict[str, str]:
114125
return parsed
115126

116127

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:
118129
cassette_dir = os.path.join(vcr_cassettes_directory, subdirectory)
130+
extra_ignore_headers = vcr_ignore_headers.split(",")
119131

120132
return vcr.VCR(
121133
cassette_library_dir=cassette_dir,
122134
record_mode="once",
123135
match_on=["path", "method"],
124-
filter_headers=CASSETTE_FILTER_HEADERS,
136+
filter_headers=CASSETTE_FILTER_HEADERS + extra_ignore_headers,
125137
)
126138

127139

@@ -146,7 +158,12 @@ def generate_cassette_name(path: str, method: str, body: bytes, vcr_cassette_pre
146158
)
147159

148160

149-
async def proxy_request(request: Request, vcr_cassettes_directory: str, vcr_ci_mode: bool) -> 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+
150167
path = request.match_info["path"]
151168
if request.query_string:
152169
path = path + "?" + request.query_string
@@ -156,7 +173,7 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str, vcr_ci_m
156173
return Response(body="Invalid path format. Expected /{provider}/...", status=400)
157174

158175
provider, remaining_path = parts
159-
if provider not in PROVIDER_BASE_URLS:
176+
if provider not in provider_base_urls:
160177
return Response(body=f"Unsupported provider: {provider}", status=400)
161178

162179
body_bytes = await request.read()
@@ -173,7 +190,7 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str, vcr_ci_m
173190
status=500,
174191
)
175192

176-
target_url = url_path_join(PROVIDER_BASE_URLS[provider], remaining_path)
193+
target_url = url_path_join(provider_base_urls[provider], remaining_path)
177194
headers = {key: value for key, value in request.headers.items() if key != "Host"}
178195

179196
request_kwargs: Dict[str, Any] = {
@@ -200,8 +217,11 @@ async def proxy_request(request: Request, vcr_cassettes_directory: str, vcr_ci_m
200217
auth = AWS4Auth(aws_access_key, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_SERVICES[provider])
201218
request_kwargs["auth"] = auth
202219

203-
with get_vcr(provider, vcr_cassettes_directory).use_cassette(cassette_file_name):
204-
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)
205225

206226
# Extract content type without charset
207227
content_type = provider_response.headers.get("content-type", "")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
features:
3+
- |
4+
vcr: Adds support for specifying a list of custom providers in the ``--vcr-provider-map`` flag or ``VCR_PROVIDER_MAP`` environment variable. The list should take the form of ``provider1=http://provider1.com/,provider2=http://provider2.com/``.
5+
- |
6+
vcr: Adds support for specifying a list of headers to ignore when recording VCR cassettes in the ``--vcr-ignore-headers`` flag or ``VCR_IGNORE_HEADERS`` environment variable. The list should take the form of ``header1,header2,header3``.

riotfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"types-protobuf": latest,
6464
"types-requests": latest,
6565
"types-setuptools": latest,
66+
"types-PyYAML": latest,
6667
},
6768
),
6869
Venv(

test_deps.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
ddtrace==3.11.0
22
pytest
33
riot==0.20.1
4+
PyYAML==6.0.3

tests/conftest.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,32 @@ def snapshot_regex_placeholders() -> Generator[Dict[str, str], None, None]:
106106

107107

108108
@pytest.fixture
109-
def snapshot_server_cassettes_directory() -> Generator[str, None, None]:
110-
yield "/snapshot-server-cassettes"
109+
def vcr_cassettes_directory() -> Generator[str, None, None]:
110+
import shutil
111+
import tempfile
112+
113+
vcr_dir = tempfile.mkdtemp(prefix="vcr-cassettes-")
114+
try:
115+
yield vcr_dir
116+
finally:
117+
shutil.rmtree(vcr_dir, ignore_errors=True)
111118

112119

113120
@pytest.fixture
114121
def vcr_ci_mode() -> Generator[bool, None, None]:
115122
yield False
116123

117124

125+
@pytest.fixture
126+
def vcr_provider_map() -> Generator[str, None, None]:
127+
yield ""
128+
129+
130+
@pytest.fixture
131+
def vcr_ignore_headers() -> Generator[str, None, None]:
132+
yield ""
133+
134+
118135
@pytest.fixture
119136
async def agent_app(
120137
aiohttp_server,
@@ -130,25 +147,29 @@ async def agent_app(
130147
disable_error_responses,
131148
snapshot_removed_attrs,
132149
snapshot_regex_placeholders,
133-
snapshot_server_cassettes_directory,
150+
vcr_cassettes_directory,
134151
vcr_ci_mode,
152+
vcr_provider_map,
153+
vcr_ignore_headers,
135154
):
136155
app = await aiohttp_server(
137156
make_app(
138-
agent_enabled_checks,
139-
log_span_fmt,
140-
str(snapshot_dir),
141-
snapshot_ci_mode,
142-
snapshot_ignored_attrs,
143-
agent_url,
144-
trace_request_delay,
145-
suppress_trace_parse_errors,
146-
pool_trace_check_failures,
147-
disable_error_responses,
148-
snapshot_removed_attrs,
149-
snapshot_regex_placeholders,
150-
snapshot_server_cassettes_directory,
151-
vcr_ci_mode,
157+
enabled_checks=agent_enabled_checks,
158+
log_span_fmt=log_span_fmt,
159+
snapshot_dir=str(snapshot_dir),
160+
snapshot_ci_mode=snapshot_ci_mode,
161+
snapshot_ignored_attrs=snapshot_ignored_attrs,
162+
agent_url=agent_url,
163+
trace_request_delay=trace_request_delay,
164+
suppress_trace_parse_errors=suppress_trace_parse_errors,
165+
pool_trace_check_failures=pool_trace_check_failures,
166+
disable_error_responses=disable_error_responses,
167+
snapshot_removed_attrs=snapshot_removed_attrs,
168+
snapshot_regex_placeholders=snapshot_regex_placeholders,
169+
vcr_cassettes_directory=vcr_cassettes_directory,
170+
vcr_ci_mode=vcr_ci_mode,
171+
vcr_provider_map=vcr_provider_map,
172+
vcr_ignore_headers=vcr_ignore_headers,
152173
)
153174
)
154175
yield app

0 commit comments

Comments
 (0)