Skip to content

Commit 44aca2e

Browse files
CopilotnikhilNava
andcommitted
Add start_time and end_time parameters to OpenTelemetry scope classes
This change implements support for custom start and end times on OpenTelemetry spans, mirroring the Node.js PR #205. Changes include: - Add TimeInput type alias supporting int (nanoseconds), float (seconds), tuple (HrTime), and datetime - Update OpenTelemetryScope base class with start_time and end_time constructor parameters - Add set_end_time() method for setting end time after construction - Update InferenceScope, InvokeAgentScope, ExecuteToolScope, and OutputScope to forward parameters - Add comprehensive unit tests for custom start/end time functionality Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com>
1 parent c65c959 commit 44aca2e

File tree

7 files changed

+421
-12
lines changed

7 files changed

+421
-12
lines changed

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .invoke_agent_details import InvokeAgentDetails
2525
from .invoke_agent_scope import InvokeAgentScope
2626
from .middleware.baggage_builder import BaggageBuilder
27-
from .opentelemetry_scope import OpenTelemetryScope
27+
from .opentelemetry_scope import OpenTelemetryScope, TimeInput
2828
from .request import Request
2929
from .source_metadata import SourceMetadata
3030
from .tenant_details import TenantDetails
@@ -47,6 +47,8 @@
4747
"SpanProcessor",
4848
# Base scope class
4949
"OpenTelemetryScope",
50+
# Type aliases
51+
"TimeInput",
5052
# Specific scope classes
5153
"ExecuteToolScope",
5254
"InvokeAgentScope",

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
SERVER_ADDRESS_KEY,
1616
SERVER_PORT_KEY,
1717
)
18-
from .opentelemetry_scope import OpenTelemetryScope
18+
from .opentelemetry_scope import OpenTelemetryScope, TimeInput
1919
from .request import Request
2020
from .tenant_details import TenantDetails
2121
from .tool_call_details import ToolCallDetails
@@ -31,6 +31,8 @@ def start(
3131
tenant_details: TenantDetails,
3232
request: Request | None = None,
3333
parent_id: str | None = None,
34+
start_time: TimeInput = None,
35+
end_time: TimeInput = None,
3436
) -> "ExecuteToolScope":
3537
"""Creates and starts a new scope for tool execution tracing.
3638
@@ -41,11 +43,18 @@ def start(
4143
request: Optional request details for additional context
4244
parent_id: Optional parent Activity ID used to link this span to an upstream
4345
operation
46+
start_time: Optional explicit start time (ms epoch, Date, or HrTime). Useful when
47+
recording a tool call after execution has already completed.
48+
end_time: Optional explicit end time (ms epoch, Date, or HrTime). When provided,
49+
the span will use this timestamp when disposed instead of the current
50+
wall-clock time.
4451
4552
Returns:
4653
A new ExecuteToolScope instance
4754
"""
48-
return ExecuteToolScope(details, agent_details, tenant_details, request, parent_id)
55+
return ExecuteToolScope(
56+
details, agent_details, tenant_details, request, parent_id, start_time, end_time
57+
)
4958

5059
def __init__(
5160
self,
@@ -54,6 +63,8 @@ def __init__(
5463
tenant_details: TenantDetails,
5564
request: Request | None = None,
5665
parent_id: str | None = None,
66+
start_time: TimeInput = None,
67+
end_time: TimeInput = None,
5768
):
5869
"""Initialize the tool execution scope.
5970
@@ -64,6 +75,11 @@ def __init__(
6475
request: Optional request details for additional context
6576
parent_id: Optional parent Activity ID used to link this span to an upstream
6677
operation
78+
start_time: Optional explicit start time (ms epoch, Date, or HrTime). Useful when
79+
recording a tool call after execution has already completed.
80+
end_time: Optional explicit end time (ms epoch, Date, or HrTime). When provided,
81+
the span will use this timestamp when disposed instead of the current
82+
wall-clock time.
6783
"""
6884
super().__init__(
6985
kind="Internal",
@@ -72,6 +88,8 @@ def __init__(
7288
agent_details=agent_details,
7389
tenant_details=tenant_details,
7490
parent_id=parent_id,
91+
start_time=start_time,
92+
end_time=end_time,
7593
)
7694

7795
# Extract details using deconstruction-like approach

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
2020
)
2121
from .inference_call_details import InferenceCallDetails
22-
from .opentelemetry_scope import OpenTelemetryScope
22+
from .opentelemetry_scope import OpenTelemetryScope, TimeInput
2323
from .request import Request
2424
from .tenant_details import TenantDetails
2525
from .utils import safe_json_dumps
@@ -35,6 +35,8 @@ def start(
3535
tenant_details: TenantDetails,
3636
request: Request | None = None,
3737
parent_id: str | None = None,
38+
start_time: TimeInput = None,
39+
end_time: TimeInput = None,
3840
) -> "InferenceScope":
3941
"""Creates and starts a new scope for inference tracing.
4042
@@ -45,11 +47,15 @@ def start(
4547
request: Optional request details for additional context
4648
parent_id: Optional parent Activity ID used to link this span to an upstream
4749
operation
50+
start_time: Optional explicit start time (ms epoch, Date, or HrTime)
51+
end_time: Optional explicit end time (ms epoch, Date, or HrTime)
4852
4953
Returns:
5054
A new InferenceScope instance
5155
"""
52-
return InferenceScope(details, agent_details, tenant_details, request, parent_id)
56+
return InferenceScope(
57+
details, agent_details, tenant_details, request, parent_id, start_time, end_time
58+
)
5359

5460
def __init__(
5561
self,
@@ -58,6 +64,8 @@ def __init__(
5864
tenant_details: TenantDetails,
5965
request: Request | None = None,
6066
parent_id: str | None = None,
67+
start_time: TimeInput = None,
68+
end_time: TimeInput = None,
6169
):
6270
"""Initialize the inference scope.
6371
@@ -68,6 +76,8 @@ def __init__(
6876
request: Optional request details for additional context
6977
parent_id: Optional parent Activity ID used to link this span to an upstream
7078
operation
79+
start_time: Optional explicit start time (ms epoch, Date, or HrTime)
80+
end_time: Optional explicit end time (ms epoch, Date, or HrTime)
7181
"""
7282

7383
super().__init__(
@@ -77,6 +87,8 @@ def __init__(
7787
agent_details=agent_details,
7888
tenant_details=tenant_details,
7989
parent_id=parent_id,
90+
start_time=start_time,
91+
end_time=end_time,
8092
)
8193

8294
if request:

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
)
3333
from .invoke_agent_details import InvokeAgentDetails
3434
from .models.caller_details import CallerDetails
35-
from .opentelemetry_scope import OpenTelemetryScope
35+
from .opentelemetry_scope import OpenTelemetryScope, TimeInput
3636
from .request import Request
3737
from .tenant_details import TenantDetails
3838
from .utils import safe_json_dumps, validate_and_normalize_ip
@@ -50,6 +50,8 @@ def start(
5050
request: Request | None = None,
5151
caller_agent_details: AgentDetails | None = None,
5252
caller_details: CallerDetails | None = None,
53+
start_time: TimeInput = None,
54+
end_time: TimeInput = None,
5355
) -> "InvokeAgentScope":
5456
"""Create and start a new scope for agent invocation tracing.
5557
@@ -60,12 +62,20 @@ def start(
6062
request: Optional request details for additional context
6163
caller_agent_details: Optional details of the caller agent
6264
caller_details: Optional details of the non-agentic caller
65+
start_time: Optional explicit start time (ms epoch, Date, or HrTime)
66+
end_time: Optional explicit end time (ms epoch, Date, or HrTime)
6367
6468
Returns:
6569
A new InvokeAgentScope instance
6670
"""
6771
return InvokeAgentScope(
68-
invoke_agent_details, tenant_details, request, caller_agent_details, caller_details
72+
invoke_agent_details,
73+
tenant_details,
74+
request,
75+
caller_agent_details,
76+
caller_details,
77+
start_time,
78+
end_time,
6979
)
7080

7181
def __init__(
@@ -75,6 +85,8 @@ def __init__(
7585
request: Request | None = None,
7686
caller_agent_details: AgentDetails | None = None,
7787
caller_details: CallerDetails | None = None,
88+
start_time: TimeInput = None,
89+
end_time: TimeInput = None,
7890
):
7991
"""Initialize the agent invocation scope.
8092
@@ -84,6 +96,8 @@ def __init__(
8496
request: Optional request details for additional context
8597
caller_agent_details: Optional details of the caller agent
8698
caller_details: Optional details of the non-agentic caller
99+
start_time: Optional explicit start time (ms epoch, Date, or HrTime)
100+
end_time: Optional explicit end time (ms epoch, Date, or HrTime)
87101
"""
88102
activity_name = INVOKE_AGENT_OPERATION_NAME
89103
if invoke_agent_details.details.agent_name:
@@ -97,6 +111,8 @@ def __init__(
97111
activity_name=activity_name,
98112
agent_details=invoke_agent_details.details,
99113
tenant_details=tenant_details,
114+
start_time=start_time,
115+
end_time=end_time,
100116
)
101117

102118
endpoint, _, session_id = (

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import os
88
import time
9+
from datetime import datetime
910
from threading import Lock
1011
from typing import TYPE_CHECKING, Any
1112

@@ -48,6 +49,12 @@
4849
# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core'
4950
logger = logging.getLogger(__name__)
5051

52+
# TimeInput is a type alias for types that can be used as span timestamps.
53+
# OpenTelemetry Python SDK accepts int (nanoseconds since epoch), float (seconds since epoch),
54+
# or a tuple of (seconds, nanoseconds) as HrTime.
55+
# We extend this to also accept datetime objects for convenience.
56+
TimeInput = int | float | tuple[int, int] | datetime | None
57+
5158

5259
class OpenTelemetryScope:
5360
"""Base class for OpenTelemetry tracing scopes in the SDK."""
@@ -80,6 +87,8 @@ def __init__(
8087
agent_details: "AgentDetails | None" = None,
8188
tenant_details: "TenantDetails | None" = None,
8289
parent_id: str | None = None,
90+
start_time: TimeInput = None,
91+
end_time: TimeInput = None,
8392
):
8493
"""Initialize the OpenTelemetry scope.
8594
@@ -91,9 +100,20 @@ def __init__(
91100
tenant_details: Optional tenant details
92101
parent_id: Optional parent Activity ID used to link this span to an upstream
93102
operation
103+
start_time: Optional explicit start time. Can be:
104+
- int: nanoseconds since epoch
105+
- float: seconds since epoch
106+
- tuple[int, int]: HrTime as (seconds, nanoseconds)
107+
- datetime: Python datetime object
108+
Useful when recording an operation after it has already completed.
109+
end_time: Optional explicit end time in the same format as start_time.
110+
When provided, the span will use this timestamp when disposed
111+
instead of the current wall-clock time.
94112
"""
95113
self._span: Span | None = None
96-
self._start_time = time.time()
114+
self._wall_clock_start_ms = time.time() * 1000 # milliseconds
115+
self._custom_start_time: TimeInput = start_time
116+
self._custom_end_time: TimeInput = end_time
97117
self._has_ended = False
98118
self._error_type: str | None = None
99119
self._exception: Exception | None = None
@@ -119,7 +139,15 @@ def __init__(
119139
parent_context = parse_parent_id_to_context(parent_id)
120140
span_context = parent_context if parent_context else context.get_current()
121141

122-
self._span = tracer.start_span(activity_name, kind=activity_kind, context=span_context)
142+
# Convert custom start time to OTel-compatible format (nanoseconds since epoch)
143+
otel_start_time = self._time_input_to_ns(start_time) if start_time else None
144+
145+
self._span = tracer.start_span(
146+
activity_name,
147+
kind=activity_kind,
148+
context=span_context,
149+
start_time=otel_start_time,
150+
)
123151

124152
# Log span creation
125153
if self._span:
@@ -230,14 +258,84 @@ def record_attributes(self, attributes: dict[str, Any] | list[tuple[str, Any]])
230258
if key and key.strip():
231259
self._span.set_attribute(key, value)
232260

261+
@staticmethod
262+
def _time_input_to_ns(t: TimeInput) -> int | None:
263+
"""Convert a TimeInput value to nanoseconds since epoch.
264+
265+
OpenTelemetry Python SDK accepts int (nanoseconds since epoch) for span
266+
start_time and end_time parameters.
267+
268+
Args:
269+
t: TimeInput value which can be:
270+
- int: nanoseconds since epoch (returned as-is)
271+
- float: seconds since epoch
272+
- tuple[int, int]: HrTime as (seconds, nanoseconds)
273+
- datetime: Python datetime object
274+
275+
Returns:
276+
Nanoseconds since epoch, or None if input is None
277+
"""
278+
if t is None:
279+
return None
280+
if isinstance(t, int):
281+
# Assume nanoseconds if it's an integer
282+
return t
283+
if isinstance(t, float):
284+
# Convert seconds to nanoseconds
285+
return int(t * 1_000_000_000)
286+
if isinstance(t, tuple) and len(t) == 2:
287+
# HrTime: (seconds, nanoseconds)
288+
seconds, nanos = t
289+
return seconds * 1_000_000_000 + nanos
290+
if isinstance(t, datetime):
291+
return int(t.timestamp() * 1_000_000_000)
292+
logger.warning(
293+
f"_time_input_to_ns received unexpected TimeInput "
294+
f"(type={type(t).__name__}); returning None"
295+
)
296+
return None
297+
298+
@staticmethod
299+
def _time_input_to_ms(t: TimeInput) -> float | None:
300+
"""Convert a TimeInput value to milliseconds since epoch.
301+
302+
Used for duration calculations.
303+
304+
Args:
305+
t: TimeInput value (see _time_input_to_ns for accepted types)
306+
307+
Returns:
308+
Milliseconds since epoch, or None if input is None
309+
"""
310+
ns = OpenTelemetryScope._time_input_to_ns(t)
311+
return ns / 1_000_000 if ns is not None else None
312+
313+
def set_end_time(self, end_time: TimeInput) -> None:
314+
"""Set a custom end time for the scope.
315+
316+
When set, dispose() will pass this value to span.end() instead of using
317+
the current wall-clock time. This is useful when the actual end time of
318+
the operation is known before the scope is disposed.
319+
320+
Args:
321+
end_time: The end time as nanoseconds since epoch, seconds since epoch,
322+
HrTime tuple (seconds, nanoseconds), or datetime object.
323+
"""
324+
self._custom_end_time = end_time
325+
233326
def _end(self) -> None:
234327
"""End the span and record metrics."""
235328
if self._span and self._is_telemetry_enabled() and not self._has_ended:
236329
self._has_ended = True
237330
span_id = f"{self._span.context.span_id:016x}" if self._span.context else "unknown"
238331
logger.info(f"Span ended: '{self._span.name}' ({span_id})")
239332

240-
self._span.end()
333+
# Convert custom end time to OTel-compatible format (nanoseconds since epoch)
334+
otel_end_time = self._time_input_to_ns(self._custom_end_time)
335+
if otel_end_time is not None:
336+
self._span.end(end_time=otel_end_time)
337+
else:
338+
self._span.end()
241339

242340
def __enter__(self):
243341
"""Enter the context manager and make span active."""

0 commit comments

Comments
 (0)