Skip to content

Commit 0e38180

Browse files
tpellissierclaude
andcommitted
Add internal metadata capture for telemetry (Phase 1.2)
Adds infrastructure for capturing request/response metadata to enable the fluent .with_detail_response() API pattern. Changes to _http.py: - Add _HttpTiming dataclass for request timing info (elapsed_ms, attempts) - Add _request_with_timing() method that returns (response, timing) Changes to _odata.py: - Import RequestMetadata from core.results - Add _request_with_metadata() that returns (response, RequestMetadata) - Add _create_with_metadata() returning (record_id, RequestMetadata) - Add _create_multiple_with_metadata() returning (ids, metadata, batch_info) - Add _update_with_metadata() returning (None, RequestMetadata) - Add _delete_with_metadata() returning (None, RequestMetadata) - Add _get_with_metadata() returning (record, RequestMetadata) These internal methods capture: - client_request_id from request headers - correlation_id from request headers - service_request_id from response headers - http_status_code from response - timing_ms from HTTP timing The original methods remain unchanged for backward compatibility. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2ebfefc commit 0e38180

2 files changed

Lines changed: 338 additions & 2 deletions

File tree

src/PowerPlatform/Dataverse/core/_http.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,26 @@
1212
from __future__ import annotations
1313

1414
import time
15-
from typing import Any, Optional
15+
from dataclasses import dataclass
16+
from typing import Any, Optional, Tuple
1617

1718
import requests
1819

1920

21+
@dataclass
22+
class _HttpTiming:
23+
"""Timing information for an HTTP request.
24+
25+
:param elapsed_ms: Total request duration in milliseconds.
26+
:type elapsed_ms: :class:`float`
27+
:param attempts: Number of attempts made (1 = no retries).
28+
:type attempts: :class:`int`
29+
"""
30+
31+
elapsed_ms: float
32+
attempts: int = 1
33+
34+
2035
class _HttpClient:
2136
"""
2237
HTTP client with configurable retry logic and timeout handling.
@@ -77,3 +92,50 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
7792
delay = self.base_delay * (2**attempt)
7893
time.sleep(delay)
7994
continue
95+
96+
def _request_with_timing(
97+
self, method: str, url: str, **kwargs: Any
98+
) -> Tuple[requests.Response, _HttpTiming]:
99+
"""
100+
Execute an HTTP request and return response with timing information.
101+
102+
Same behavior as :meth:`_request` but additionally returns timing data
103+
for telemetry purposes.
104+
105+
:param method: HTTP method (GET, POST, PUT, DELETE, etc.).
106+
:type method: :class:`str`
107+
:param url: Target URL for the request.
108+
:type url: :class:`str`
109+
:param kwargs: Additional arguments passed to ``requests.request()``.
110+
:return: Tuple of (HTTP response, timing information).
111+
:rtype: :class:`tuple` of (:class:`requests.Response`, :class:`_HttpTiming`)
112+
:raises requests.exceptions.RequestException: If all retry attempts fail.
113+
"""
114+
# If no timeout is provided, use the user-specified default timeout if set;
115+
# otherwise, apply per-method defaults (120s for POST/DELETE, 10s for others).
116+
if "timeout" not in kwargs:
117+
if self.default_timeout is not None:
118+
kwargs["timeout"] = self.default_timeout
119+
else:
120+
m = (method or "").lower()
121+
kwargs["timeout"] = 120 if m in ("post", "delete") else 10
122+
123+
start_time = time.time()
124+
attempts = 0
125+
126+
# Small backoff retry on network errors only
127+
for attempt in range(self.max_attempts):
128+
attempts = attempt + 1
129+
try:
130+
response = requests.request(method, url, **kwargs)
131+
elapsed_ms = (time.time() - start_time) * 1000
132+
return response, _HttpTiming(elapsed_ms=elapsed_ms, attempts=attempts)
133+
except requests.exceptions.RequestException:
134+
if attempt == self.max_attempts - 1:
135+
raise
136+
delay = self.base_delay * (2**attempt)
137+
time.sleep(delay)
138+
continue
139+
140+
# This should not be reached, but include for type safety
141+
raise RuntimeError("Unexpected state in _request_with_timing")

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 275 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from contextlib import contextmanager
1919
from contextvars import ContextVar
2020

21-
from ..core._http import _HttpClient
21+
from ..core._http import _HttpClient, _HttpTiming
2222
from ._upload import _ODataFileUpload
2323
from ..core.errors import *
2424
from ..core._error_codes import (
@@ -33,6 +33,7 @@
3333
METADATA_COLUMN_NOT_FOUND,
3434
VALIDATION_UNSUPPORTED_CACHE_KIND,
3535
)
36+
from ..core.results import RequestMetadata
3637

3738
from ..__version__ import __version__ as _SDK_VERSION
3839

@@ -251,6 +252,279 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL
251252
is_transient=is_transient,
252253
)
253254

255+
def _request_with_metadata(
256+
self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAULT_EXPECTED_STATUSES, **kwargs
257+
) -> tuple[Any, RequestMetadata]:
258+
"""Execute HTTP request and return response with RequestMetadata.
259+
260+
This method is used internally to capture telemetry for the fluent
261+
``.with_detail_response()`` API pattern.
262+
263+
:param method: HTTP method (GET, POST, PUT, DELETE, etc.).
264+
:type method: ``str``
265+
:param url: Target URL for the request.
266+
:type url: ``str``
267+
:param expected: Tuple of acceptable HTTP status codes.
268+
:type expected: ``tuple[int, ...]``
269+
:param kwargs: Additional arguments passed to the underlying HTTP request.
270+
:return: Tuple of (response, RequestMetadata).
271+
:rtype: ``tuple[Any, RequestMetadata]``
272+
:raises HttpError: If the response status is not in expected statuses.
273+
"""
274+
request_context = _RequestContext.build(
275+
method,
276+
url,
277+
expected=expected,
278+
merge_headers=self._merge_headers,
279+
**kwargs,
280+
)
281+
282+
# Use timing-aware request
283+
r, timing = self._http._request_with_timing(
284+
request_context.method, request_context.url, **request_context.kwargs
285+
)
286+
287+
# Extract service request ID from response headers
288+
response_headers = getattr(r, "headers", {}) or {}
289+
service_request_id = (
290+
response_headers.get("x-ms-service-request-id")
291+
or response_headers.get("req_id")
292+
or response_headers.get("x-ms-request-id")
293+
)
294+
295+
# Build metadata
296+
metadata = RequestMetadata(
297+
client_request_id=request_context.headers.get("x-ms-client-request-id"),
298+
correlation_id=request_context.headers.get("x-ms-correlation-id"),
299+
service_request_id=service_request_id,
300+
http_status_code=r.status_code,
301+
timing_ms=timing.elapsed_ms,
302+
)
303+
304+
if r.status_code in request_context.expected:
305+
return r, metadata
306+
307+
# Error handling - same logic as _request but we have metadata available
308+
body_excerpt = (getattr(r, "text", "") or "")[:200]
309+
svc_code = None
310+
msg = f"HTTP {r.status_code}"
311+
try:
312+
data = r.json() if getattr(r, "text", None) else {}
313+
if isinstance(data, dict):
314+
inner = data.get("error")
315+
if isinstance(inner, dict):
316+
svc_code = inner.get("code")
317+
imsg = inner.get("message")
318+
if isinstance(imsg, str) and imsg.strip():
319+
msg = imsg.strip()
320+
else:
321+
imsg2 = data.get("message")
322+
if isinstance(imsg2, str) and imsg2.strip():
323+
msg = imsg2.strip()
324+
except Exception:
325+
pass
326+
sc = r.status_code
327+
subcode = _http_subcode(sc)
328+
traceparent = response_headers.get("traceparent")
329+
ra = response_headers.get("Retry-After")
330+
retry_after = None
331+
if ra:
332+
try:
333+
retry_after = int(ra)
334+
except Exception:
335+
retry_after = None
336+
is_transient = _is_transient_status(sc)
337+
raise HttpError(
338+
msg,
339+
status_code=sc,
340+
subcode=subcode,
341+
service_error_code=svc_code,
342+
correlation_id=metadata.correlation_id,
343+
client_request_id=metadata.client_request_id,
344+
service_request_id=metadata.service_request_id,
345+
traceparent=traceparent,
346+
body_excerpt=body_excerpt,
347+
retry_after=retry_after,
348+
is_transient=is_transient,
349+
)
350+
351+
# --- CRUD Internal functions with metadata ---
352+
def _create_with_metadata(
353+
self, entity_set: str, table_schema_name: str, record: Dict[str, Any]
354+
) -> tuple[str, RequestMetadata]:
355+
"""Create a single record and return its GUID with metadata.
356+
357+
Same as :meth:`_create` but returns a tuple of (record_id, RequestMetadata)
358+
for the fluent API pattern.
359+
360+
:param entity_set: Resolved entity set (plural) name.
361+
:type entity_set: ``str``
362+
:param table_schema_name: Schema name of the table.
363+
:type table_schema_name: ``str``
364+
:param record: Attribute payload mapped by logical column names.
365+
:type record: ``dict[str, Any]``
366+
:return: Tuple of (created record GUID, request metadata).
367+
:rtype: ``tuple[str, RequestMetadata]``
368+
"""
369+
record = self._lowercase_keys(record)
370+
record = self._convert_labels_to_ints(table_schema_name, record)
371+
url = f"{self.api}/{entity_set}"
372+
r, metadata = self._request_with_metadata("post", url, json=record)
373+
374+
ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID")
375+
if ent_loc:
376+
m = _GUID_RE.search(ent_loc)
377+
if m:
378+
return m.group(0), metadata
379+
loc = r.headers.get("Location")
380+
if loc:
381+
m = _GUID_RE.search(loc)
382+
if m:
383+
return m.group(0), metadata
384+
header_keys = ", ".join(sorted(r.headers.keys()))
385+
raise RuntimeError(
386+
f"Create response missing GUID in OData-EntityId/Location headers (status={getattr(r,'status_code', '?')}). Headers: {header_keys}"
387+
)
388+
389+
def _create_multiple_with_metadata(
390+
self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]]
391+
) -> tuple[List[str], RequestMetadata, Dict[str, Any]]:
392+
"""Create multiple records and return GUIDs with metadata.
393+
394+
Same as :meth:`_create_multiple` but returns a tuple of
395+
(record_ids, RequestMetadata, batch_info) for the fluent API pattern.
396+
397+
:param entity_set: Resolved entity set (plural) name.
398+
:type entity_set: ``str``
399+
:param table_schema_name: Schema name of the table.
400+
:type table_schema_name: ``str``
401+
:param records: Payload dictionaries mapped by column schema names.
402+
:type records: ``list[dict[str, Any]]``
403+
:return: Tuple of (list of created GUIDs, request metadata, batch info).
404+
:rtype: ``tuple[list[str], RequestMetadata, dict[str, Any]]``
405+
"""
406+
if not all(isinstance(r, dict) for r in records):
407+
raise TypeError("All items for multi-create must be dicts")
408+
need_logical = any("@odata.type" not in r for r in records)
409+
logical_name = table_schema_name.lower()
410+
enriched: List[Dict[str, Any]] = []
411+
for r in records:
412+
r = self._lowercase_keys(r)
413+
r = self._convert_labels_to_ints(table_schema_name, r)
414+
if "@odata.type" in r or not need_logical:
415+
enriched.append(r)
416+
else:
417+
nr = r.copy()
418+
nr["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}"
419+
enriched.append(nr)
420+
payload = {"Targets": enriched}
421+
url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.CreateMultiple"
422+
r, metadata = self._request_with_metadata("post", url, json=payload)
423+
424+
try:
425+
body = r.json() if r.text else {}
426+
except ValueError:
427+
body = {}
428+
if not isinstance(body, dict):
429+
body = {}
430+
431+
ids: List[str] = []
432+
raw_ids = body.get("Ids")
433+
if isinstance(raw_ids, list):
434+
ids = [i for i in raw_ids if isinstance(i, str)]
435+
else:
436+
value = body.get("value")
437+
if isinstance(value, list):
438+
for item in value:
439+
if isinstance(item, dict):
440+
for k, v in item.items():
441+
if isinstance(k, str) and k.lower().endswith("id") and isinstance(v, str) and len(v) >= 32:
442+
ids.append(v)
443+
break
444+
445+
batch_info = {
446+
"total": len(records),
447+
"success": len(ids),
448+
"failures": len(records) - len(ids),
449+
}
450+
return ids, metadata, batch_info
451+
452+
def _update_with_metadata(
453+
self, table_schema_name: str, key: str, data: Dict[str, Any]
454+
) -> tuple[None, RequestMetadata]:
455+
"""Update a single record and return metadata.
456+
457+
Same as :meth:`_update` but returns a tuple of (None, RequestMetadata)
458+
for the fluent API pattern.
459+
460+
:param table_schema_name: Schema name of the table.
461+
:type table_schema_name: ``str``
462+
:param key: Record GUID.
463+
:type key: ``str``
464+
:param data: Attribute changes.
465+
:type data: ``dict[str, Any]``
466+
:return: Tuple of (None, request metadata).
467+
:rtype: ``tuple[None, RequestMetadata]``
468+
"""
469+
data = self._lowercase_keys(data)
470+
data = self._convert_labels_to_ints(table_schema_name, data)
471+
entity_set = self._entity_set_from_schema_name(table_schema_name)
472+
url = f"{self.api}/{entity_set}({self._format_key(key)})"
473+
_, metadata = self._request_with_metadata("patch", url, json=data)
474+
return None, metadata
475+
476+
def _delete_with_metadata(
477+
self, table_schema_name: str, key: str
478+
) -> tuple[None, RequestMetadata]:
479+
"""Delete a single record and return metadata.
480+
481+
Same as :meth:`_delete` but returns a tuple of (None, RequestMetadata)
482+
for the fluent API pattern.
483+
484+
:param table_schema_name: Schema name of the table.
485+
:type table_schema_name: ``str``
486+
:param key: Record GUID.
487+
:type key: ``str``
488+
:return: Tuple of (None, request metadata).
489+
:rtype: ``tuple[None, RequestMetadata]``
490+
"""
491+
entity_set = self._entity_set_from_schema_name(table_schema_name)
492+
url = f"{self.api}/{entity_set}({self._format_key(key)})"
493+
_, metadata = self._request_with_metadata("delete", url)
494+
return None, metadata
495+
496+
def _get_with_metadata(
497+
self,
498+
table_schema_name: str,
499+
key: str,
500+
*,
501+
select: Optional[List[str]] = None,
502+
) -> tuple[Dict[str, Any], RequestMetadata]:
503+
"""Get a single record by ID and return with metadata.
504+
505+
Same as :meth:`_get` but returns a tuple of (record, RequestMetadata)
506+
for the fluent API pattern.
507+
508+
:param table_schema_name: Schema name of the table.
509+
:type table_schema_name: ``str``
510+
:param key: Record GUID.
511+
:type key: ``str``
512+
:param select: Optional list of columns to select.
513+
:type select: ``list[str]`` | ``None``
514+
:return: Tuple of (record dict, request metadata).
515+
:rtype: ``tuple[dict[str, Any], RequestMetadata]``
516+
"""
517+
entity_set = self._entity_set_from_schema_name(table_schema_name)
518+
url = f"{self.api}/{entity_set}({self._format_key(key)})"
519+
if select:
520+
select = self._lowercase_list(select)
521+
url += "?$select=" + ",".join(select)
522+
r, metadata = self._request_with_metadata("get", url)
523+
try:
524+
return r.json(), metadata
525+
except ValueError:
526+
return {}, metadata
527+
254528
# --- CRUD Internal functions ---
255529
def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any]) -> str:
256530
"""Create a single record and return its GUID.

0 commit comments

Comments
 (0)