Skip to content

Commit 3df65a0

Browse files
sabrenneranais-raison
authored andcommitted
feat(vcr): add ability to add custom providers in flag/environment variable (#250)
* add arg parse support for vcr provider map reading * add vcr support for parsing custom providers map * rel note * add tests * default fixture in conftest for vcr provider map * rename test variables * update readme with providers * read custom ignore-list of headers for recording vcr cassettes * use aiohttp_server instead * add types pyyaml to riotfile for mypy * safer get_custom_providers implementation * add pyyaml to test deps
1 parent 4f7db97 commit 3df65a0

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
@@ -148,7 +148,33 @@ The cassettes are matched based on the path, method, and body of the request. To
148148
-v $PWD/vcr-cassettes:/vcr-cassettes
149149
ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:latest
150150

151-
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.
151+
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.
152+
153+
#### Custom 3rd Party Providers
154+
155+
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.
156+
157+
```shell
158+
VCR_PROVIDER_MAP="provider1=http://provider1.com/,provider2=http://provider2.com/"
159+
```
160+
161+
or
162+
163+
```shell
164+
--vcr-provider-map="provider1=http://provider1.com/,provider2=http://provider2.com/"
165+
```
166+
167+
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.
168+
169+
With this configuration set, you can make the following request to the test agent without error:
170+
171+
```shell
172+
curl -X POST 'http://127.0.0.1:9126/vcr/provider1/some/path'
173+
```
174+
175+
#### Ignoring Headers in Recorded Cassettes
176+
177+
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.
152178

153179
#### AWS Services
154180
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
@@ -1580,6 +1580,8 @@ def make_app(
15801580
snapshot_regex_placeholders: Dict[str, str],
15811581
vcr_cassettes_directory: str,
15821582
vcr_ci_mode: bool,
1583+
vcr_provider_map: str,
1584+
vcr_ignore_headers: str,
15831585
enable_web_ui: bool = False,
15841586
) -> web.Application:
15851587
agent = Agent()
@@ -1653,7 +1655,9 @@ def make_app(
16531655
web.route(
16541656
"*",
16551657
"/vcr/{path:.*}",
1656-
lambda request: proxy_request(request, vcr_cassettes_directory, vcr_ci_mode),
1658+
lambda request: proxy_request(
1659+
request, vcr_cassettes_directory, vcr_ci_mode, vcr_provider_map, vcr_ignore_headers
1660+
),
16571661
),
16581662
]
16591663
)
@@ -1961,6 +1965,18 @@ def main(args: Optional[List[str]] = None) -> None:
19611965
default=os.environ.get("VCR_CI_MODE", False),
19621966
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}",
19631967
)
1968+
parser.add_argument(
1969+
"--vcr-provider-map",
1970+
type=str,
1971+
default=os.environ.get("VCR_PROVIDER_MAP", ""),
1972+
help="Comma-separated list of provider=base_url tuples to map providers to paths. Used in addition to the default provider paths.",
1973+
)
1974+
parser.add_argument(
1975+
"--vcr-ignore-headers",
1976+
type=str,
1977+
default=os.environ.get("VCR_IGNORE_HEADERS", ""),
1978+
help="Comma-separated list of headers to ignore when recording VCR cassettes.",
1979+
)
19641980
parser.add_argument(
19651981
"--web-ui-port",
19661982
type=int,
@@ -2017,6 +2033,8 @@ def main(args: Optional[List[str]] = None) -> None:
20172033
snapshot_regex_placeholders=parsed_args.snapshot_regex_placeholders,
20182034
vcr_cassettes_directory=parsed_args.vcr_cassettes_directory,
20192035
vcr_ci_mode=parsed_args.vcr_ci_mode,
2036+
vcr_provider_map=parsed_args.vcr_provider_map,
2037+
vcr_ignore_headers=parsed_args.vcr_ignore_headers,
20202038
enable_web_ui=parsed_args.web_ui_port > 0,
20212039
)
20222040

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)