Skip to content

Commit 20e3f46

Browse files
tpellissierclaude
andcommitted
Add client-side telemetry hooks with OpenTelemetry integration
Adds a complete telemetry infrastructure that lets users hook into the SDK's HTTP request lifecycle, integrate with OpenTelemetry for tracing/metrics, and use Python's standard logging -- all opt-in with zero overhead when disabled. Key components: - TelemetryConfig: frozen dataclass for configuring signals and hooks - TelemetryHook: concrete base class for custom telemetry integrations - TelemetryManager / NoOpTelemetryManager: instrumentation engine with factory that returns a zero-overhead no-op when telemetry is disabled - RequestContext / ResponseContext: typed data passed to hooks - _operation_scope: ContextVar-based mechanism to propagate operation names (e.g. "records.create") from namespace methods to _request() Design improvements over the initial draft: - TelemetryHook as concrete base class (not broken @runtime_checkable Protocol) - _span removed from user-facing RequestContext (kept internal via _TrackedRequest) - No except block in trace_request() -- eliminates double error dispatch - Per-subsystem try/except in record_response() for exception safety - NoOp yields None for true zero allocation overhead - Explicit span status (OK/ERROR) on every response - Safe log_level parsing with fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 78eb5dd commit 20e3f46

11 files changed

Lines changed: 1576 additions & 107 deletions

File tree

examples/telemetry_demo.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""
2+
Telemetry Demo - Demonstrates OpenTelemetry integration with Dataverse SDK.
3+
4+
This script shows telemetry flowing through:
5+
1. Custom hooks (always -- no extra dependencies)
6+
2. Console span/metric exporters (requires opentelemetry-sdk)
7+
3. Jaeger UI via OTLP (optional, if running locally via Docker)
8+
9+
To run with hooks only (no OTel dependency):
10+
python examples/telemetry_demo.py
11+
12+
To run with full OTel:
13+
pip install "PowerPlatform-Dataverse-Client[telemetry]"
14+
pip install opentelemetry-sdk
15+
python examples/telemetry_demo.py
16+
17+
To run Jaeger locally:
18+
docker run -d --name jaeger -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest
19+
20+
Then open http://localhost:16686 to see traces.
21+
22+
Usage:
23+
python examples/telemetry_demo.py
24+
"""
25+
26+
import sys
27+
from pathlib import Path
28+
29+
# Add src to path for development
30+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
31+
32+
# =============================================================================
33+
# OpenTelemetry Setup (optional -- demo works with hooks alone)
34+
# =============================================================================
35+
36+
OTEL_CONFIGURED = False
37+
38+
try:
39+
from opentelemetry import trace, metrics
40+
from opentelemetry.sdk.trace import TracerProvider
41+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
42+
from opentelemetry.sdk.metrics import MeterProvider
43+
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
44+
from opentelemetry.sdk.resources import Resource
45+
46+
resource = Resource.create({"service.name": "dataverse-telemetry-demo"})
47+
48+
tracer_provider = TracerProvider(resource=resource)
49+
tracer_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
50+
51+
# Try OTLP exporter for Jaeger
52+
try:
53+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
54+
55+
tracer_provider.add_span_processor(
56+
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True))
57+
)
58+
print("OTLP exporter configured - view traces at http://localhost:16686")
59+
except ImportError:
60+
pass
61+
62+
trace.set_tracer_provider(tracer_provider)
63+
64+
metric_reader = PeriodicExportingMetricReader(ConsoleMetricExporter(), export_interval_millis=5000)
65+
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader]))
66+
67+
OTEL_CONFIGURED = True
68+
print("OpenTelemetry configured with Console exporter")
69+
70+
except ImportError:
71+
print("OpenTelemetry not installed -- running with hooks only")
72+
print(" Install with: pip install opentelemetry-sdk opentelemetry-api")
73+
74+
print("-" * 60)
75+
76+
# =============================================================================
77+
# Dataverse SDK Setup
78+
# =============================================================================
79+
80+
from PowerPlatform.Dataverse.client import DataverseClient
81+
from PowerPlatform.Dataverse.core.config import DataverseConfig
82+
from PowerPlatform.Dataverse.core.telemetry import TelemetryConfig, TelemetryHook
83+
from azure.identity import InteractiveBrowserCredential
84+
85+
# Org details
86+
ORG_URL = "https://aurorabapenvcc726.crmtest.dynamics.com"
87+
TENANT_ID = "91bee3d9-0c15-4f17-8624-c92bb8b36ead"
88+
89+
90+
class DemoTelemetryHook(TelemetryHook):
91+
"""Custom hook that prints request/response info to the console."""
92+
93+
def on_request_start(self, ctx):
94+
print(f"\n>>> Starting: {ctx.operation} [{ctx.method}]")
95+
if ctx.table_name:
96+
print(f" Table: {ctx.table_name}")
97+
98+
def on_request_end(self, request, response):
99+
status = "[OK]" if response.status_code < 400 else "[ERR]"
100+
print(f"<<< {status} {request.operation} - {response.status_code} in {response.duration_ms:.1f}ms")
101+
if response.service_request_id:
102+
print(f" Service Request ID: {response.service_request_id}")
103+
104+
def on_request_error(self, request, error):
105+
print(f"!!! Error in {request.operation}: {error}")
106+
107+
108+
def main():
109+
print("\n" + "=" * 60)
110+
print("DATAVERSE TELEMETRY DEMO")
111+
print("=" * 60)
112+
113+
config = DataverseConfig(
114+
telemetry=TelemetryConfig(
115+
enable_tracing=OTEL_CONFIGURED,
116+
enable_metrics=OTEL_CONFIGURED,
117+
enable_logging=True,
118+
log_level="DEBUG",
119+
hooks=[DemoTelemetryHook()],
120+
)
121+
)
122+
123+
print(f"\nConnecting to: {ORG_URL}")
124+
print("(Browser will open for authentication)\n")
125+
126+
credential = InteractiveBrowserCredential(tenant_id=TENANT_ID)
127+
client = DataverseClient(ORG_URL, credential, config=config)
128+
129+
# ---- Operation 1: Query accounts ----
130+
print("\n" + "-" * 60)
131+
print("OPERATION 1: Query accounts (top 3)")
132+
print("-" * 60)
133+
134+
for page in client.records.get("account", select=["name", "accountid"], top=3):
135+
print(f"\nFound {len(page)} accounts:")
136+
for record in page:
137+
print(f" - {record.get('name', 'N/A')} ({record.get('accountid', 'N/A')[:8]}...)")
138+
139+
# ---- Operation 2: SQL query ----
140+
print("\n" + "-" * 60)
141+
print("OPERATION 2: SQL query for contacts")
142+
print("-" * 60)
143+
144+
rows = client.query.sql("SELECT TOP 3 fullname, emailaddress1 FROM contact ORDER BY fullname")
145+
print(f"\nFound {len(rows)} contacts:")
146+
for row in rows:
147+
print(f" - {row.get('fullname', 'N/A')} <{row.get('emailaddress1', 'N/A')}>")
148+
149+
# ---- Operation 3: Table metadata ----
150+
print("\n" + "-" * 60)
151+
print("OPERATION 3: Get table metadata")
152+
print("-" * 60)
153+
154+
info = client.tables.get("account")
155+
if info:
156+
print(f"\nTable: {info.get('table_schema_name')}")
157+
print(f" Logical: {info.get('table_logical_name')}")
158+
print(f" Entity Set: {info.get('entity_set_name')}")
159+
160+
print("\n" + "=" * 60)
161+
print("DEMO COMPLETE")
162+
print("=" * 60)
163+
print("\nCheck the console output above for:")
164+
print(" - Hook output (>>> / <<< lines)")
165+
if OTEL_CONFIGURED:
166+
print(" - Span traces (name, attributes, duration)")
167+
print(" - Metrics (request counts, durations)")
168+
print("\nIf Jaeger is running, view traces at: http://localhost:16686")
169+
print()
170+
171+
172+
if __name__ == "__main__":
173+
main()

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ dev = [
4949
"mypy>=1.0.0",
5050
"ruff>=0.1.0",
5151
]
52+
telemetry = [
53+
"opentelemetry-api>=1.20.0",
54+
"opentelemetry-sdk>=1.20.0",
55+
]
5256

5357
[tool.setuptools]
5458
package-dir = {"" = "src"}

src/PowerPlatform/Dataverse/common/constants.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,34 @@
2929

3030
CASCADE_BEHAVIOR_RESTRICT = "Restrict"
3131
"""Prevent the referenced table record from being deleted when referencing table records exist."""
32+
33+
34+
# OpenTelemetry semantic convention attribute names
35+
# See: https://opentelemetry.io/docs/specs/semconv/
36+
37+
OTEL_ATTR_DB_SYSTEM = "db.system"
38+
"""Database system identifier."""
39+
40+
OTEL_ATTR_DB_OPERATION = "db.operation"
41+
"""Database operation name."""
42+
43+
OTEL_ATTR_HTTP_METHOD = "http.request.method"
44+
"""HTTP request method."""
45+
46+
OTEL_ATTR_HTTP_URL = "url.full"
47+
"""Full HTTP request URL."""
48+
49+
OTEL_ATTR_HTTP_STATUS_CODE = "http.response.status_code"
50+
"""HTTP response status code."""
51+
52+
OTEL_ATTR_DATAVERSE_TABLE = "dataverse.table"
53+
"""Dataverse table (entity) name."""
54+
55+
OTEL_ATTR_DATAVERSE_REQUEST_ID = "dataverse.client_request_id"
56+
"""Client-generated request ID (x-ms-client-request-id header)."""
57+
58+
OTEL_ATTR_DATAVERSE_CORRELATION_ID = "dataverse.correlation_id"
59+
"""Client-generated correlation ID (x-ms-correlation-id header)."""
60+
61+
OTEL_ATTR_DATAVERSE_SERVICE_REQUEST_ID = "dataverse.service_request_id"
62+
"""Server-assigned request ID (x-ms-service-request-id header)."""

src/PowerPlatform/Dataverse/core/config.py

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

1414
from dataclasses import dataclass
15-
from typing import Optional
15+
from typing import Optional, TYPE_CHECKING
16+
17+
if TYPE_CHECKING:
18+
from .telemetry import TelemetryConfig
1619

1720

1821
@dataclass(frozen=True)
@@ -28,6 +31,9 @@ class DataverseConfig:
2831
:type http_backoff: :class:`float` or None
2932
:param http_timeout: Optional request timeout in seconds. Reserved for future use.
3033
:type http_timeout: :class:`float` or None
34+
:param telemetry: Optional telemetry configuration for tracing, metrics, logging, and custom hooks.
35+
When ``None`` (the default) telemetry is disabled with zero overhead.
36+
:type telemetry: ~PowerPlatform.Dataverse.core.telemetry.TelemetryConfig or None
3137
"""
3238

3339
language_code: int = 1033
@@ -37,6 +43,9 @@ class DataverseConfig:
3743
http_backoff: Optional[float] = None
3844
http_timeout: Optional[float] = None
3945

46+
# Telemetry configuration (opt-in; None = disabled)
47+
telemetry: Optional[TelemetryConfig] = None
48+
4049
@classmethod
4150
def from_env(cls) -> "DataverseConfig":
4251
"""

0 commit comments

Comments
 (0)