Skip to content

Commit 9d9087c

Browse files
parth-wisdomtconley1428claude
authored
feat: expose histogram_bucket_overrides on OpenTelemetryConfig (#1433)
* Expose histogram_bucket_overrides on OpenTelemetryConfig Mirror the existing PrometheusConfig.histogram_bucket_overrides field. The underlying OtelCollectorOptions builder already supports this via maybe_histogram_bucket_overrides; this change wires it through the Python wrapper and pyo3 bridge. * Add test for OpenTelemetry histogram_bucket_overrides * Drop redundant test comment * Fix runtime test lint * fix: reformat test_runtime.py with ruff 0.15.15 The test-latest-deps CI job upgrades ruff to 0.15.15, which reformats assert-with-message statements into the new preferred style. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: tconley1428 <tconley1428@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b9168c1 commit 9d9087c

4 files changed

Lines changed: 116 additions & 1 deletion

File tree

temporalio/bridge/runtime.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class OpenTelemetryConfig:
7171
metric_temporality_delta: bool
7272
durations_as_seconds: bool
7373
http: bool
74+
histogram_bucket_overrides: Mapping[str, Sequence[float]] | None = None
7475

7576

7677
@dataclass(frozen=True)

temporalio/bridge/src/runtime.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ pub struct OpenTelemetryConfig {
7575
metric_temporality_delta: bool,
7676
durations_as_seconds: bool,
7777
http: bool,
78+
histogram_bucket_overrides: Option<HashMap<String, Vec<f64>>>,
7879
}
7980

8081
#[derive(FromPyObject)]
@@ -357,6 +358,11 @@ impl TryFrom<MetricsConfig> for Arc<dyn CoreMeter> {
357358
} else {
358359
None
359360
})
361+
.maybe_histogram_bucket_overrides(otel_conf.histogram_bucket_overrides.map(
362+
|overrides| temporalio_common::telemetry::HistogramBucketOverrides {
363+
overrides,
364+
},
365+
))
360366
.build();
361367
Ok(Arc::new(build_otlp_metric_exporter(otel_options).map_err(
362368
|err| PyValueError::new_err(format!("Failed building OTel exporter: {err}")),

temporalio/runtime.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ class OpenTelemetryConfig:
336336
When enabled, the ``url`` should point to the HTTP endpoint
337337
(e.g. ``"http://localhost:4318/v1/metrics"``).
338338
Defaults to ``False`` (gRPC).
339+
histogram_bucket_overrides: Override the default histogram bucket
340+
boundaries for specific metrics. Keys are metric names and
341+
values are sequences of bucket boundaries (e.g.
342+
``{"workflow_task_schedule_to_start_latency": [0.01, 0.05, 0.1, 0.5, 1.0, 5.0]}``).
339343
"""
340344

341345
url: str
@@ -346,6 +350,7 @@ class OpenTelemetryConfig:
346350
)
347351
durations_as_seconds: bool = False
348352
http: bool = False
353+
histogram_bucket_overrides: Mapping[str, Sequence[float]] | None = None
349354

350355
def _to_bridge_config(self) -> temporalio.bridge.runtime.OpenTelemetryConfig:
351356
return temporalio.bridge.runtime.OpenTelemetryConfig(
@@ -361,6 +366,7 @@ def _to_bridge_config(self) -> temporalio.bridge.runtime.OpenTelemetryConfig:
361366
),
362367
durations_as_seconds=self.durations_as_seconds,
363368
http=self.http,
369+
histogram_bucket_overrides=self.histogram_bucket_overrides,
364370
)
365371

366372

tests/test_runtime.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
import uuid
66
from datetime import timedelta
7-
from typing import cast
7+
from typing import Any, cast
88
from urllib.request import urlopen
99

1010
import pytest
@@ -14,6 +14,7 @@
1414
from temporalio.runtime import (
1515
LogForwardingConfig,
1616
LoggingConfig,
17+
OpenTelemetryConfig,
1718
PrometheusConfig,
1819
Runtime,
1920
TelemetryConfig,
@@ -269,6 +270,107 @@ async def check_metrics() -> None:
269270
await assert_eventually(check_metrics)
270271

271272

273+
async def test_opentelemetry_histogram_bucket_overrides(client: Client):
274+
# Set up an OpenTelemetry configuration with custom histogram bucket overrides
275+
import threading
276+
from http.server import BaseHTTPRequestHandler, HTTPServer
277+
278+
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
279+
ExportMetricsServiceRequest,
280+
ExportMetricsServiceResponse,
281+
)
282+
283+
special_value = float(1234.5678)
284+
histogram_overrides = {
285+
"temporal_long_request_latency": [special_value / 2, special_value],
286+
"custom_histogram": [special_value / 2, special_value],
287+
}
288+
289+
captured: dict[str, list[float]] = {}
290+
lock = threading.Lock()
291+
292+
class Handler(BaseHTTPRequestHandler):
293+
def log_message(self, format: str, *args: Any):
294+
pass # silence default stderr logging
295+
296+
def do_POST(self):
297+
length = int(self.headers.get("Content-Length", "0"))
298+
req = ExportMetricsServiceRequest()
299+
req.ParseFromString(self.rfile.read(length))
300+
with lock:
301+
for rm in req.resource_metrics:
302+
for sm in rm.scope_metrics:
303+
for m in sm.metrics:
304+
if m.HasField("histogram"):
305+
for dp in m.histogram.data_points:
306+
captured[m.name] = list(dp.explicit_bounds)
307+
body = ExportMetricsServiceResponse().SerializeToString()
308+
self.send_response(200)
309+
self.send_header("Content-Type", "application/x-protobuf")
310+
self.send_header("Content-Length", str(len(body)))
311+
self.end_headers()
312+
self.wfile.write(body)
313+
314+
otel_port = find_free_port()
315+
server = HTTPServer(("127.0.0.1", otel_port), Handler)
316+
thread = threading.Thread(target=server.serve_forever, daemon=True)
317+
thread.start()
318+
try:
319+
runtime = Runtime(
320+
telemetry=TelemetryConfig(
321+
metrics=OpenTelemetryConfig(
322+
url=f"http://127.0.0.1:{otel_port}/v1/metrics",
323+
http=True,
324+
metric_periodicity=timedelta(milliseconds=100),
325+
durations_as_seconds=False,
326+
histogram_bucket_overrides=histogram_overrides,
327+
),
328+
),
329+
)
330+
331+
# Create and record to a custom histogram
332+
custom_histogram = runtime.metric_meter.create_histogram(
333+
"custom_histogram", "Custom histogram", "ms"
334+
)
335+
custom_histogram.record(600)
336+
337+
# Run a workflow so built-in histograms (e.g. temporal_long_request_latency)
338+
# are recorded and exported.
339+
client_with_overrides = await Client.connect(
340+
client.service_client.config.target_host,
341+
namespace=client.namespace,
342+
runtime=runtime,
343+
)
344+
task_queue = f"task-queue-{uuid.uuid4()}"
345+
async with Worker(
346+
client_with_overrides,
347+
task_queue=task_queue,
348+
workflows=[HelloWorkflow],
349+
):
350+
assert "Hello, World!" == await client_with_overrides.execute_workflow(
351+
HelloWorkflow.run,
352+
"World",
353+
id=f"workflow-{uuid.uuid4()}",
354+
task_queue=task_queue,
355+
)
356+
357+
async def check_metrics() -> None:
358+
with lock:
359+
snapshot = dict(captured)
360+
for key, buckets in histogram_overrides.items():
361+
assert key in snapshot, (
362+
f"Missing {key} in captured metrics: {list(snapshot)}"
363+
)
364+
assert snapshot[key] == pytest.approx(buckets), (
365+
f"Bucket mismatch for {key}: got {snapshot[key]} expected {buckets}"
366+
)
367+
368+
await assert_eventually(check_metrics)
369+
finally:
370+
server.shutdown()
371+
server.server_close()
372+
373+
272374
def test_runtime_options_invalid_heartbeat() -> None:
273375
with pytest.raises(ValueError):
274376
Runtime(

0 commit comments

Comments
 (0)