Skip to content

Commit c2843da

Browse files
feat: add operation_context kwarg for User-Agent attribution (#178)
## Summary - Adds an optional `operation_context` keyword argument to `DataverseClient` that appends a parenthesized comment to the outbound `User-Agent` header - Enables callers (e.g. the Dataverse skills plugin) to attribute traffic they originate, so server-side dashboards can compute MAU/MAT, skill split, and agent distribution from existing Dataverse server logs - The context can also be set via `DataverseConfig(operation_context=...)` for callers using custom config ## Example ```python client = DataverseClient( base_url="https://org.crm.dynamics.com", credential=credential, operation_context="app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code", ) ``` Resulting UA: `DataverseSvcPythonClient:0.1.0b10 (app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code)` Without `operation_context`, the UA remains unchanged: `DataverseSvcPythonClient:0.1.0b10` ## Changes | File | Change | |---|---| | `src/.../core/config.py` | Added `operation_context: Optional[str] = None` to `DataverseConfig` dataclass | | `src/.../data/_odata.py` | `_headers()` conditionally appends `(operation_context)` to UA string | | `src/.../client.py` | Added `operation_context` keyword-only arg to `DataverseClient.__init__()`. Raises `ValueError` if both `config` and `operation_context` are provided | | `tests/unit/test_operation_context.py` | 11 new unit tests covering config, client kwarg, and UA header behavior | ## Test plan - [x] `pytest tests/unit/test_operation_context.py -v` — 11 passed - [x] `pytest tests/unit/ -v` — 1369 passed, 0 regressions - [ ] Manual: instantiate `DataverseClient` with `operation_context`, make a request, verify UA in Fiddler/server logs ## Design reference See `design/plugin-telemetry-design.md` in the `dataverse-cli` repo on `design/plugin-telemetry-review` branch — section 3.2 (single operation_context) and Phase 1 in section 5. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7441b5c commit c2843da

5 files changed

Lines changed: 220 additions & 3 deletions

File tree

src/PowerPlatform/Dataverse/client.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from azure.core.credentials import TokenCredential
1313

1414
from .core._auth import _AuthManager
15-
from .core.config import DataverseConfig
15+
from .core.config import DataverseConfig, OperationContext
1616
from .data._odata import _ODataClient
1717
from .operations.dataframe import DataFrameOperations
1818
from .operations.records import RecordOperations
@@ -44,8 +44,14 @@ class DataverseClient:
4444
:param config: Optional configuration for language, timeouts, and retries.
4545
If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`.
4646
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None
47+
:param context: Optional caller-defined context object appended to the
48+
outbound ``User-Agent`` header for plugin/tool attribution. Cannot be used
49+
together with ``config`` -- pass the context via
50+
:class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead.
51+
:type context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
4752
4853
:raises ValueError: If ``base_url`` is missing or empty after trimming.
54+
:raises ValueError: If both ``config`` and ``context`` are provided.
4955
5056
.. note::
5157
The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls.
@@ -95,12 +101,23 @@ def __init__(
95101
base_url: str,
96102
credential: TokenCredential,
97103
config: Optional[DataverseConfig] = None,
104+
*,
105+
context: Optional[OperationContext] = None,
98106
) -> None:
107+
if config is not None and context is not None:
108+
raise ValueError(
109+
"Cannot specify both 'config' and 'context'. " "Pass operation_context via DataverseConfig instead."
110+
)
99111
self.auth = _AuthManager(credential)
100112
self._base_url = (base_url or "").rstrip("/")
101113
if not self._base_url:
102114
raise ValueError("base_url is required.")
103-
self._config = config or DataverseConfig.from_env()
115+
if config is not None:
116+
self._config = config
117+
elif context is not None:
118+
self._config = DataverseConfig(operation_context=context)
119+
else:
120+
self._config = DataverseConfig.from_env()
104121
self._odata: Optional[_ODataClient] = None
105122
self._session: Optional[requests.Session] = None
106123
self._closed: bool = False

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,50 @@
1111

1212
from __future__ import annotations
1313

14+
import re
1415
from dataclasses import dataclass
1516
from typing import TYPE_CHECKING, Optional
1617

1718
if TYPE_CHECKING:
1819
from .log_config import LogConfig
1920

21+
# key=value pairs separated by semicolons.
22+
# Keys: alphanumeric, hyphens, underscores.
23+
# Values: alphanumeric, hyphens, underscores, dots, slashes.
24+
_CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$")
25+
26+
27+
@dataclass(frozen=True)
28+
class OperationContext:
29+
"""Caller-defined context appended to outbound ``User-Agent`` headers.
30+
31+
The context string is validated to be semicolon-separated ``key=value`` pairs
32+
(e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email
33+
addresses, and other potentially sensitive strings are rejected.
34+
35+
:param user_agent_context: Attribution string in ``key=value;key=value`` format.
36+
:type user_agent_context: :class:`str`
37+
38+
:raises ValueError: If the string is empty, contains control characters, or
39+
does not match the required ``key=value`` format.
40+
"""
41+
42+
user_agent_context: str
43+
44+
def __post_init__(self) -> None:
45+
val = self.user_agent_context
46+
if not val:
47+
raise ValueError("operation_context must not be empty.")
48+
if any(c in val for c in "\r\n\x00"):
49+
raise ValueError("operation_context must not contain CR, LF, or NUL characters.")
50+
if not _CONTEXT_PATTERN.match(val):
51+
raise ValueError(
52+
"operation_context must be semicolon-separated key=value pairs "
53+
"(e.g. 'app=myapp/1.0;agent=claude-code'). "
54+
"Keys and values may contain alphanumerics, hyphens, underscores, "
55+
"dots, and slashes."
56+
)
57+
2058

2159
@dataclass(frozen=True)
2260
class DataverseConfig:
@@ -35,6 +73,10 @@ class DataverseConfig:
3573
When provided, all HTTP requests and responses are logged to timestamped
3674
``.log`` files with automatic redaction of sensitive headers.
3775
:type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None
76+
:param operation_context: Optional caller-defined context object appended to the
77+
outbound ``User-Agent`` header as a parenthesized comment. Intended for
78+
plugin/tool attribution.
79+
:type operation_context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
3880
"""
3981

4082
language_code: int = 1033
@@ -46,6 +88,8 @@ class DataverseConfig:
4688

4789
log_config: Optional["LogConfig"] = None
4890

91+
operation_context: Optional[OperationContext] = None
92+
4993
@classmethod
5094
def from_env(cls) -> "DataverseConfig":
5195
"""
@@ -61,4 +105,5 @@ def from_env(cls) -> "DataverseConfig":
61105
http_backoff=None,
62106
http_timeout=None,
63107
log_config=None,
108+
operation_context=None,
64109
)

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ def __init__(
206206
session=session,
207207
logger=self._http_logger,
208208
)
209+
ctx_obj = self.config.operation_context
210+
self._operation_context = ctx_obj.user_agent_context if ctx_obj else None
209211
self._logical_to_entityset_cache: dict[str, str] = {}
210212
# Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid)
211213
self._logical_primaryid_cache: dict[str, str] = {}
@@ -241,13 +243,16 @@ def _headers(self) -> Dict[str, str]:
241243
"""Build standard OData headers with bearer auth."""
242244
scope = f"{self.base_url}/.default"
243245
token = self.auth._acquire_token(scope).access_token
246+
ua = _USER_AGENT
247+
if self._operation_context:
248+
ua = f"{_USER_AGENT} ({self._operation_context})"
244249
return {
245250
"Authorization": f"Bearer {token}",
246251
"Accept": "application/json",
247252
"Content-Type": "application/json",
248253
"OData-MaxVersion": "4.0",
249254
"OData-Version": "4.0",
250-
"User-Agent": _USER_AGENT,
255+
"User-Agent": ua,
251256
}
252257

253258
def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:

tests/unit/data/test_enum_optionset_payload.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(self, language_code=1033):
2525
self.http_backoff = 0
2626
self.http_timeout = 5
2727
self.log_config = None
28+
self.operation_context = None # None or OperationContext object
2829

2930

3031
def _make_client(lang=1033):
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Tests for operation_context support on DataverseClient and User-Agent header."""
5+
6+
import unittest
7+
from unittest.mock import MagicMock
8+
9+
from azure.core.credentials import TokenCredential
10+
11+
from PowerPlatform.Dataverse.client import DataverseClient
12+
from PowerPlatform.Dataverse.core.config import DataverseConfig, OperationContext
13+
from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT
14+
15+
16+
class TestOperationContextValidation(unittest.TestCase):
17+
"""Tests for OperationContext format validation and PII rejection."""
18+
19+
def test_valid_single_pair(self):
20+
ctx = OperationContext(user_agent_context="app=test/1.0")
21+
self.assertEqual(ctx.user_agent_context, "app=test/1.0")
22+
23+
def test_valid_multiple_pairs(self):
24+
ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code")
25+
self.assertEqual(ctx.user_agent_context, "app=test/1.0;skill=dv-data;agent=claude-code")
26+
27+
def test_valid_with_dots_slashes_hyphens(self):
28+
ctx = OperationContext(user_agent_context="app=dataverse-skills/1.2.1")
29+
self.assertEqual(ctx.user_agent_context, "app=dataverse-skills/1.2.1")
30+
31+
def test_reject_empty(self):
32+
with self.assertRaises(ValueError):
33+
OperationContext(user_agent_context="")
34+
35+
def test_reject_email(self):
36+
with self.assertRaises(ValueError):
37+
OperationContext(user_agent_context="myname@email.com")
38+
39+
def test_reject_freeform_text(self):
40+
with self.assertRaises(ValueError):
41+
OperationContext(user_agent_context="my bank password is 1234")
42+
43+
def test_reject_control_chars(self):
44+
for bad in ["has\rnewline", "has\nnewline", "has\x00null"]:
45+
with self.assertRaises(ValueError):
46+
OperationContext(user_agent_context=bad)
47+
48+
def test_reject_spaces(self):
49+
with self.assertRaises(ValueError):
50+
OperationContext(user_agent_context="app=my app")
51+
52+
def test_reject_no_equals(self):
53+
with self.assertRaises(ValueError):
54+
OperationContext(user_agent_context="justaplainstring")
55+
56+
57+
class TestOperationContextConfig(unittest.TestCase):
58+
"""Tests for operation_context on DataverseConfig."""
59+
60+
def test_default_is_none(self):
61+
config = DataverseConfig.from_env()
62+
self.assertIsNone(config.operation_context)
63+
64+
def test_explicit_value(self):
65+
ctx = OperationContext(user_agent_context="app=test/1.0;agent=claude-code")
66+
config = DataverseConfig(operation_context=ctx)
67+
self.assertEqual(config.operation_context.user_agent_context, "app=test/1.0;agent=claude-code")
68+
69+
def test_default_constructor_is_none(self):
70+
config = DataverseConfig()
71+
self.assertIsNone(config.operation_context)
72+
73+
74+
class TestOperationContextClient(unittest.TestCase):
75+
"""Tests for context kwarg on DataverseClient."""
76+
77+
def setUp(self):
78+
self.mock_credential = MagicMock(spec=TokenCredential)
79+
self.base_url = "https://example.crm.dynamics.com"
80+
81+
def test_kwarg_sets_config(self):
82+
ctx = OperationContext(user_agent_context="app=test/1.0;skill=dv-data;agent=claude-code")
83+
client = DataverseClient(
84+
self.base_url,
85+
self.mock_credential,
86+
context=ctx,
87+
)
88+
self.assertEqual(
89+
client._config.operation_context.user_agent_context,
90+
"app=test/1.0;skill=dv-data;agent=claude-code",
91+
)
92+
93+
def test_no_kwarg_leaves_config_default(self):
94+
client = DataverseClient(self.base_url, self.mock_credential)
95+
self.assertIsNone(client._config.operation_context)
96+
97+
def test_config_and_context_raises(self):
98+
ctx = OperationContext(user_agent_context="app=test/1.0")
99+
config = DataverseConfig(operation_context=ctx)
100+
with self.assertRaises(ValueError):
101+
DataverseClient(
102+
self.base_url,
103+
self.mock_credential,
104+
config=config,
105+
context=OperationContext(user_agent_context="app=other/2.0"),
106+
)
107+
108+
def test_config_alone_works(self):
109+
ctx = OperationContext(user_agent_context="app=test/1.0;agent=copilot")
110+
config = DataverseConfig(operation_context=ctx)
111+
client = DataverseClient(self.base_url, self.mock_credential, config=config)
112+
self.assertEqual(
113+
client._config.operation_context.user_agent_context,
114+
"app=test/1.0;agent=copilot",
115+
)
116+
117+
118+
class TestOperationContextUserAgent(unittest.TestCase):
119+
"""Tests for User-Agent header with operation_context."""
120+
121+
def setUp(self):
122+
self.dummy_auth = MagicMock()
123+
token_result = MagicMock()
124+
token_result.access_token = "test-token"
125+
self.dummy_auth._acquire_token.return_value = token_result
126+
self.base_url = "https://org.example.com"
127+
128+
def test_default_user_agent_unchanged(self):
129+
odata = _ODataClient(self.dummy_auth, self.base_url)
130+
headers = odata._headers()
131+
self.assertEqual(headers["User-Agent"], _USER_AGENT)
132+
133+
def test_operation_context_appended(self):
134+
ctx_str = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"
135+
ctx = OperationContext(user_agent_context=ctx_str)
136+
config = DataverseConfig(operation_context=ctx)
137+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
138+
headers = odata._headers()
139+
self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx_str})")
140+
141+
def test_none_context_no_parentheses(self):
142+
config = DataverseConfig(operation_context=None)
143+
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
144+
headers = odata._headers()
145+
self.assertNotIn("(", headers["User-Agent"])
146+
147+
def test_empty_string_rejected_at_creation(self):
148+
with self.assertRaises(ValueError):
149+
OperationContext(user_agent_context="")

0 commit comments

Comments
 (0)