Skip to content

Commit 2385b23

Browse files
author
Max Wang
committed
add client side correlation id, client request id. expose these and also service request id correctly
1 parent 5318897 commit 2385b23

5 files changed

Lines changed: 288 additions & 95 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,25 @@ except ValidationError as e:
300300
print(f"Validation error: {e.message}")
301301
```
302302

303+
### Correlate multiple requests to debug
304+
305+
Give your own identifier to every HTTP call by wrapping operations in
306+
`DataverseClient.correlation_scope()`:
307+
308+
```python
309+
from uuid import uuid4
310+
311+
with client.correlation_scope(str(uuid4())):
312+
client.create("account", {"name": "Scoped Request"})
313+
pages = client.get("account", filter="statecode eq 0")
314+
for batch in pages:
315+
...
316+
```
317+
318+
All nested SDK calls inside the block (including pagination and retries) reuse
319+
the provided value for the `x-ms-correlation-request-id` header, which makes it
320+
easy to align Dataverse traces. If you omit the context manager, the SDK automatically generates unique correlation IDs.
321+
303322
### Authentication issues
304323

305324
**Common fixes:**

src/PowerPlatform/Dataverse/client.py

Lines changed: 136 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
from __future__ import annotations
55

6-
from typing import Any, Dict, Optional, Union, List, Iterable
6+
from typing import Any, Dict, Optional, Union, List, Iterable, Iterator
7+
from contextlib import contextmanager
78

89
from azure.core.credentials import TokenCredential
910

@@ -99,6 +100,13 @@ def _get_odata(self) -> _ODataClient:
99100
)
100101
return self._odata
101102

103+
@contextmanager
104+
def _scoped_odata(self) -> Iterator[_ODataClient]:
105+
"""Yield the low-level client while ensuring a correlation scope is active."""
106+
od = self._get_odata()
107+
with od._call_scope():
108+
yield od
109+
102110
# ---------------- Unified CRUD: create/update/delete ----------------
103111
def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]:
104112
"""
@@ -132,19 +140,19 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic
132140
ids = client.create("account", records)
133141
print(f"Created {len(ids)} accounts")
134142
"""
135-
od = self._get_odata()
136-
entity_set = od._entity_set_from_schema_name(table_schema_name)
137-
if isinstance(records, dict):
138-
rid = od._create(entity_set, table_schema_name, records)
139-
# _create returns str on single input
140-
if not isinstance(rid, str):
141-
raise TypeError("_create (single) did not return GUID string")
142-
return [rid]
143-
if isinstance(records, list):
144-
ids = od._create_multiple(entity_set, table_schema_name, records)
145-
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
146-
raise TypeError("_create (multi) did not return list[str]")
147-
return ids
143+
with self._scoped_odata() as od:
144+
entity_set = od._entity_set_from_schema_name(table_schema_name)
145+
if isinstance(records, dict):
146+
rid = od._create(entity_set, table_schema_name, records)
147+
# _create returns str on single input
148+
if not isinstance(rid, str):
149+
raise TypeError("_create (single) did not return GUID string")
150+
return [rid]
151+
if isinstance(records, list):
152+
ids = od._create_multiple(entity_set, table_schema_name, records)
153+
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
154+
raise TypeError("_create (multi) did not return list[str]")
155+
return ids
148156
raise TypeError("records must be dict or list[dict]")
149157

150158
def update(
@@ -192,16 +200,16 @@ def update(
192200
]
193201
client.update("account", ids, changes)
194202
"""
195-
od = self._get_odata()
196-
if isinstance(ids, str):
197-
if not isinstance(changes, dict):
198-
raise TypeError("For single id, changes must be a dict")
199-
od._update(table_schema_name, ids, changes) # discard representation
203+
with self._scoped_odata() as od:
204+
if isinstance(ids, str):
205+
if not isinstance(changes, dict):
206+
raise TypeError("For single id, changes must be a dict")
207+
od._update(table_schema_name, ids, changes) # discard representation
208+
return None
209+
if not isinstance(ids, list):
210+
raise TypeError("ids must be str or list[str]")
211+
od._update_by_ids(table_schema_name, ids, changes)
200212
return None
201-
if not isinstance(ids, list):
202-
raise TypeError("ids must be str or list[str]")
203-
od._update_by_ids(table_schema_name, ids, changes)
204-
return None
205213

206214
def delete(
207215
self,
@@ -235,21 +243,21 @@ def delete(
235243
236244
job_id = client.delete("account", [id1, id2, id3])
237245
"""
238-
od = self._get_odata()
239-
if isinstance(ids, str):
240-
od._delete(table_schema_name, ids)
241-
return None
242-
if not isinstance(ids, list):
243-
raise TypeError("ids must be str or list[str]")
244-
if not ids:
246+
with self._scoped_odata() as od:
247+
if isinstance(ids, str):
248+
od._delete(table_schema_name, ids)
249+
return None
250+
if not isinstance(ids, list):
251+
raise TypeError("ids must be str or list[str]")
252+
if not ids:
253+
return None
254+
if not all(isinstance(rid, str) for rid in ids):
255+
raise TypeError("ids must contain string GUIDs")
256+
if use_bulk_delete:
257+
return od._delete_multiple(table_schema_name, ids)
258+
for rid in ids:
259+
od._delete(table_schema_name, rid)
245260
return None
246-
if not all(isinstance(rid, str) for rid in ids):
247-
raise TypeError("ids must contain string GUIDs")
248-
if use_bulk_delete:
249-
return od._delete_multiple(table_schema_name, ids)
250-
for rid in ids:
251-
od._delete(table_schema_name, rid)
252-
return None
253261

254262
def get(
255263
self,
@@ -328,24 +336,29 @@ def get(
328336
):
329337
print(f"Batch size: {len(batch)}")
330338
"""
331-
od = self._get_odata()
332339
if record_id is not None:
333340
if not isinstance(record_id, str):
334341
raise TypeError("record_id must be str")
335-
return od._get(
336-
table_schema_name,
337-
record_id,
338-
select=select,
339-
)
340-
return od._get_multiple(
341-
table_schema_name,
342-
select=select,
343-
filter=filter,
344-
orderby=orderby,
345-
top=top,
346-
expand=expand,
347-
page_size=page_size,
348-
)
342+
with self._scoped_odata() as od:
343+
return od._get(
344+
table_schema_name,
345+
record_id,
346+
select=select,
347+
)
348+
349+
def _paged() -> Iterable[List[Dict[str, Any]]]:
350+
with self._scoped_odata() as od:
351+
yield from od._get_multiple(
352+
table_schema_name,
353+
select=select,
354+
filter=filter,
355+
orderby=orderby,
356+
top=top,
357+
expand=expand,
358+
page_size=page_size,
359+
)
360+
361+
return _paged()
349362

350363
# SQL via Web API sql parameter
351364
def query_sql(self, sql: str):
@@ -381,7 +394,8 @@ def query_sql(self, sql: str):
381394
sql = "SELECT a.name, a.telephone1 FROM account AS a WHERE a.statecode = 0"
382395
results = client.query_sql(sql)
383396
"""
384-
return self._get_odata()._query_sql(sql)
397+
with self._scoped_odata() as od:
398+
return od._query_sql(sql)
385399

386400
# Table metadata helpers
387401
def get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]:
@@ -404,7 +418,8 @@ def get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]:
404418
print(f"Logical name: {info['table_logical_name']}")
405419
print(f"Entity set: {info['entity_set_name']}")
406420
"""
407-
return self._get_odata()._get_table_info(table_schema_name)
421+
with self._scoped_odata() as od:
422+
return od._get_table_info(table_schema_name)
408423

409424
def create_table(
410425
self,
@@ -474,12 +489,13 @@ class ItemStatus(IntEnum):
474489
primary_column_schema_name="new_ProductName"
475490
)
476491
"""
477-
return self._get_odata()._create_table(
478-
table_schema_name,
479-
columns,
480-
solution_unique_name,
481-
primary_column_schema_name,
482-
)
492+
with self._scoped_odata() as od:
493+
return od._create_table(
494+
table_schema_name,
495+
columns,
496+
solution_unique_name,
497+
primary_column_schema_name,
498+
)
483499

484500
def delete_table(self, table_schema_name: str) -> None:
485501
"""
@@ -499,7 +515,8 @@ def delete_table(self, table_schema_name: str) -> None:
499515
500516
client.delete_table("new_MyTestTable")
501517
"""
502-
self._get_odata()._delete_table(table_schema_name)
518+
with self._scoped_odata() as od:
519+
od._delete_table(table_schema_name)
503520

504521
def list_tables(self) -> list[str]:
505522
"""
@@ -515,7 +532,8 @@ def list_tables(self) -> list[str]:
515532
for table in tables:
516533
print(table)
517534
"""
518-
return self._get_odata()._list_tables()
535+
with self._scoped_odata() as od:
536+
return od._list_tables()
519537

520538
def create_columns(
521539
self,
@@ -545,10 +563,11 @@ def create_columns(
545563
)
546564
print(created) # ['new_Scratch', 'new_Flags']
547565
"""
548-
return self._get_odata()._create_columns(
549-
table_schema_name,
550-
columns,
551-
)
566+
with self._scoped_odata() as od:
567+
return od._create_columns(
568+
table_schema_name,
569+
columns,
570+
)
552571

553572
def delete_columns(
554573
self,
@@ -573,10 +592,11 @@ def delete_columns(
573592
)
574593
print(removed) # ['new_Scratch', 'new_Flags']
575594
"""
576-
return self._get_odata()._delete_columns(
577-
table_schema_name,
578-
columns,
579-
)
595+
with self._scoped_odata() as od:
596+
return od._delete_columns(
597+
table_schema_name,
598+
columns,
599+
)
580600

581601
# File upload
582602
def upload_file(
@@ -640,18 +660,18 @@ def upload_file(
640660
mode="auto"
641661
)
642662
"""
643-
od = self._get_odata()
644-
entity_set = od._entity_set_from_schema_name(table_schema_name)
645-
od._upload_file(
646-
entity_set,
647-
record_id,
648-
file_name_attribute,
649-
path,
650-
mode=mode,
651-
mime_type=mime_type,
652-
if_none_match=if_none_match,
653-
)
654-
return None
663+
with self._scoped_odata() as od:
664+
entity_set = od._entity_set_from_schema_name(table_schema_name)
665+
od._upload_file(
666+
entity_set,
667+
record_id,
668+
file_name_attribute,
669+
path,
670+
mode=mode,
671+
mime_type=mime_type,
672+
if_none_match=if_none_match,
673+
)
674+
return None
655675

656676
# Cache utilities
657677
def flush_cache(self, kind) -> int:
@@ -675,7 +695,40 @@ def flush_cache(self, kind) -> int:
675695
removed = client.flush_cache("picklist")
676696
print(f"Cleared {removed} cached picklist entries")
677697
"""
678-
return self._get_odata()._flush_cache(kind)
698+
with self._scoped_odata() as od:
699+
return od._flush_cache(kind)
700+
701+
# Other utilities
702+
@contextmanager
703+
def correlation_scope(self, correlation_id: str) -> Iterator["DataverseClient"]:
704+
"""Share a caller-specified correlation id across nested SDK calls.
705+
706+
Use this context manager to stamp your own identifier on every Dataverse
707+
request made within the ``with`` block. Nested SDK calls reuse the
708+
existing correlation id, and concurrent scopes remain isolated.
709+
710+
:param correlation_id: Non-empty identifier to propagate to
711+
``x-ms-correlation-request-id``.
712+
:type correlation_id: :class:`str`
713+
:raises TypeError: If ``correlation_id`` is not a string.
714+
:raises ValueError: If ``correlation_id`` is empty after trimming.
715+
716+
Example::
717+
718+
with client.correlation_scope("6f187988-5fb4-4bd2-9f25-4d7a1c9e24ce"):
719+
client.create("account", {"name": "Scoped Run"})
720+
for batch in client.get("account", filter="statecode eq 0"):
721+
...
722+
"""
723+
724+
if not isinstance(correlation_id, str):
725+
raise TypeError("correlation_id must be str")
726+
trimmed = correlation_id.strip()
727+
if not trimmed:
728+
raise ValueError("correlation_id cannot be empty")
729+
od = self._get_odata()
730+
with od._call_scope(trimmed):
731+
yield self
679732

680733

681734
__all__ = ["DataverseClient"]

src/PowerPlatform/Dataverse/core/errors.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,12 @@ class HttpError(DataverseError):
141141
:type subcode: :class:`str` | None
142142
:param service_error_code: Optional Dataverse-specific error code from the API response.
143143
:type service_error_code: :class:`str` | None
144-
:param correlation_id: Optional correlation ID for tracking requests across services.
144+
:param correlation_id: Client-generated Correlation ID for tracking requests within a SDK call.
145145
:type correlation_id: :class:`str` | None
146-
:param request_id: Optional request ID from the API response headers.
147-
:type request_id: :class:`str` | None
146+
:param client_request_id: Client-generated request ID injected into outbound headers.
147+
:type client_request_id: :class:`str` | None
148+
:param service_request_id: ``x-ms-service-request-id`` returned by Dataverse servers.
149+
:type service_request_id: :class:`str` | None
148150
:param traceparent: Optional W3C trace context for distributed tracing.
149151
:type traceparent: :class:`str` | None
150152
:param body_excerpt: Optional excerpt of the response body for diagnostics.
@@ -159,11 +161,12 @@ def __init__(
159161
self,
160162
message: str,
161163
status_code: int,
164+
correlation_id: Optional[str],
165+
client_request_id: Optional[str],
166+
service_request_id: Optional[str],
162167
is_transient: bool = False,
163168
subcode: Optional[str] = None,
164169
service_error_code: Optional[str] = None,
165-
correlation_id: Optional[str] = None,
166-
request_id: Optional[str] = None,
167170
traceparent: Optional[str] = None,
168171
body_excerpt: Optional[str] = None,
169172
retry_after: Optional[int] = None,
@@ -174,8 +177,10 @@ def __init__(
174177
d["service_error_code"] = service_error_code
175178
if correlation_id is not None:
176179
d["correlation_id"] = correlation_id
177-
if request_id is not None:
178-
d["request_id"] = request_id
180+
if client_request_id is not None:
181+
d["client_request_id"] = client_request_id
182+
if service_request_id is not None:
183+
d["service_request_id"] = service_request_id
179184
if traceparent is not None:
180185
d["traceparent"] = traceparent
181186
if body_excerpt is not None:

0 commit comments

Comments
 (0)