Skip to content

Commit 0a01196

Browse files
Avery MilandinCopilot
andcommitted
adk-telemetry: address PR #237 review comments (round 2)
Reviewer feedback and design nits from daeunJe0ng's copilot-review: - Fix A (should-fix #1: auth-path latency): the /organization and /me silent lookups on the interactive auth hot path now use a bounded 3s socket timeout via plain requests.get (not the module-level _SESSION, whose 3x 429/5xx retry with backoff_factor=1 would add ~7s per lookup on a throttled tenant). Worst-case authenticate() overhead is now bounded to ~6s instead of ~15-40s in the admin-consent-gap case. tenant_class is driven by the tid claim already in the token, so a timeout here only degrades tenant_name to blank (recoverable on the next FlightCheck run) — never a misclassification. - Fix B (inline nit on common_dimensions): sanitize the explicit tenant_id kwarg too. Every tenant_id source (explicit, in-memory identity, disk cache) now flows through _sanitize_tenant_id at the single choke point in common_dimensions, closing the last back-door where a caller-supplied non-GUID could leak into the customer bucket. - Fix C (inline nit on cache_tenant_name): atomic write via tempfile+os.replace, matching cache_tenant_id. A concurrent reader (subprocess spawned from a SKILL.md step) can no longer see a truncated file and fall back to a blank tenant_name for the whole session. - Fix D (inline nit on get_cached_tenant_id): the legacy raw-string compatibility shim now validates against _GUID_RE (case-insensitive) before returning, matching the docstring's guarantee. Any garbage / torn / hand-edited pre-versioned file yields "" instead of leaking a bad value onto real events. - New regression tests: kwarg-sanitization in common_dimensions, legacy raw-string rejection in get_cached_tenant_id. - Test-side: tests/flightcheck/test_graph_client.py now patches requests.get instead of graph_client._SESSION.get to match the no-retry hot-path pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70ddf006-7f8d-48e7-9afa-3fbae73b3864
1 parent b2c1c78 commit 0a01196

5 files changed

Lines changed: 124 additions & 34 deletions

File tree

solutions/ess-maker-skills/scripts/adk_telemetry.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,19 @@ def common_dimensions(
437437
tenant_name: str | None = None,
438438
) -> dict[str, Any]:
439439
"""Build the dimensions present on every event (spec Common Dimensions)."""
440-
tid = _IDENTITY["tenant_id"] if tenant_id is None else tenant_id
441-
# Fall back to the disk-persisted tenant_id when this process never
442-
# called set_identity (subprocess launched by emit_capability.py from a
443-
# SKILL.md step). Without this, capability events emitted by the shim
444-
# carry an empty tenant_id, classify as "unknown" via classify_tenant(""),
445-
# and never appear on the customer-filtered External dashboard even
446-
# though the maker's earlier auth flow knew the tenant. Re-run the cache
447-
# value through the same GUID sanitizer used in ``set_identity`` so a
448-
# torn / legacy / hand-edited file can never bypass the check.
449-
if not tid and tenant_id is None:
450-
tid = _sanitize_tenant_id(_fc.get_cached_tenant_id())
440+
# Single sanitization choke point: EVERY tenant_id source (explicit kwarg,
441+
# in-memory identity, disk cache) flows through ``_sanitize_tenant_id``
442+
# here, so a non-GUID value from *any* source normalizes to "" and the
443+
# event lands in the "unknown" bucket instead of leaking into "customer".
444+
# This matches the guarantee ``set_identity`` gives on ingress and closes
445+
# the last back-door where an explicit ``common_dimensions(tenant_id=...)``
446+
# kwarg (used by the ``emit_*`` helpers) could bypass the check.
447+
if tenant_id is None:
448+
tid = _IDENTITY["tenant_id"] or _sanitize_tenant_id(
449+
_fc.get_cached_tenant_id()
450+
)
451+
else:
452+
tid = _sanitize_tenant_id(tenant_id)
451453
tname = _IDENTITY["tenant_name"] if tenant_name is None else tenant_name
452454
# Fall back to the org display name a prior Graph-capable run (FlightCheck)
453455
# cached for THIS tenant, so pure-ADK events (session/build/deploy/

solutions/ess-maker-skills/scripts/flightcheck/graph_client.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@
5656
"https://graph.microsoft.com/ExternalConnection.Read.All",
5757
]
5858

59+
# Wall-clock ceiling for the two silent lookups called from the interactive
60+
# auth hot path (``resolve_tenant_display_name_silent`` → /organization then
61+
# /me). Kept small — the sign-in critical path must not stall on a
62+
# best-effort telemetry-only label. ``tenant_class`` is driven by the ``tid``
63+
# claim already extracted from the token, so a timeout here only degrades
64+
# ``tenant_name`` to blank (recoverable on the next FlightCheck run), never
65+
# to a misclassification. Total worst case per authenticate() is
66+
# 2 × _SILENT_LOOKUP_TIMEOUT.
67+
_SILENT_LOOKUP_TIMEOUT = 3.0
68+
5969
# Module-level requests Session with bounded retry-with-backoff for 429/5xx.
6070
# Mirrors the auth.py pattern - Graph throttles on /users and /servicePrincipals
6171
# in larger tenants, and one transient 503 mid-FlightCheck would otherwise blow
@@ -197,6 +207,13 @@ def _try_organization_lookup(app, account) -> tuple[str, bool]:
197207
Graph just refused this specific query — so the caller can meaningfully
198208
fall through to ``/me?$select=companyName``. When the transport is broken
199209
the ``/me`` call would fail identically, so we tell the caller to skip it.
210+
211+
Called on the interactive auth hot path so the socket timeout is short
212+
(``_SILENT_LOOKUP_TIMEOUT``) and a plain ``requests.get`` is used instead
213+
of the module-level ``_SESSION`` — the session's 3x 429/5xx retry with
214+
``backoff_factor=1`` would add up to ~7s of extra wait per lookup on a
215+
throttled tenant, which is unacceptable on the sign-in critical path
216+
for a best-effort telemetry label.
200217
"""
201218
try:
202219
result = app.acquire_token_silent(_ORG_READ_SCOPE, account=account)
@@ -207,13 +224,13 @@ def _try_organization_lookup(app, account) -> tuple[str, bool]:
207224
except Exception: # noqa: BLE001
208225
return "", True
209226
try:
210-
resp = _SESSION.get(
227+
resp = requests.get(
211228
f"{GRAPH_BASE}/organization",
212229
headers={
213230
"Authorization": f"Bearer {result['access_token']}",
214231
"Accept": "application/json",
215232
},
216-
timeout=15,
233+
timeout=_SILENT_LOOKUP_TIMEOUT,
217234
)
218235
except (requests.ConnectionError, requests.Timeout):
219236
return "", False
@@ -237,18 +254,26 @@ def _try_me_company_name_lookup(app, account) -> str:
237254
the same classification we already have for ``tenant_name`` — and never
238255
EUPI: we only read a single organizational attribute, not the user's
239256
name / UPN / id.
257+
258+
Runs on the interactive auth hot path in the common admin-consent-gap
259+
case where ``/organization`` returned nothing, so the same bounded
260+
``_SILENT_LOOKUP_TIMEOUT`` + no-retry policy as the organization lookup
261+
applies here. ``tenant_class`` is driven by the ``tid`` claim already in
262+
the token, not by this call, so failing fast never hurts dashboard
263+
correctness — it only degrades ``tenant_name`` to blank until the next
264+
(interactive) FlightCheck run resolves and caches it.
240265
"""
241266
try:
242267
result = app.acquire_token_silent(_USER_READ_SCOPE, account=account)
243268
if not result or "access_token" not in result:
244269
return ""
245-
resp = _SESSION.get(
270+
resp = requests.get(
246271
f"{GRAPH_BASE}/me?$select=companyName",
247272
headers={
248273
"Authorization": f"Bearer {result['access_token']}",
249274
"Accept": "application/json",
250275
},
251-
timeout=15,
276+
timeout=_SILENT_LOOKUP_TIMEOUT,
252277
)
253278
if resp.status_code != 200:
254279
return ""

solutions/ess-maker-skills/scripts/flightcheck/telemetry.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import json
6767
import os
6868
import platform
69+
import re
6970
import tempfile
7071
import uuid
7172
from datetime import datetime, timezone
@@ -122,6 +123,17 @@
122123
TENANT_CLASS_CUSTOMER = "customer"
123124
TENANT_CLASS_UNKNOWN = "unknown"
124125

126+
# Canonical Entra tenant GUID (8-4-4-4-12 lowercase hex). Used by
127+
# ``get_cached_tenant_id`` to validate the legacy raw-string cache format
128+
# so a torn / hand-edited / garbage ``.tenant_id`` file can never leak onto
129+
# real events. Duplicated from ``adk_telemetry._GUID_RE`` (this module
130+
# cannot import from adk_telemetry — the dependency direction goes the
131+
# other way); the two regexes are kept in lock-step by a regression test
132+
# in ``test_adk_telemetry.py``.
133+
_GUID_RE = re.compile(
134+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
135+
)
136+
125137

126138
def _internal_tenant_ids() -> frozenset[str]:
127139
"""Allow-list of tenant GUIDs treated as internal Microsoft tenancy.
@@ -299,15 +311,26 @@ def cache_tenant_name(
299311
pass
300312
try:
301313
os.makedirs(local_dir, exist_ok=True)
302-
with open(path, "w", encoding="utf-8") as f:
303-
json.dump(
304-
{
305-
"tenant_id": tenant_id,
306-
"tenant_name": tenant_name,
307-
"source": source,
308-
},
309-
f,
310-
)
314+
# Atomic write via tempfile + os.replace, matching cache_tenant_id.
315+
# A concurrent reader (a sibling emit_capability.py subprocess spawned
316+
# from a SKILL.md step) must never see a truncated file — otherwise
317+
# the reader would fall back to a blank tenant_name for the whole
318+
# session, undoing the very cache we're populating.
319+
payload = {
320+
"tenant_id": tenant_id,
321+
"tenant_name": tenant_name,
322+
"source": source,
323+
}
324+
fd, tmp = tempfile.mkstemp(prefix=".tenant_name.", dir=local_dir)
325+
try:
326+
with os.fdopen(fd, "w", encoding="utf-8") as f:
327+
json.dump(payload, f)
328+
os.replace(tmp, path)
329+
except OSError:
330+
try:
331+
os.remove(tmp)
332+
except OSError:
333+
pass
311334
except OSError:
312335
pass
313336

@@ -371,9 +394,11 @@ def get_cached_tenant_id(local_dir: str = ".local") -> str:
371394
real events.
372395
373396
A very small compatibility shim recognizes the pre-versioning raw-string
374-
format (a single line whose contents look like a GUID) so a maker who
375-
upgrades mid-session doesn't lose their cached tenant. Any other content
376-
(truncated GUID, JSON at a future schema version, garbage) is discarded.
397+
format (a single line whose contents look like a canonical GUID) so a
398+
maker who upgrades mid-session doesn't lose their cached tenant. The
399+
raw string is validated against ``_GUID_RE`` (case-insensitive) before
400+
being returned; any other content (truncated GUID, non-hex, garbage,
401+
JSON at a future schema version) is discarded.
377402
"""
378403
path = os.path.join(local_dir, _TENANT_ID_FILE)
379404
try:
@@ -391,8 +416,13 @@ def get_cached_tenant_id(local_dir: str = ".local") -> str:
391416
if not isinstance(obj, dict) or obj.get("version") != 1:
392417
return ""
393418
return str(obj.get("tenant_id", "")).strip()
394-
# Legacy raw-string format from pre-versioned ADK builds.
395-
return raw
419+
# Legacy raw-string format from pre-versioned ADK builds: only trust the
420+
# value when it matches the canonical GUID shape. Otherwise return ""
421+
# and let the caller's fallback path (or classify_tenant) treat it as
422+
# unknown — never leak a torn / hand-edited / garbage cache onto real
423+
# events. Matches the docstring guarantee above.
424+
v = raw.lower()
425+
return v if _GUID_RE.match(v) else ""
396426

397427

398428
def get_cached_tenant_name(tenant_id: str, local_dir: str = ".local") -> str:

tests/flightcheck/test_graph_client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def test_success_returns_display_name():
7171
)
7272
resp = _resp(200, {"value": [{"displayName": "Contoso Ltd"}]})
7373
with patch.object(graph_client.msal, "PublicClientApplication", return_value=app), \
74-
patch.object(graph_client._SESSION, "get", return_value=resp) as mock_get:
74+
patch.object(graph_client.requests, "get", return_value=resp) as mock_get:
7575
assert (
7676
graph_client.resolve_tenant_display_name_silent("tenant-Z") == "Contoso Ltd"
7777
)
@@ -88,7 +88,7 @@ def test_non_200_response_returns_empty():
8888
accounts=[SimpleNamespace()], silent_result={"access_token": "tok"}
8989
)
9090
with patch.object(graph_client.msal, "PublicClientApplication", return_value=app), \
91-
patch.object(graph_client._SESSION, "get", return_value=_resp(403)):
91+
patch.object(graph_client.requests, "get", return_value=_resp(403)):
9292
assert graph_client.resolve_tenant_display_name_silent("tenant-Z") == ""
9393

9494

@@ -97,7 +97,7 @@ def test_empty_org_list_returns_empty():
9797
accounts=[SimpleNamespace()], silent_result={"access_token": "tok"}
9898
)
9999
with patch.object(graph_client.msal, "PublicClientApplication", return_value=app), \
100-
patch.object(graph_client._SESSION, "get", return_value=_resp(200, {"value": []})):
100+
patch.object(graph_client.requests, "get", return_value=_resp(200, {"value": []})):
101101
assert graph_client.resolve_tenant_display_name_silent("tenant-Z") == ""
102102

103103

@@ -138,7 +138,7 @@ def fake_get(url, headers, timeout):
138138
return _resp(200, {"companyName": " Fabrikam Corp "})
139139

140140
with patch.object(graph_client.msal, "PublicClientApplication", return_value=app), \
141-
patch.object(graph_client._SESSION, "get", side_effect=fake_get):
141+
patch.object(graph_client.requests, "get", side_effect=fake_get):
142142
# Whitespace on companyName is stripped so downstream label matches
143143
# what /organization would return.
144144
assert (
@@ -184,5 +184,5 @@ def silent(scopes, account):
184184

185185
app.acquire_token_silent.side_effect = silent
186186
with patch.object(graph_client.msal, "PublicClientApplication", return_value=app), \
187-
patch.object(graph_client._SESSION, "get", return_value=_resp(200, {})):
187+
patch.object(graph_client.requests, "get", return_value=_resp(200, {})):
188188
assert graph_client.resolve_tenant_display_name_silent("tenant-Z") == ""

tests/test_adk_telemetry.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,39 @@ def test_classify_tenant_env_allowlist_extends(monkeypatch):
296296
assert _fc.classify_tenant("11111111-1111-1111-1111-111111111111") == "customer"
297297

298298

299+
def test_get_cached_tenant_id_rejects_non_guid_legacy_string(tmp_path):
300+
# The legacy raw-string compatibility shim in get_cached_tenant_id must
301+
# validate against _GUID_RE before returning — otherwise a torn /
302+
# hand-edited / garbage .tenant_id file would ride onto real events.
303+
d = tmp_path / "state"
304+
d.mkdir()
305+
(d / _fc._TENANT_ID_FILE).write_text("tenant-id", encoding="utf-8")
306+
assert _fc.get_cached_tenant_id(local_dir=str(d)) == ""
307+
# A canonical raw-string GUID (older ADK builds) is still honored.
308+
(d / _fc._TENANT_ID_FILE).write_text(
309+
"ABCDEF01-2345-6789-abcd-ef0123456789", encoding="utf-8"
310+
)
311+
assert (
312+
_fc.get_cached_tenant_id(local_dir=str(d))
313+
== "abcdef01-2345-6789-abcd-ef0123456789"
314+
)
315+
316+
317+
def test_common_dimensions_sanitizes_explicit_tenant_id_kwarg(monkeypatch):
318+
# Single choke point: EVERY tenant_id source (explicit kwarg included)
319+
# is sanitized in common_dimensions, so an emit_* helper that forwards
320+
# a caller-supplied non-GUID never lands in the customer bucket.
321+
monkeypatch.setattr(_fc, "get_instance_id", lambda: "install-guid-1")
322+
dims = adk.common_dimensions(adk.SURFACE_CLI, tenant_id="tenant-id")
323+
assert dims["tenant_id"] == ""
324+
assert dims["tenant_class"] == _fc.TENANT_CLASS_UNKNOWN
325+
# A well-formed GUID is preserved (and lowercased).
326+
dims = adk.common_dimensions(
327+
adk.SURFACE_CLI, tenant_id="ABCDEF01-2345-6789-abcd-ef0123456789"
328+
)
329+
assert dims["tenant_id"] == "abcdef01-2345-6789-abcd-ef0123456789"
330+
331+
299332
def test_tenant_class_flows_into_dimensions(monkeypatch):
300333
monkeypatch.setattr(_fc, "get_instance_id", lambda: "install-guid-1")
301334
adk.set_identity(tenant_id=_fc.MICROSOFT_CORP_TENANT_ID, instance_id="inst-9")

0 commit comments

Comments
 (0)