Skip to content

Commit c6e1324

Browse files
committed
Add monotonic timing support for LLM invocation duration calculations
1 parent 232df6c commit c6e1324

File tree

4 files changed

+44
-23
lines changed

4 files changed

+44
-23
lines changed

util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
from __future__ import annotations
6262

63+
import timeit
6364
from contextlib import contextmanager
6465
from typing import Iterator
6566

@@ -131,6 +132,9 @@ def start_llm(
131132
name=f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}",
132133
kind=SpanKind.CLIENT,
133134
)
135+
# Record a monotonic start timestamp (seconds) for duration
136+
# calculation using timeit.default_timer.
137+
invocation.monotonic_start_s = timeit.default_timer()
134138
invocation.span = span
135139
invocation.context_token = otel_context.attach(
136140
set_span_in_context(span)

util/opentelemetry-util-genai/src/opentelemetry/util/genai/metrics.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import time
6+
import timeit
67
from numbers import Number
78
from typing import Dict, Optional
89

@@ -18,10 +19,6 @@
1819
_NS_PER_SECOND = 1_000_000_000
1920

2021

21-
def _now_ns() -> int:
22-
return time.time_ns()
23-
24-
2522
def _get_span_start_time_ns(span: Optional[Span]) -> Optional[int]:
2623
if span is None:
2724
return None
@@ -32,12 +29,31 @@ def _get_span_start_time_ns(span: Optional[Span]) -> Optional[int]:
3229
return None
3330

3431

35-
def _calculate_duration_seconds(span: Optional[Span]) -> Optional[float]:
36-
"""Calculate duration in seconds from span start time to now."""
32+
def _calculate_duration_seconds(
33+
span: Optional[Span], invocation: Optional[LLMInvocation] = None
34+
) -> Optional[float]:
35+
"""Calculate duration in seconds from a start time to now.
36+
37+
If `invocation.monotonic_start_ns` is present, use a monotonic
38+
clock (`perf_counter_ns`) for elapsed time. Otherwise fall back to the
39+
span's wall-clock start time (epoch ns) and `time_ns()` for now.
40+
41+
Returns None if no usable start time is available.
42+
"""
43+
# Prefer an explicit monotonic start on the invocation (seconds)
44+
if invocation is not None and getattr(
45+
invocation, "monotonic_start_s", None
46+
):
47+
start_s = invocation.monotonic_start_s
48+
if isinstance(start_s, (int, float)):
49+
elapsed_s = max(timeit.default_timer() - float(start_s), 0.0)
50+
return elapsed_s
51+
52+
# Fall back to span start_time (wall clock epoch ns)
3753
start_time_ns = _get_span_start_time_ns(span)
3854
if start_time_ns is None:
3955
return None
40-
elapsed_ns = max(_now_ns() - start_time_ns, 0)
56+
elapsed_ns = max(time.time_ns() - start_time_ns, 0)
4157
return elapsed_ns / _NS_PER_SECOND
4258

4359

@@ -90,8 +106,8 @@ def record(
90106
invocation.response_model_name
91107
)
92108

93-
# Calculate duration from span timing
94-
duration_seconds = _calculate_duration_seconds(span)
109+
# Calculate duration from span timing or invocation monotonic start
110+
duration_seconds = _calculate_duration_seconds(span, invocation)
95111

96112
span_context = set_span_in_context(span)
97113
if error_type:

util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ class LLMInvocation:
123123
max_tokens: int | None = None
124124
stop_sequences: list[str] | None = None
125125
seed: int | None = None
126+
# Monotonic start time in seconds (from timeit.default_timer) used
127+
# for duration calculations to avoid mixing clock sources. This is
128+
# populated by the TelemetryHandler when starting an invocation.
129+
monotonic_start_s: float | None = None
126130

127131

128132
@dataclass

util/opentelemetry-util-genai/tests/test_handler_metrics.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,14 @@ def test_stop_llm_records_duration_and_tokens(self) -> None:
3838
invocation = LLMInvocation(request_model="model", provider="prov")
3939
invocation.input_tokens = 5
4040
invocation.output_tokens = 7
41-
handler.start_llm(invocation)
42-
span = invocation.span
43-
self.assertIsNotNone(span)
44-
start_ns = self._get_span_start_time(span)
45-
self.assertIsNotNone(start_ns)
41+
# Patch default_timer during start to ensure monotonic_start_s
42+
with patch("timeit.default_timer", return_value=1000.0):
43+
handler.start_llm(invocation)
4644

45+
# Simulate 2 seconds of elapsed monotonic time (seconds)
4746
with patch(
48-
"time.time_ns",
49-
return_value=start_ns + 2_000_000_000,
47+
"timeit.default_timer",
48+
return_value=1002.0,
5049
):
5150
handler.stop_llm(invocation)
5251

@@ -92,16 +91,14 @@ def test_fail_llm_records_error_and_available_tokens(self) -> None:
9291
)
9392
invocation = LLMInvocation(request_model="err-model", provider=None)
9493
invocation.input_tokens = 11
95-
handler.start_llm(invocation)
96-
span = invocation.span
97-
self.assertIsNotNone(span)
98-
start_ns = self._get_span_start_time(span)
99-
self.assertIsNotNone(start_ns)
94+
# Patch default_timer during start to ensure monotonic_start_s
95+
with patch("timeit.default_timer", return_value=2000.0):
96+
handler.start_llm(invocation)
10097

10198
error = Error(message="boom", type=ValueError)
10299
with patch(
103-
"time.time_ns",
104-
return_value=start_ns + 1_000_000_000,
100+
"timeit.default_timer",
101+
return_value=2001.0,
105102
):
106103
handler.fail_llm(invocation, error)
107104

0 commit comments

Comments
 (0)