Skip to content

Commit 7a05937

Browse files
feat(agent): wire solution UA into aws_session and all direct boto3 sites
Session-level user_agent_extra on both the scoped (refreshable) and plain singleton sessions covers every tenant_client/tenant_resource caller; new platform_client() helper carries the UA + trace appender for the ambient-chain call sites (logs x5, secretsmanager x2, bedrock-agentcore x1) that bypass the session by design. configure_session() doubles the task id as the UA trace handle. The trace appender splices #{TRACE} onto the md/ segment (not the header end) because boto3 renders 'Botocore/x.y.z' after the session-level extra. Task 2 of PR #338 plan. Part of #319 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d126f1c commit 7a05937

8 files changed

Lines changed: 199 additions & 32 deletions

File tree

agent/src/aws_session.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,23 @@ def configure_session(user_id: str, repo: str, task_id: str) -> None:
9494
for key, value in (("user_id", user_id), ("repo", repo), ("task_id", task_id))
9595
if value
9696
}
97+
# The task id doubles as the UA trace handle (#319): every AWS call made
98+
# while this task runs carries md/...#agent#{task_id}.
99+
import ua
100+
101+
ua.set_trace(task_id or None)
97102

98103

99104
def reset_session_cache() -> None:
100-
"""Drop the cached session and tags. For tests that toggle config."""
105+
"""Drop the cached session, tags, and UA trace. For tests that toggle config."""
101106
global _session, _scoped, _tags
102107
with _lock:
103108
_session = None
104109
_scoped = None
105110
_tags = {}
111+
import ua
112+
113+
ua.set_trace(None)
106114

107115

108116
def _session_tags() -> list[dict[str, str]]:
@@ -128,6 +136,8 @@ def _build_scoped_session(role_arn: str) -> Any:
128136
)
129137
from botocore.session import get_session as get_botocore_session
130138

139+
import ua
140+
131141
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
132142
task_id = _tags.get("task_id", "")
133143
# Role session name must be <=64 chars and match [\w+=,.@-]. task_id is a
@@ -139,7 +149,8 @@ def _build_scoped_session(role_arn: str) -> Any:
139149
# A dedicated STS client built from the *ambient* (compute-role) chain.
140150
# This is the role-chaining caller; the assumed SessionRole credentials it
141151
# returns must NOT be used to build it, or refresh would recurse.
142-
sts_client = boto3.client("sts", region_name=region)
152+
sts_client = boto3.client("sts", region_name=region, config=ua.client_config())
153+
ua.register_trace_appender(sts_client.meta.events)
143154

144155
def _refresh() -> dict[str, str]:
145156
resp = sts_client.assume_role(
@@ -167,6 +178,12 @@ def _refresh() -> dict[str, str]:
167178
)
168179
if region:
169180
botocore_session.set_config_variable("region", region)
181+
# Outbound UA solution tracking (#319): session-level so every client and
182+
# resource derived from this singleton carries the static segments; the
183+
# per-request #{TRACE} appender mutates only the header, preserving the
184+
# session's connection pool across trace changes.
185+
botocore_session.user_agent_extra = ua.static_user_agent_extra()
186+
ua.register_trace_appender(botocore_session.get_component("event_emitter"))
170187
return boto3.Session(botocore_session=botocore_session)
171188

172189

@@ -209,10 +226,19 @@ def get_session() -> Any:
209226
) from exc
210227
else:
211228
# Scoping not requested (local/dev/tests, or pre-provisioning):
212-
# plain ambient session, behaviorally identical to pre-feature code.
213-
_session = boto3.Session(
214-
region_name=os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
215-
)
229+
# plain ambient session, behaviorally identical to pre-feature code
230+
# apart from the UA solution-tracking segments (#319).
231+
from botocore.session import get_session as get_botocore_session
232+
233+
import ua
234+
235+
botocore_session = get_botocore_session()
236+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
237+
if region:
238+
botocore_session.set_config_variable("region", region)
239+
botocore_session.user_agent_extra = ua.static_user_agent_extra()
240+
ua.register_trace_appender(botocore_session.get_component("event_emitter"))
241+
_session = boto3.Session(botocore_session=botocore_session)
216242
_scoped = False
217243
return _session
218244

@@ -235,9 +261,7 @@ def tenant_client(service_name: str, **kwargs: Any) -> Any:
235261
session = get_session()
236262
if is_scoped():
237263
return session.client(service_name, **kwargs)
238-
import boto3
239-
240-
return boto3.client(service_name, **kwargs)
264+
return platform_client(service_name, **kwargs)
241265

242266

243267
def tenant_resource(service_name: str, **kwargs: Any) -> Any:
@@ -247,4 +271,43 @@ def tenant_resource(service_name: str, **kwargs: Any) -> Any:
247271
return session.resource(service_name, **kwargs)
248272
import boto3
249273

250-
return boto3.resource(service_name, **kwargs)
274+
resource = boto3.resource(service_name, **_with_ua(kwargs))
275+
# Guarded like platform_client: test doubles may lack the meta chain.
276+
inner = getattr(getattr(resource, "meta", None), "client", None)
277+
events = getattr(getattr(inner, "meta", None), "events", None)
278+
if events is not None:
279+
import ua
280+
281+
ua.register_trace_appender(events)
282+
return resource
283+
284+
285+
def platform_client(service_name: str, **kwargs: Any) -> Any:
286+
"""boto3 client for platform (non-tenant) calls, with the ABCA UA (#319).
287+
288+
For call sites that intentionally use the ambient compute-role chain
289+
(CloudWatch Logs debug writers, Secrets Manager, AgentCore memory) rather
290+
than the tenant-scoped session. Same signature as ``boto3.client`` plus
291+
the solution-tracking User-Agent and per-request trace appender.
292+
"""
293+
import boto3
294+
295+
client = boto3.client(service_name, **_with_ua(kwargs))
296+
# Real clients always expose meta.events; test doubles (MagicMock, or the
297+
# bare fakes some suites install as a stub boto3 module) may not — the
298+
# appender is solution telemetry, never worth failing a call site over.
299+
events = getattr(getattr(client, "meta", None), "events", None)
300+
if events is not None:
301+
import ua
302+
303+
ua.register_trace_appender(events)
304+
return client
305+
306+
307+
def _with_ua(kwargs: dict[str, Any]) -> dict[str, Any]:
308+
"""Merge the ABCA UA config into a boto3 client/resource kwargs dict."""
309+
import ua
310+
311+
supplied = kwargs.get("config")
312+
config = supplied.merge(ua.client_config()) if supplied is not None else ua.client_config()
313+
return {**kwargs, "config": config}

agent/src/config.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ def resolve_github_token() -> str:
4040
return cached
4141
secret_arn = os.environ.get("GITHUB_TOKEN_SECRET_ARN")
4242
if secret_arn:
43-
import boto3
43+
from aws_session import platform_client
4444

4545
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
46-
client = boto3.client("secretsmanager", region_name=region)
46+
client = platform_client("secretsmanager", region_name=region)
4747
resp = client.get_secret_value(SecretId=secret_arn)
4848
token = resp["SecretString"]
4949
# Cache in env so downstream tools (git, gh CLI) work unchanged
@@ -101,13 +101,15 @@ def resolve_linear_api_token(channel_metadata: dict[str, str] | None = None) ->
101101
import json
102102
from datetime import datetime, timedelta
103103

104-
import boto3
104+
import boto3 # noqa: F401 — availability probe; client built via platform_client
105105
from botocore.exceptions import BotoCoreError, ClientError
106106
except ImportError as e:
107107
log("WARN", f"resolve_linear_api_token: boto3 unavailable ({e}); skipping")
108108
return ""
109109

110-
sm = boto3.client("secretsmanager", region_name=region)
110+
from aws_session import platform_client
111+
112+
sm = platform_client("secretsmanager", region_name=region)
111113

112114
def _fetch_token() -> dict | None:
113115
"""Fetch + parse the per-workspace OAuth secret.

agent/src/memory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ def _get_client():
3535
global _client
3636
if _client is not None:
3737
return _client
38-
import boto3
38+
from aws_session import platform_client
3939

4040
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
4141
if not region:
4242
raise ValueError("AWS_REGION or AWS_DEFAULT_REGION must be set for memory operations")
43-
_client = boto3.client("bedrock-agentcore", region_name=region)
43+
_client = platform_client("bedrock-agentcore", region_name=region)
4444
return _client
4545

4646

agent/src/server.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@ def _warn_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) -
151151
covers both writers.
152152
"""
153153
try:
154-
import boto3
154+
from aws_session import platform_client
155155

156156
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
157-
client = boto3.client("logs", region_name=region)
157+
client = platform_client("logs", region_name=region)
158158

159159
stream = f"server_warn/{task_id or 'server'}"
160160
with _ctx_for_debug.suppress(client.exceptions.ResourceAlreadyExistsException):
@@ -178,10 +178,10 @@ def _warn_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) -
178178
def _debug_cw_write_blocking(log_group: str, task_id: str | None, stamped: str) -> None:
179179
"""Blocking CloudWatch write — only called from a background thread."""
180180
try:
181-
import boto3
181+
from aws_session import platform_client
182182

183183
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
184-
client = boto3.client("logs", region_name=region)
184+
client = platform_client("logs", region_name=region)
185185

186186
stream = f"server_debug/{task_id or 'server'}"
187187
with _ctx_for_debug.suppress(client.exceptions.ResourceAlreadyExistsException):

agent/src/shell.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ def _log_error_cw_blocking(log_group: str, task_id: str | None, stamped: str) ->
6161
fire on the absence of the expected stream, not on this helper).
6262
"""
6363
try:
64-
import boto3
64+
from aws_session import platform_client
6565

6666
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
67-
client = boto3.client("logs", region_name=region)
67+
client = platform_client("logs", region_name=region)
6868
stream = f"agent_error/{task_id or 'unknown'}"
6969
with contextlib.suppress(client.exceptions.ResourceAlreadyExistsException):
7070
client.create_log_stream(logGroupName=log_group, logStreamName=stream)

agent/src/telemetry.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ def _emit_metrics_to_cloudwatch(json_payload: dict) -> None:
5656
try:
5757
import contextlib
5858

59-
import boto3
59+
from aws_session import platform_client
6060

6161
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
62-
client = boto3.client("logs", region_name=region)
62+
client = platform_client("logs", region_name=region)
6363

6464
task_id = json_payload.get("task_id", "unknown")
6565
log_stream = f"metrics/{task_id}"
@@ -164,10 +164,10 @@ def _ensure_client(self):
164164

165165
import contextlib
166166

167-
import boto3
167+
from aws_session import platform_client
168168

169169
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
170-
self._client = boto3.client("logs", region_name=region)
170+
self._client = platform_client("logs", region_name=region)
171171

172172
log_stream = f"trajectory/{self._task_id}"
173173
with contextlib.suppress(self._client.exceptions.ResourceAlreadyExistsException):

agent/src/ua.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
_trace_lock = threading.Lock()
6060
_trace: str | None = None
6161

62+
# The static md/ segment the per-request appender extends. Computed once —
63+
# COMPONENT never varies at runtime.
64+
_MD_SEGMENT = f"md/{SOLUTION_ID}#{COMPONENT}"
65+
6266

6367
def sanitize_ua_value(raw: str) -> str:
6468
"""Replace every non-UA-token char (incl. non-ASCII) with ``-``."""
@@ -105,10 +109,14 @@ def register_trace_appender(events: Any) -> None:
105109
106110
``events`` is a botocore event emitter — either ``client.meta.events``
107111
(single client) or a botocore session's emitter (propagates to every
108-
client *and resource* derived from it). Registered on ``before-send`` so
109-
it runs after botocore renders the header (``user_agent_extra`` is the
110-
final component, so the suffix lands exactly on our ``md/`` segment) and
111-
mutates only the header — the connection pool is untouched.
112+
client *and resource* derived from it). Registered on ``before-send``,
113+
after botocore has rendered the header; only the header string changes —
114+
the connection pool is untouched.
115+
116+
The trace is spliced onto the ``md/`` segment rather than appended to the
117+
header's end: for *resources*, boto3 sets a client-level
118+
``user_agent_extra='Resource'`` marker that renders after the
119+
session-level extra, so our segment is not always last.
112120
"""
113121

114122
def _append_trace(request: Any, **_kwargs: Any) -> None:
@@ -119,9 +127,13 @@ def _append_trace(request: Any, **_kwargs: Any) -> None:
119127
if current is None:
120128
return
121129
# Headers may surface as bytes depending on the transport path.
122-
if isinstance(current, bytes):
130+
was_bytes = isinstance(current, bytes)
131+
if was_bytes:
123132
current = current.decode("ascii", errors="replace")
124-
request.headers["User-Agent"] = f"{current}#{trace}"
133+
if _MD_SEGMENT not in current:
134+
return
135+
updated = current.replace(_MD_SEGMENT, f"{_MD_SEGMENT}#{trace}", 1)
136+
request.headers["User-Agent"] = updated.encode("ascii") if was_bytes else updated
125137

126138
events.register("before-send.*", _append_trace, unique_id="abca-ua-trace")
127139

agent/tests/test_aws_session.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,93 @@ def test_overlong_value_truncated_to_256(self, monkeypatch):
298298
assert len(tags["repo"]) == _MAX_TAG_VALUE_LEN == 256
299299
# Untruncated values are passed through unchanged.
300300
assert tags["user_id"] == "u-1"
301+
302+
303+
# ---------------------------------------------------------------------------
304+
# Outbound UA solution tracking (#319)
305+
# ---------------------------------------------------------------------------
306+
307+
308+
class TestUserAgentWiring:
309+
def test_configure_session_sets_ua_trace(self, monkeypatch):
310+
import ua
311+
312+
configure_session(user_id="u-1", repo="owner/repo", task_id="01KTVYTASK")
313+
assert ua.get_trace() == "01KTVYTASK"
314+
315+
def test_reset_session_cache_clears_trace(self, monkeypatch):
316+
import ua
317+
318+
configure_session(user_id="u-1", repo="owner/repo", task_id="t-abc")
319+
reset_session_cache()
320+
assert ua.get_trace() is None
321+
322+
def test_plain_session_emits_solution_ua(self, monkeypatch):
323+
"""End-to-end wire capture through the real unscoped session path:
324+
the singleton session must bake the static segments and append the
325+
per-request #{TRACE} without rebuilding the client."""
326+
from botocore.awsrequest import AWSResponse
327+
328+
import ua
329+
330+
monkeypatch.setenv("AWS_REGION", "us-east-1")
331+
monkeypatch.setenv(ua.STACK_NAME_ENV, "backgroundagent-dev")
332+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
333+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
334+
335+
session = get_session()
336+
client = session.client("sts")
337+
338+
captured: list[str] = []
339+
340+
def _short_circuit(request, **kwargs):
341+
value = request.headers["User-Agent"]
342+
captured.append(value.decode("ascii") if isinstance(value, bytes) else value)
343+
body = (
344+
b"<GetCallerIdentityResponse "
345+
b'xmlns="https://sts.amazonaws.com/doc/2011-06-15/">'
346+
b"<GetCallerIdentityResult><Arn>arn:aws:iam::123456789012:user/t</Arn>"
347+
b"<UserId>AIDA</UserId><Account>123456789012</Account>"
348+
b"</GetCallerIdentityResult></GetCallerIdentityResponse>"
349+
)
350+
351+
class _Raw:
352+
def __init__(self, data):
353+
self._data = data
354+
355+
def read(self, *a, **k):
356+
data, self._data = self._data, b""
357+
return data
358+
359+
def stream(self, *a, **k):
360+
yield self.read()
361+
362+
return AWSResponse(url=request.url, status_code=200, headers={}, raw=_Raw(body))
363+
364+
client.meta.events.register_last("before-send.sts.GetCallerIdentity", _short_circuit)
365+
366+
ua.set_trace("trace-one")
367+
client.get_caller_identity()
368+
ua.set_trace("trace-two")
369+
client.get_caller_identity()
370+
371+
assert f"app/{ua.SOLUTION_ID}/backgroundagent-dev" in captured[0]
372+
# boto3 appends "Botocore/x.y.z" AFTER the session-level extra, so the
373+
# segment is mid-header — which is exactly why the appender splices
374+
# onto the md/ segment instead of appending to the header's end.
375+
assert f"md/{ua.SOLUTION_ID}#agent#trace-one " in captured[0] + " "
376+
assert f"md/{ua.SOLUTION_ID}#agent#trace-two " in captured[1] + " "
377+
378+
def test_tenant_resource_unscoped_carries_ua(self, monkeypatch):
379+
"""The unscoped resource path bypasses the session; it must still
380+
carry the static UA via the merged client config."""
381+
import ua
382+
383+
monkeypatch.setenv("AWS_REGION", "us-east-1")
384+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
385+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
386+
from aws_session import tenant_resource
387+
388+
resource = tenant_resource("dynamodb", region_name="us-east-1")
389+
extra = resource.meta.client.meta.config.user_agent_extra
390+
assert f"md/{ua.SOLUTION_ID}#agent" in extra

0 commit comments

Comments
 (0)