From 5c5539db9ad4439fd0c5ae9fc3490a1d12722bea Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Mon, 27 Apr 2026 21:56:52 -0300 Subject: [PATCH 01/17] Rework OTEL + polish samples --- .bumpversion.cfg | 2 +- .dockerignore | 13 ++ README.md | 12 +- inorbit_edge/__init__.py | 2 +- inorbit_edge/metrics.py | 82 ++++++-- inorbit_edge/robot.py | 25 ++- inorbit_edge/tests/demo/Dockerfile | 32 +++ inorbit_edge/tests/demo/README.md | 54 ++++- inorbit_edge/tests/demo/example.py | 107 +++++++--- .../tests/demo/robots_config_example.yaml | 10 - inorbit_edge/tests/test_metrics.py | 196 ++++++++++++++++++ requirements-telemetry.txt | 5 + requirements.txt | 2 - setup.py | 7 +- tox.ini | 1 + 15 files changed, 466 insertions(+), 84 deletions(-) create mode 100644 .dockerignore create mode 100644 inorbit_edge/tests/demo/Dockerfile delete mode 100644 inorbit_edge/tests/demo/robots_config_example.yaml create mode 100644 inorbit_edge/tests/test_metrics.py create mode 100644 requirements-telemetry.txt diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c4cf95f..e60a5a0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = true -current_version = 2.0.2 +current_version = 2.1.0 tag = true [bumpversion:file(version):setup.py] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30194db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.tox +**/__pycache__ +**/*.pyc +.pytest_cache +.mypy_cache +htmlcov +.coverage +*.egg-info +.venv +dist +build diff --git a/README.md b/README.md index 751ec64..eb806b9 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,14 @@ which supports various exporting mechanisms. Connectors are responsible for configuring the exporter of their choice; as well as adding more metrics if they chose to do so. -To do so, add these `opentelemetry-api` and `opentelemetry-sdk` packages -to the connector project. Depending on the exporter, another package such -as `opentelemetry-exporter-prometheus` (for Prometheus) is required. -The following is an example initialization code that enables a +Install the optional **telemetry** extra (see `requirements-telemetry.txt`) so +the SDK records real OpenTelemetry metrics. Without it, built-in metrics are +no-ops and the base package has no OpenTelemetry dependency: + +`pip install inorbit-edge[telemetry]` + +To export to Prometheus, the extra above includes `opentelemetry-exporter-prometheus` +and `prometheus-client`. The following is an example initialization code that enables a [Prometheus](https://prometheus.io/) HTTP endpoint, where all SDK metrics (including system metrics such as CPU usage) and any metric added by the connector can be scraped and exported to any external system (Grafana, diff --git a/inorbit_edge/__init__.py b/inorbit_edge/__init__.py index 0318055..bc808c1 100644 --- a/inorbit_edge/__init__.py +++ b/inorbit_edge/__init__.py @@ -6,7 +6,7 @@ __email__ = "support@inorbit.ai" # Do not edit this string manually, always use bumpversion # Details in CONTRIBUTING.md -__version__ = "2.0.2" +__version__ = "2.1.0" def get_module_version(): diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 2dc1781..d33fd29 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -22,10 +22,31 @@ # start_http_server(port=prometheus_port, addr=prometheus_host) # import functools +import inspect +import warnings -from opentelemetry import metrics +try: + from opentelemetry import metrics as _otel_metrics -meter = metrics.get_meter("inorbit_edge_sdk") + def _get_meter(): + return _otel_metrics.get_meter("inorbit_edge_sdk") + +except ImportError: # pragma: no cover + # Optional "telemetry" extra not installed + + class _NoOpCounter: + def add(self, amount, attributes=None): + pass + + class _NoOpMeter: + def create_counter(self, name, unit="", description=""): + return _NoOpCounter() + + def _get_meter(): + return _NoOpMeter() + + +meter = _get_meter() publish_map_counter = meter.create_counter( "calls_publish_map", "1", "number of calls to publish maps" @@ -53,33 +74,56 @@ ) -def with_counter_metric(metric): - """ - Decorator to count the number of calls to a function +def with_counter_metric(metric, attributes=None): + """Decorator: increment ``metric`` by 1 on every call. + + Works on sync and async functions (auto-detected). + + attributes: + * ``None`` — no per-call attributes (identical to the original behavior) + * ``dict`` — static per-call attributes + * ``callable`` — invoked with the wrapped function's ``*args, **kwargs``; + must return a dict of attributes """ + def _resolve_attrs(args, kwargs): + if attributes is None: + return {} + if callable(attributes): + return attributes(*args, **kwargs) or {} + return dict(attributes) + def decorator(func): + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + metric.add(1, _resolve_attrs(args, kwargs)) + return await func(*args, **kwargs) + + return async_wrapper + @functools.wraps(func) - def wrapper_decorator(*args, **kwargs): - metric.add(1) + def sync_wrapper(*args, **kwargs): + metric.add(1, _resolve_attrs(args, kwargs)) return func(*args, **kwargs) - return wrapper_decorator + return sync_wrapper return decorator def with_counter_metric_async(metric): - """ - Decorator to count the number of calls to a function - """ + """Deprecated alias for :func:`with_counter_metric`. - def decorator(func): - @functools.wraps(func) - async def wrapper_decorator(*args, **kwargs): - metric.add(1) - return await func(*args, **kwargs) - - return wrapper_decorator + Prefer ``@with_counter_metric(...)``, which now detects async functions + automatically. + """ - return decorator + warnings.warn( + "with_counter_metric_async is deprecated; use with_counter_metric " + "which now auto-detects async functions.", + DeprecationWarning, + stacklevel=2, + ) + return with_counter_metric(metric) diff --git a/inorbit_edge/robot.py b/inorbit_edge/robot.py index 61fe4fe..a7567bc 100644 --- a/inorbit_edge/robot.py +++ b/inorbit_edge/robot.py @@ -105,6 +105,10 @@ DISTANCE_ACCUMULATION_INTERVAL_LIMIT_MS = 30 * 1000 +def _counter_attrs_robot_id(self, *args, **kwargs): + return {"robot_id": self.robot_id} + + @dataclass class LaserConfig: """ @@ -888,7 +892,7 @@ def _handle_mapreq(self, msg): include_pixels=True, ) - @with_counter_metric(publish_map_counter) + @with_counter_metric(publish_map_counter, attributes=_counter_attrs_robot_id) def publish_map( self, file, @@ -934,7 +938,9 @@ def publish_map( include_pixels=force_upload, ) - @with_counter_metric(publish_camera_frame_counter) + @with_counter_metric( + publish_camera_frame_counter, attributes=_counter_attrs_robot_id + ) def publish_camera_frame(self, camera_id, image, width, height, ts): """Publishes a camera frame""" msg = CameraMessage() @@ -1264,7 +1270,7 @@ def publish_protobuf(self, subtopic, message, qos=0, retain=False): ) self.logger.debug("Return code: {}".format(ret)) - @with_counter_metric(publish_pose_counter) + @with_counter_metric(publish_pose_counter, attributes=_counter_attrs_robot_id) def publish_pose(self, x, y, yaw, frame_id="map", ts=None): """Publish robot pose @@ -1322,7 +1328,7 @@ def reached_waypoint(self, waypoint: Pose, tolerance: SpatialTolerance): <= tolerance.angularRadians ) - @with_counter_metric(publish_key_values_counter) + @with_counter_metric(publish_key_values_counter, attributes=_counter_attrs_robot_id) def publish_key_values(self, key_values, custom_field="0", is_event=False): """Publish key value pairs @@ -1356,7 +1362,9 @@ def set_pairs(k): self.publish_protobuf(MQTT_SUBTOPIC_CUSTOM_DATA, msg) - @with_counter_metric(publish_system_stats_counter) + @with_counter_metric( + publish_system_stats_counter, attributes=_counter_attrs_robot_id + ) def publish_system_stats( self, cpu_load_percentage=None, @@ -1389,7 +1397,7 @@ def publish_system_stats( self.publish_protobuf(MQTT_SUBTOPIC_SYSTEM_STATS, msg) - @with_counter_metric(publish_odometry_counter) + @with_counter_metric(publish_odometry_counter, attributes=_counter_attrs_robot_id) def publish_odometry( self, ts_start=None, @@ -1451,7 +1459,7 @@ def publish_odometry( msg.speed_available = True self.publish_protobuf(MQTT_SUBTOPIC_ODOMETRY, msg) - @with_counter_metric(publish_laser_counter) + @with_counter_metric(publish_laser_counter, attributes=_counter_attrs_robot_id) def publish_lasers(self, x, y, yaw, ranges, frame_id="map", ts=None): """Publish an array of lasers. @@ -1504,7 +1512,6 @@ def publish_lasers(self, x, y, yaw, ranges, frame_id="map", ts=None): # Now publish all lasers self.publish_protobuf(MQTT_SUBTOPIC_POSE, msg) - @with_counter_metric(publish_laser_counter) def publish_laser(self, x, y, yaw, ranges, frame_id="map", ts=None): """Publish a single robot laser scan. @@ -1558,7 +1565,7 @@ def register_lasers(self, configs): retain=True, ) - @with_counter_metric(publish_path_counter) + @with_counter_metric(publish_path_counter, attributes=_counter_attrs_robot_id) def publish_path( self, path_points, path_id="0", frame_id="map", ts=None, rdp_epsilon=0.001 ): diff --git a/inorbit_edge/tests/demo/Dockerfile b/inorbit_edge/tests/demo/Dockerfile new file mode 100644 index 0000000..ce80b51 --- /dev/null +++ b/inorbit_edge/tests/demo/Dockerfile @@ -0,0 +1,32 @@ +# Build from repository root, e.g.: +# docker build -f inorbit_edge/tests/demo/Dockerfile -t inorbit-edge-sdk-demo . +# +# Run: mount this folder from the host (example, map, user_scripts). Example from repo root: +# docker run --rm -p 9464:9464 \ +# -v "$PWD/inorbit_edge/tests/demo:/demo:ro" \ +# -e INORBIT_URL=... -e INORBIT_API_URL=... -e INORBIT_API_KEY=... \ +# -e INORBIT_ACCOUNT_ID=... -e INORBIT_USE_SSL=true \ +# inorbit-edge-sdk-demo + +FROM python:3.12-slim-bookworm + +WORKDIR /build + +COPY README.md \ + requirements.txt requirements-dev.txt \ + requirements-video.txt requirements-telemetry.txt \ + setup.py ./ + +COPY inorbit_edge/ ./inorbit_edge/ + +RUN pip install --no-cache-dir -U pip setuptools wheel && \ + pip install --no-cache-dir ".[video,telemetry]" + +# Demo assets (example.py, map.png, user_scripts/) are expected from a host mount at /demo +WORKDIR /demo + +# Default: export SDK metrics for Prometheus on 9464 (scrape /metrics on this port) +ENV INORBIT_METRICS_PORT=9464 +ENV INORBIT_METRICS_ADDR=0.0.0.0 + +CMD ["python", "example.py"] diff --git a/inorbit_edge/tests/demo/README.md b/inorbit_edge/tests/demo/README.md index b90446d..e791cf9 100644 --- a/inorbit_edge/tests/demo/README.md +++ b/inorbit_edge/tests/demo/README.md @@ -5,21 +5,59 @@ data to InOrbit. It also uses the InOrbit API for publishing map data (see `map. ## How to use -Export required environment variables and execute the `example.py` script. Use the `virtualenv` used on -the `CONTRIBUTING.md` guide. +From a virtual environment (see `CONTRIBUTING.md`), install the SDK with the video and telemetry extras from the +repository root, `cd` into this directory so paths such as `./user_scripts` resolve correctly, then set environment +variables and run `example.py`. ```bash +cd /path/to/edge-sdk-python +pip install -e '.[video,telemetry]' +cd inorbit_edge/tests/demo + export INORBIT_URL="https://control.inorbit.ai" export INORBIT_API_URL="https://api.inorbit.ai" export INORBIT_API_KEY="foobar123" -# Set when using InOrbit connect (make sure to update the robot keys -# in the example config first through -# https://api.inorbit.ai/docs/index.html#operation/generateRobotKey) -export INORBIT_ROBOT_CONFIG_FILE=`pwd`/robots_config_example.yaml -# Disable SSL for local development only -export INORBIT_USE_SSL="true" +# TLS to the MQTT broker defaults to on. For a local broker without TLS: INORBIT_USE_SSL=false # Optionally enable video streaming as camera "0" export INORBIT_VIDEO_URL=/dev/video0 +# Optional: Prometheus scrape endpoint for SDK internal metrics (requires [telemetry]) +export INORBIT_METRICS_PORT=9464 +export INORBIT_METRICS_ADDR=0.0.0.0 + python example.py ``` + +Robot ids are always `_edgesdk_demo_0`, `_edgesdk_demo_1`, … The prefix is `INORBIT_ROBOT_ID_PREFIX` and is mandatory. + +With `INORBIT_METRICS_PORT` set, the demo configures OpenTelemetry before importing the SDK, then serves metrics at +`http://$INORBIT_METRICS_ADDR:$INORBIT_METRICS_PORT/metrics` (e.g. curl from the host if the port is published). + +## Run in a throwaway Docker container + +The image only installs the SDK and dependencies. **Mount the demo directory** from your checkout so `example.py`, +`map.png`, and `user_scripts/` come from the host (edit the demo without rebuilding the image). + +Build from the **repository root**: + +```bash +docker build -f inorbit_edge/tests/demo/Dockerfile -t inorbit-edge-sdk-demo . +``` + +Run from the **repository root** (adjust paths if you run from elsewhere). Publish `9464` if you want Prometheus +metrics on the host: + +```bash +docker run --rm -p 9464:9464 \ + -v "$PWD/inorbit_edge/tests/demo:/demo:ro" \ + -e INORBIT_URL=... -e INORBIT_API_URL=... -e INORBIT_API_KEY=... \ + -e INORBIT_ACCOUNT_ID=... \ + -e INORBIT_ROBOT_ID_PREFIX=$(hostname) \ + inorbit-edge-sdk-demo +``` + +`INORBIT_USE_SSL` defaults to **true** in the demo (required for InOrbit staging/production). Only set +`INORBIT_USE_SSL=false` if you use a local broker without TLS. + +The image sets `INORBIT_METRICS_PORT=9464` and `INORBIT_METRICS_ADDR=0.0.0.0` by default; override or unset +`INORBIT_METRICS_PORT` to disable the metrics HTTP server. diff --git a/inorbit_edge/tests/demo/example.py b/inorbit_edge/tests/demo/example.py index 3bf63b7..db9d51c 100644 --- a/inorbit_edge/tests/demo/example.py +++ b/inorbit_edge/tests/demo/example.py @@ -2,12 +2,12 @@ # -*- coding: utf-8 -*- import logging -from time import sleep -from random import randint, uniform, random -from math import pi import os +import socket import sys -from math import inf +from time import sleep +from random import randint, uniform, random +from math import pi, inf from inorbit_edge.robot import ( RobotSessionFactory, @@ -34,16 +34,11 @@ NUM_ROBOTS = 2 NUM_LASERS = 3 -ROBOT_FOOTPRINT = RobotFootprintSpec( - footprint=[ - {"x": -0.5, "y": -0.5}, - {"x": 0.3, "y": -0.5}, - {"x": 0.7, "y": 0.0}, - {"x": 0.3, "y": 0.5}, - {"x": -0.5, "y": 0.5}, - ], - radius=0.2, -) + +def _mqtt_use_ssl(): + """Use TLS for MQTT unless INORBIT_USE_SSL is false, 0, no, or off.""" + v = os.environ.get("INORBIT_USE_SSL", "true").strip().lower() + return v not in ("false", "0", "no", "off") class FakeRobot: @@ -145,18 +140,67 @@ def my_command_handler(robot_id, command_name, args, options): options["result_function"]("0") -if __name__ == "__main__": +def _init_prometheus_metrics(): + """Serve /metrics when INORBIT_METRICS_PORT is set (pip extra telemetry).""" + port_s = os.environ.get("INORBIT_METRICS_PORT", "").strip() + if not port_s: + return + try: + port = int(port_s) + except ValueError: + logging.warning("INORBIT_METRICS_PORT is not a valid integer: %r", port_s) + return + if port <= 0: + return + try: + from opentelemetry import metrics + from opentelemetry.exporter.prometheus import PrometheusMetricReader + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.resources import Resource + from prometheus_client import start_http_server + except ImportError: + logging.warning( + "INORBIT_METRICS_PORT=%s set but telemetry packages missing. " + "Use: pip install 'inorbit-edge[telemetry]'", + port_s, + ) + return + + host = os.environ.get("INORBIT_METRICS_ADDR", "0.0.0.0") + _svc = os.environ.get("INORBIT_METRICS_SERVICE_NAME", "inorbit-edge-sdk-demo") + resource = Resource(attributes={"service.name": _svc}) + # Note: Do not use "-" in the MetricsReader name for GCP envs + metric_reader = PrometheusMetricReader("inorbit_edge_demo") + meter_provider = MeterProvider(metric_readers=[metric_reader], resource=resource) + metrics.set_meter_provider(meter_provider) + start_http_server(port=port, addr=host) + logging.info( + "OpenTelemetry metrics (Prometheus) on http://%s:%s/metrics", + host, + port, + ) + + +def main(): + # Must run before any inorbit_edge import so the SDK uses this MeterProvider + _init_prometheus_metrics() + + robot_footprint = RobotFootprintSpec( + footprint=[ + {"x": -0.5, "y": -0.5}, + {"x": 0.3, "y": -0.5}, + {"x": 0.7, "y": 0.0}, + {"x": 0.3, "y": 0.5}, + {"x": -0.5, "y": 0.5}, + ], + radius=0.2, + ) + inorbit_api_endpoint = os.environ.get("INORBIT_URL") inorbit_api_url = os.environ.get("INORBIT_API_URL") inorbit_account_id = os.environ.get("INORBIT_ACCOUNT_ID") - inorbit_api_use_ssl = os.environ.get("INORBIT_USE_SSL") inorbit_api_key = os.environ.get("INORBIT_API_KEY") - # For InOrbit Connect (https://connect.inorbit.ai/) certified robots, - # use a yaml file to define the robot_key for each robot_id. This - # file stores additional params such as robot_name, etc. - inorbit_robots_config = os.environ.get("INORBIT_ROBOT_CONFIG_FILE") - # If configured stream video as if it was a robot camera video_url = os.environ.get("INORBIT_VIDEO_URL") @@ -166,25 +210,30 @@ def my_command_handler(robot_id, command_name, args, options): assert inorbit_api_url, "Environment variable INORBIT_API_URL not specified" assert inorbit_account_id, "Environment variable INORBIT_ACCOUNT_ID not specified" + # Robot ids are always "_edgesdk_py_". Prefix is mandatory. + robot_id_prefix = os.environ.get("INORBIT_ROBOT_ID_PREFIX").strip() + assert robot_id_prefix, "Environment variable INORBIT_ROBOT_ID_PREFIX is required" + logging.info("Robot id prefix: %r", robot_id_prefix) + # Create robot session factory and session pool robot_session_factory = RobotSessionFactory( endpoint=inorbit_api_endpoint, rest_api_endpoint=inorbit_api_url, api_key=inorbit_api_key, - use_ssl=inorbit_api_use_ssl == "true", + use_ssl=_mqtt_use_ssl(), account_id=inorbit_account_id, ) robot_session_factory.register_command_callback(log_command) robot_session_factory.register_command_callback(my_command_handler) robot_session_factory.register_commands_path("./user_scripts", r".*\.sh") - robot_session_pool = RobotSessionPool(robot_session_factory, inorbit_robots_config) + robot_session_pool = RobotSessionPool(robot_session_factory) # Dictionary mapping robot ID and fake robot object fake_robot_pool = dict() # Create fake robots and populate `fake_robot_pool` dictionary for i in range(NUM_ROBOTS): - cur_robot_id = "edgesdk_py_{}".format(i) + cur_robot_id = "{}_edgesdk_demo_{}".format(robot_id_prefix, i) robot_session = robot_session_pool.get_session( robot_id=cur_robot_id, robot_name=cur_robot_id ) @@ -220,8 +269,8 @@ def my_command_handler(robot_id, command_name, args, options): robot_session.register_lasers(configs) # Configure robot footprint - if ROBOT_FOOTPRINT: - robot_session.apply_footprint(ROBOT_FOOTPRINT) + if robot_footprint: + robot_session.apply_footprint(robot_footprint) # Go through every fake robot and simulate robot movement while True: @@ -265,7 +314,7 @@ def my_command_handler(robot_id, command_name, args, options): ) # Publish multiple lasers - ranges, angles = [], [] + ranges = [] for i in range(NUM_LASERS): # Generate random lidar ranges within arbitrary limits lidar = [max(LIDAR_MIN, random() * LIDAR_MAX) for _ in range(700)] @@ -285,3 +334,7 @@ def my_command_handler(robot_id, command_name, args, options): except KeyboardInterrupt: robot_session_pool.tear_down() sys.exit() + + +if __name__ == "__main__": + main() diff --git a/inorbit_edge/tests/demo/robots_config_example.yaml b/inorbit_edge/tests/demo/robots_config_example.yaml deleted file mode 100644 index 8ee1e02..0000000 --- a/inorbit_edge/tests/demo/robots_config_example.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# robot_id to robot config mappings. Make sure to replace each robot_key -# with the appropriate (unique) values generated through -# https://api.inorbit.ai/docs/index.html#operation/generateRobotKey ---- -edgesdk_py_0: - robot_name: 'edgesdk_py_0' - robot_key: 'robotkey_0' -edgesdk_py_1: - robot_name: 'edgesdk_py_1' - robot_key: 'robotkey_1' diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py new file mode 100644 index 0000000..9a4e268 --- /dev/null +++ b/inorbit_edge/tests/test_metrics.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: 2026 InOrbit, Inc. +# SPDX-License-Identifier: MIT + +import asyncio +import warnings + +import pytest + +from inorbit_edge import metrics as edge_metrics + + +class _RecordingCounter: + """Stand-in for a real OTEL counter that records .add() calls.""" + + def __init__(self): + self.calls = [] + + def add(self, amount, attributes=None): + self.calls.append((amount, dict(attributes) if attributes else {})) + + +def test_with_counter_metric_sync_no_attributes(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric(counter) + def add(a, b): + return a + b + + assert add(1, 2) == 3 + assert counter.calls == [(1, {})] + + +def test_with_counter_metric_async_no_attributes(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric(counter) + async def add(a, b): + return a + b + + result = asyncio.run(add(2, 3)) + assert result == 5 + assert counter.calls == [(1, {})] + + +def test_with_counter_metric_sync_static_attributes(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric(counter, attributes={"endpoint": "/x"}) + def f(): + return "ok" + + f() + assert counter.calls == [(1, {"endpoint": "/x"})] + + +def test_with_counter_metric_callable_attributes_receives_args(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric( + counter, + attributes=lambda a, b=None: {"a": str(a), "b": str(b)}, + ) + def f(a, b=None): + return a + + f(1, b=2) + assert counter.calls == [(1, {"a": "1", "b": "2"})] + + +def test_with_counter_metric_counts_even_when_wrapped_raises(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric(counter) + def f(): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + f() + assert counter.calls == [(1, {})] + + +def test_with_counter_metric_async_alias_emits_deprecation_warning(): + counter = _RecordingCounter() + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + + @edge_metrics.with_counter_metric_async(counter) + async def f(): + return 1 + + asyncio.run(f()) + + assert any(issubclass(w.category, DeprecationWarning) for w in captured) + assert counter.calls == [(1, {})] + + +def test_wrapped_function_preserves_name_and_docstring(): + counter = _RecordingCounter() + + @edge_metrics.with_counter_metric(counter) + def original(x): + """original docstring.""" + return x + + assert original.__name__ == "original" + assert "original docstring" in (original.__doc__ or "") + + +def test_publish_pose_counter_receives_robot_id_attribute( + mock_mqtt_client, monkeypatch +): + """RobotSession.publish_pose adds a robot_id attribute to its counter.""" + from inorbit_edge.robot import RobotSession + + calls = [] + + def _spy_add(amount, attributes=None): + calls.append((amount, dict(attributes) if attributes else {})) + + monkeypatch.setattr(edge_metrics.publish_pose_counter, "add", _spy_add) + + session = RobotSession( + robot_id="test-robot-1", robot_name="test-robot-1", api_key="ak" + ) + session.publish_pose(x=1.0, y=2.0, yaw=0.0, frame_id="map") + + assert calls, "counter was not called" + amount, attrs = calls[0] + assert amount == 1 + assert attrs.get("robot_id") == "test-robot-1" + + +def test_publish_laser_increments_laser_counter_once(mock_mqtt_client, monkeypatch): + """publish_laser delegates to publish_lasers; count once, not on both methods.""" + from inorbit_edge.robot import RobotSession + + calls = [] + + def _spy_add(amount, attributes=None): + calls.append((amount, dict(attributes) if attributes else {})) + + monkeypatch.setattr(edge_metrics.publish_laser_counter, "add", _spy_add) + + session = RobotSession(robot_id="laser-bot", robot_name="laser-bot", api_key="ak") + session.publish_laser(0, 0, 0, [1.0, 2.0], frame_id="map") + + assert len(calls) == 1 + assert calls[0][0] == 1 + assert calls[0][1].get("robot_id") == "laser-bot" + + +@pytest.mark.parametrize( + "counter_name,method_name,method_kwargs", + [ + ( + "publish_key_values_counter", + "publish_key_values", + {"key_values": {"k": "v"}}, + ), + ( + "publish_odometry_counter", + "publish_odometry", + {"linear_distance": 1.0, "angular_distance": 0.1}, + ), + ("publish_path_counter", "publish_path", {"path_points": []}), + ], +) +def test_publish_methods_all_add_robot_id( + mock_mqtt_client, monkeypatch, counter_name, method_name, method_kwargs +): + """Each decorated publish_* method passes robot_id on its counter.""" + from inorbit_edge.robot import RobotSession + + calls = [] + monkeypatch.setattr( + getattr(edge_metrics, counter_name), + "add", + lambda amount, attributes=None: calls.append( + (amount, dict(attributes) if attributes else {}) + ), + ) + + session = RobotSession( + robot_id="fleet-bot-7", robot_name="fleet-bot-7", api_key="ak" + ) + try: + getattr(session, method_name)(**method_kwargs) + except Exception: + # We only care that the counter was called before the body runs + pass + + assert calls, f"counter {counter_name} was not called" + amount, attrs = calls[0] + assert amount == 1 + assert attrs.get("robot_id") == "fleet-bot-7" diff --git a/requirements-telemetry.txt b/requirements-telemetry.txt new file mode 100644 index 0000000..cb407fc --- /dev/null +++ b/requirements-telemetry.txt @@ -0,0 +1,5 @@ +opentelemetry-api~=1.39.1 +opentelemetry-sdk~=1.39.1 +# 0.x release line; must match the SDK line (see PyPI for each version’s opentelemetry-sdk pin) +opentelemetry-exporter-prometheus~=0.60b0 +prometheus-client>=0.20,<1.0 diff --git a/requirements.txt b/requirements.txt index b759047..8482288 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,3 @@ protobuf~=5.29.6 certifi>=2024.2 deprecated>=1.2,<2.0 rdp2~=1.1.2 -opentelemetry-api~=1.39.1 -opentelemetry-sdk~=1.39.1 diff --git a/setup.py b/setup.py index 1e64a8d..03d804d 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup # Do not edit manually, always use bumpversion (see CONTRIBUTING.rst) -VERSION = "2.0.2" +VERSION = "2.1.0" GITHUB_ORG = "https://github.com/inorbit-ai" GITHUB_REPO = f"{GITHUB_ORG}/edge-sdk-python" @@ -20,7 +20,7 @@ long_description = readme_file.read() # Load from the requirements-*.txt files where '*' is anything extra -requirements = {key: [] for key in ["dev", "install", "video"]} +requirements = {key: [] for key in ["dev", "install", "video", "telemetry"]} base_path = os.path.dirname(os.path.abspath(__file__)) for key in requirements: fname = os.path.join( @@ -48,9 +48,10 @@ ], description="InOrbit Edge SDK for Python", # Do not edit manually, always use bumpversion (see CONTRIBUTING.rst) - download_url=f"{GITHUB_REPO}/archive/refs/tags/v2.0.2.zip", + download_url=f"{GITHUB_REPO}/archive/refs/tags/v2.1.0.zip", extras_require={ "video": requirements["video"], + "telemetry": requirements["telemetry"], "dev": requirements["dev"], }, install_requires=requirements["install"], diff --git a/tox.ini b/tox.ini index bc285ca..bfe0caf 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ deps = -rrequirements.txt -rrequirements-dev.txt -rrequirements-video.txt + -rrequirements-telemetry.txt commands = flake8 inorbit_edge black --check --diff inorbit_edge From a2bceb2093bb8368f1b26f866cd9a8fb800c1c8d Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Mon, 27 Apr 2026 21:57:23 -0300 Subject: [PATCH 02/17] Nit --- inorbit_edge/tests/demo/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/inorbit_edge/tests/demo/README.md b/inorbit_edge/tests/demo/README.md index e791cf9..53f46c8 100644 --- a/inorbit_edge/tests/demo/README.md +++ b/inorbit_edge/tests/demo/README.md @@ -50,7 +50,9 @@ metrics on the host: ```bash docker run --rm -p 9464:9464 \ -v "$PWD/inorbit_edge/tests/demo:/demo:ro" \ - -e INORBIT_URL=... -e INORBIT_API_URL=... -e INORBIT_API_KEY=... \ + -e INORBIT_URL=... \ + -e INORBIT_API_URL=... \ + -e INORBIT_API_KEY=... \ -e INORBIT_ACCOUNT_ID=... \ -e INORBIT_ROBOT_ID_PREFIX=$(hostname) \ inorbit-edge-sdk-demo From 890b6c1186c58243b6cd58c31c614b99b18fa840 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 09:33:21 -0300 Subject: [PATCH 03/17] Refactor meter provider --- inorbit_edge/metrics.py | 137 ++++++++++++++++++++++++----- inorbit_edge/tests/test_metrics.py | 64 ++++++++++++++ 2 files changed, 177 insertions(+), 24 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index d33fd29..7ca4222 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -4,22 +4,23 @@ # can be added by connectors to monitor their own operations, following these # examples. # -# In all cases, initialization code is necessary to export these metrics. -# For example, to export metrics from a connector through a Prometheus HTTP -# endpoint, add the following to your initialization code: +# To export these metrics over a Prometheus HTTP endpoint, call +# :func:`setup_prometheus_meter_provider` once during initialization, then +# start the HTTP server with ``prometheus_client.start_http_server``. For +# example: # -# from opentelemetry import metrics -# from opentelemetry.exporter.prometheus import PrometheusMetricReader -# from opentelemetry.sdk.metrics import MeterProvider -# from opentelemetry.sdk.resources import Resource +# from inorbit_edge.metrics import setup_prometheus_meter_provider # from prometheus_client import start_http_server # -# resource = Resource(attributes={"service.name": "my-connector"}) -# # Note: Do not use "-" in the MetricsReader namefor GCP envs -# metric_reader = PrometheusMetricReader("my_connector") -# meter_provider = MeterProvider(metric_readers=[metric_reader], resource=resource) -# metrics.set_meter_provider(meter_provider) -# start_http_server(port=prometheus_port, addr=prometheus_host) +# setup_prometheus_meter_provider( +# service_name="my_connector", # No '-' for GCP compatibility +# service_instance_id="robot-123", +# service_version="1.2.3", +# ) +# start_http_server(port=9090, addr="0.0.0.0") +# +# When the optional ``telemetry`` extra is not installed, all instruments +# become no-ops and ``setup_prometheus_meter_provider`` returns False. # import functools import inspect @@ -27,26 +28,114 @@ try: from opentelemetry import metrics as _otel_metrics + from opentelemetry.metrics import Observation - def _get_meter(): - return _otel_metrics.get_meter("inorbit_edge_sdk") - -except ImportError: # pragma: no cover - # Optional "telemetry" extra not installed + OTEL_API_AVAILABLE = True +except ImportError: # pragma: no cover - exercised when telemetry extra is missing + OTEL_API_AVAILABLE = False + Observation = None # type: ignore[assignment] - class _NoOpCounter: + class _NoOpInstrument: def add(self, amount, attributes=None): pass + def record(self, value, attributes=None): + pass + + def set(self, value, attributes=None): + pass + class _NoOpMeter: - def create_counter(self, name, unit="", description=""): - return _NoOpCounter() + def create_counter(self, *args, **kwargs): + return _NoOpInstrument() + + def create_up_down_counter(self, *args, **kwargs): + return _NoOpInstrument() + + def create_histogram(self, *args, **kwargs): + return _NoOpInstrument() + + def create_gauge(self, *args, **kwargs): + return _NoOpInstrument() - def _get_meter(): - return _NoOpMeter() + def create_observable_gauge(self, *args, **kwargs): + return _NoOpInstrument() + def create_observable_counter(self, *args, **kwargs): + return _NoOpInstrument() -meter = _get_meter() + def create_observable_up_down_counter(self, *args, **kwargs): + return _NoOpInstrument() + + +try: + from opentelemetry.exporter.prometheus import PrometheusMetricReader + from opentelemetry.sdk.metrics import MeterProvider as _SdkMeterProvider + from opentelemetry.sdk.resources import Resource + + PROMETHEUS_EXPORTER_AVAILABLE = True +except ImportError: # pragma: no cover + PROMETHEUS_EXPORTER_AVAILABLE = False + + +def get_meter(name): + """Return an OpenTelemetry Meter for ``name``. + + When the ``telemetry`` extra is not installed, returns a no-op meter + whose instruments accept any call without raising. + """ + if OTEL_API_AVAILABLE: + return _otel_metrics.get_meter(name) + return _NoOpMeter() + + +def setup_prometheus_meter_provider( + service_name, + service_instance_id, + service_version=None, + extra_resource_attributes=None, + exporter_namespace=None, +): + """Install a global OTEL MeterProvider with a Prometheus reader. + + OpenTelemetry permits only one provider per process; subsequent calls are + ignored with a warning by the OTEL runtime. + + Returns True when a provider was built and set. Returns False when the + OpenTelemetry / Prometheus exporter dependencies are not installed (in + which case all instrument calls become no-ops). + + Args: + service_name: OTLP ``service.name`` resource attribute. Also used as + the default ``exporter_namespace``. + service_instance_id: OTLP ``service.instance.id`` resource attribute. + Should be unique per process on a host. + service_version: optional OTLP ``service.version``. + extra_resource_attributes: optional dict of extra Resource attributes. + exporter_namespace: optional namespace for the + ``PrometheusMetricReader``. Defaults to ``service_name``. Must be + ASCII without hyphens for GCP / Prometheus compatibility. + """ + if not (OTEL_API_AVAILABLE and PROMETHEUS_EXPORTER_AVAILABLE): + return False + + attrs = { + "service.name": service_name, + "service.instance.id": service_instance_id, + } + if service_version: + attrs["service.version"] = service_version + if extra_resource_attributes: + attrs.update(extra_resource_attributes) + + resource = Resource.create(attrs) + reader = PrometheusMetricReader(exporter_namespace or service_name) + provider = _SdkMeterProvider(metric_readers=[reader], resource=resource) + _otel_metrics.set_meter_provider(provider) + return True + + +meter = get_meter("inorbit_edge_sdk") publish_map_counter = meter.create_counter( "calls_publish_map", "1", "number of calls to publish maps" diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index 9a4e268..553250a 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -194,3 +194,67 @@ def test_publish_methods_all_add_robot_id( amount, attrs = calls[0] assert amount == 1 assert attrs.get("robot_id") == "fleet-bot-7" + + +# --- Tests for the public Prometheus-setup helpers ------------------------ + +from opentelemetry.metrics import _internal as _otel_internal + + +@pytest.fixture(autouse=False) +def reset_meter_provider(): + """Reset OTEL global provider state before/after the test.""" + from opentelemetry.util._once import Once + + _otel_internal._METER_PROVIDER = None + _otel_internal._PROXY_METER_PROVIDER = _otel_internal._ProxyMeterProvider() + _otel_internal._METER_PROVIDER_SET_ONCE = Once() + yield + _otel_internal._METER_PROVIDER = None + _otel_internal._PROXY_METER_PROVIDER = _otel_internal._ProxyMeterProvider() + _otel_internal._METER_PROVIDER_SET_ONCE = Once() + + +def test_get_meter_returns_real_meter_when_otel_available(): + m = edge_metrics.get_meter("inorbit_test") + # When OTEL is available we get a Meter (likely a _ProxyMeter); not the + # local _NoOpMeter sentinel. + assert m is not None + counter = m.create_counter("inorbit.test.counter") + counter.add(1) + + +def test_setup_prometheus_meter_provider_installs_resource(reset_meter_provider): + from opentelemetry import metrics as otel_metrics + from opentelemetry.sdk.metrics import MeterProvider + + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="inorbit_connector", + service_instance_id="r-1", + service_version="1.0.0", + extra_resource_attributes={"site": "lab"}, + ) + assert installed is True + + provider = otel_metrics.get_meter_provider() + assert isinstance(provider, MeterProvider) + attrs = dict(provider._sdk_config.resource.attributes) + assert attrs["service.name"] == "inorbit_connector" + assert attrs["service.instance.id"] == "r-1" + assert attrs["service.version"] == "1.0.0" + assert attrs["site"] == "lab" + + +def test_setup_prometheus_meter_provider_returns_false_when_disabled(monkeypatch): + monkeypatch.setattr(edge_metrics, "PROMETHEUS_EXPORTER_AVAILABLE", False) + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="x", service_instance_id="y" + ) + assert installed is False + + +def test_otel_api_available_reflects_import_status(): + # In the test environment the telemetry extra is installed, so OTEL is + # available. The flag is the source of truth for callers. + assert edge_metrics.OTEL_API_AVAILABLE is True + assert edge_metrics.Observation is not None From 18846226ca0f170d570c5696f5bcf4c9d62cf7b4 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 10:59:04 -0300 Subject: [PATCH 04/17] Simplify example --- inorbit_edge/tests/demo/Dockerfile | 8 ++++++-- inorbit_edge/tests/demo/example.py | 30 ++++++++++++++++-------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/inorbit_edge/tests/demo/Dockerfile b/inorbit_edge/tests/demo/Dockerfile index ce80b51..9049f74 100644 --- a/inorbit_edge/tests/demo/Dockerfile +++ b/inorbit_edge/tests/demo/Dockerfile @@ -4,8 +4,12 @@ # Run: mount this folder from the host (example, map, user_scripts). Example from repo root: # docker run --rm -p 9464:9464 \ # -v "$PWD/inorbit_edge/tests/demo:/demo:ro" \ -# -e INORBIT_URL=... -e INORBIT_API_URL=... -e INORBIT_API_KEY=... \ -# -e INORBIT_ACCOUNT_ID=... -e INORBIT_USE_SSL=true \ +# -e INORBIT_URL=... \ +# -e INORBIT_API_URL=... \ +# -e INORBIT_API_KEY=... \ +# -e INORBIT_ACCOUNT_ID=... \ +# -e INORBIT_USE_SSL=true \ +# -e INORBIT_ROBOT_ID_PREFIX=$(hostname) \ # inorbit-edge-sdk-demo FROM python:3.12-slim-bookworm diff --git a/inorbit_edge/tests/demo/example.py b/inorbit_edge/tests/demo/example.py index db9d51c..8419b72 100644 --- a/inorbit_edge/tests/demo/example.py +++ b/inorbit_edge/tests/demo/example.py @@ -9,6 +9,7 @@ from random import randint, uniform, random from math import pi, inf +from inorbit_edge.metrics import setup_prometheus_meter_provider from inorbit_edge.robot import ( RobotSessionFactory, RobotSessionPool, @@ -17,6 +18,11 @@ ) from inorbit_edge.video import OpenCVCamera +try: + from prometheus_client import start_http_server +except ImportError: + start_http_server = None + logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", @@ -152,13 +158,16 @@ def _init_prometheus_metrics(): return if port <= 0: return - try: - from opentelemetry import metrics - from opentelemetry.exporter.prometheus import PrometheusMetricReader - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.resources import Resource - from prometheus_client import start_http_server - except ImportError: + + service_name = os.environ.get( + "INORBIT_METRICS_SERVICE_NAME", "inorbit-edge-sdk-demo" + ) + # Note: exporter_namespace must not contain '-' for GCP compatibility. + if not setup_prometheus_meter_provider( + service_name=service_name, + service_instance_id=socket.gethostname(), + exporter_namespace="inorbit_edge_demo", + ) or start_http_server is None: logging.warning( "INORBIT_METRICS_PORT=%s set but telemetry packages missing. " "Use: pip install 'inorbit-edge[telemetry]'", @@ -167,12 +176,6 @@ def _init_prometheus_metrics(): return host = os.environ.get("INORBIT_METRICS_ADDR", "0.0.0.0") - _svc = os.environ.get("INORBIT_METRICS_SERVICE_NAME", "inorbit-edge-sdk-demo") - resource = Resource(attributes={"service.name": _svc}) - # Note: Do not use "-" in the MetricsReader name for GCP envs - metric_reader = PrometheusMetricReader("inorbit_edge_demo") - meter_provider = MeterProvider(metric_readers=[metric_reader], resource=resource) - metrics.set_meter_provider(meter_provider) start_http_server(port=port, addr=host) logging.info( "OpenTelemetry metrics (Prometheus) on http://%s:%s/metrics", @@ -182,7 +185,6 @@ def _init_prometheus_metrics(): def main(): - # Must run before any inorbit_edge import so the SDK uses this MeterProvider _init_prometheus_metrics() robot_footprint = RobotFootprintSpec( From 4acd3eb62ee8887b778296245263c37c372be685 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 12:42:13 -0300 Subject: [PATCH 05/17] Better with_counter_metric --- inorbit_edge/metrics.py | 29 ++++++++++++++++++++++ inorbit_edge/robot.py | 21 +++++++--------- inorbit_edge/tests/test_metrics.py | 39 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 7ca4222..37f9157 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -163,6 +163,35 @@ def setup_prometheus_meter_provider( ) +def attrs_from_self(*names): + """Build an attributes extractor for :func:`with_counter_metric` on methods. + + The returned callable reads each named attribute from the bound instance + (the first positional arg) and returns them as an OTEL attributes dict. + + Use this on instance methods to add per-call attributes that come from + the instance's own state, for example:: + + @with_counter_metric( + publish_pose_counter, attributes=attrs_from_self("robot_id") + ) + def publish_pose(self, ...): + ... + + Multiple attributes are supported:: + + attrs_from_self("robot_id", "session_id") + + Raises ``AttributeError`` at call time if any name is not an attribute of + the instance. + """ + + def _extract(self, *_args, **_kwargs): + return {name: getattr(self, name) for name in names} + + return _extract + + def with_counter_metric(metric, attributes=None): """Decorator: increment ``metric`` by 1 on every call. diff --git a/inorbit_edge/robot.py b/inorbit_edge/robot.py index a7567bc..b6209ee 100644 --- a/inorbit_edge/robot.py +++ b/inorbit_edge/robot.py @@ -9,6 +9,7 @@ from inorbit_edge import __version__ as inorbit_edge_version from inorbit_edge.types import Pose, SpatialTolerance from inorbit_edge.metrics import ( + attrs_from_self, with_counter_metric, publish_map_counter, publish_camera_frame_counter, @@ -105,10 +106,6 @@ DISTANCE_ACCUMULATION_INTERVAL_LIMIT_MS = 30 * 1000 -def _counter_attrs_robot_id(self, *args, **kwargs): - return {"robot_id": self.robot_id} - - @dataclass class LaserConfig: """ @@ -892,7 +889,7 @@ def _handle_mapreq(self, msg): include_pixels=True, ) - @with_counter_metric(publish_map_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_map_counter, attributes=attrs_from_self("robot_id")) def publish_map( self, file, @@ -939,7 +936,7 @@ def publish_map( ) @with_counter_metric( - publish_camera_frame_counter, attributes=_counter_attrs_robot_id + publish_camera_frame_counter, attributes=attrs_from_self("robot_id") ) def publish_camera_frame(self, camera_id, image, width, height, ts): """Publishes a camera frame""" @@ -1270,7 +1267,7 @@ def publish_protobuf(self, subtopic, message, qos=0, retain=False): ) self.logger.debug("Return code: {}".format(ret)) - @with_counter_metric(publish_pose_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_pose_counter, attributes=attrs_from_self("robot_id")) def publish_pose(self, x, y, yaw, frame_id="map", ts=None): """Publish robot pose @@ -1328,7 +1325,7 @@ def reached_waypoint(self, waypoint: Pose, tolerance: SpatialTolerance): <= tolerance.angularRadians ) - @with_counter_metric(publish_key_values_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_key_values_counter, attributes=attrs_from_self("robot_id")) def publish_key_values(self, key_values, custom_field="0", is_event=False): """Publish key value pairs @@ -1363,7 +1360,7 @@ def set_pairs(k): self.publish_protobuf(MQTT_SUBTOPIC_CUSTOM_DATA, msg) @with_counter_metric( - publish_system_stats_counter, attributes=_counter_attrs_robot_id + publish_system_stats_counter, attributes=attrs_from_self("robot_id") ) def publish_system_stats( self, @@ -1397,7 +1394,7 @@ def publish_system_stats( self.publish_protobuf(MQTT_SUBTOPIC_SYSTEM_STATS, msg) - @with_counter_metric(publish_odometry_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_odometry_counter, attributes=attrs_from_self("robot_id")) def publish_odometry( self, ts_start=None, @@ -1459,7 +1456,7 @@ def publish_odometry( msg.speed_available = True self.publish_protobuf(MQTT_SUBTOPIC_ODOMETRY, msg) - @with_counter_metric(publish_laser_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_laser_counter, attributes=attrs_from_self("robot_id")) def publish_lasers(self, x, y, yaw, ranges, frame_id="map", ts=None): """Publish an array of lasers. @@ -1565,7 +1562,7 @@ def register_lasers(self, configs): retain=True, ) - @with_counter_metric(publish_path_counter, attributes=_counter_attrs_robot_id) + @with_counter_metric(publish_path_counter, attributes=attrs_from_self("robot_id")) def publish_path( self, path_points, path_id="0", frame_id="map", ts=None, rdp_epsilon=0.001 ): diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index 553250a..88b4c80 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -258,3 +258,42 @@ def test_otel_api_available_reflects_import_status(): # available. The flag is the source of truth for callers. assert edge_metrics.OTEL_API_AVAILABLE is True assert edge_metrics.Observation is not None + + +# --- Tests for attrs_from_self ------------------------------------------ + + +def test_attrs_from_self_extracts_named_attributes(): + extract = edge_metrics.attrs_from_self("robot_id", "site") + + class _Stub: + robot_id = "r-7" + site = "lab" + + assert extract(_Stub()) == {"robot_id": "r-7", "site": "lab"} + + +def test_attrs_from_self_used_with_with_counter_metric(): + counter = _RecordingCounter() + + class _Thing: + robot_id = "r-1" + + @edge_metrics.with_counter_metric( + counter, attributes=edge_metrics.attrs_from_self("robot_id") + ) + def do_work(self, _arg): + return _arg + + _Thing().do_work(42) + assert counter.calls == [(1, {"robot_id": "r-1"})] + + +def test_attrs_from_self_raises_when_attribute_missing(): + extract = edge_metrics.attrs_from_self("missing_attr") + + class _Stub: + pass + + with pytest.raises(AttributeError): + extract(_Stub()) From 7a0e93993b7d6d6feb637d5176909c4865aef1f4 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 17:31:21 -0300 Subject: [PATCH 06/17] Bump OTEL + exporter to support prefix --- README.md | 19 +++---- inorbit_edge/metrics.py | 22 ++++---- inorbit_edge/tests/demo/Dockerfile | 2 +- inorbit_edge/tests/demo/README.md | 2 + inorbit_edge/tests/demo/example.py | 28 +++++----- inorbit_edge/tests/test_metrics.py | 87 ++++++++++++++++++++++++++++++ requirements-telemetry.txt | 6 +-- 7 files changed, 125 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index eb806b9..e6167a6 100644 --- a/README.md +++ b/README.md @@ -118,19 +118,16 @@ and `prometheus-client`. The following is an example initialization code that en connector can be scraped and exported to any external system (Grafana, StackDriver, etc.) -``` -from opentelemetry import metrics -from opentelemetry.exporter.prometheus import PrometheusMetricReader -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.resources import Resource +```python +from inorbit_edge.metrics import setup_prometheus_meter_provider from prometheus_client import start_http_server # ... -resource = Resource(attributes={"service.name": "my-connector"}) -# Note: Do not use "-" in the MetricsReader namefor GCP envs -metric_reader = PrometheusMetricReader("my_connector") -meter_provider = MeterProvider(metric_readers=[metric_reader], resource=resource) -metrics.set_meter_provider(meter_provider) -start_http_server(port=9464, addr="0.0.0.0") +if setup_prometheus_meter_provider( + service_name="my-connector", + service_instance_id="robot-123", + service_version="1.2.3", +): + start_http_server(port=9464, addr="0.0.0.0") ``` diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 37f9157..509bb54 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -13,7 +13,7 @@ # from prometheus_client import start_http_server # # setup_prometheus_meter_provider( -# service_name="my_connector", # No '-' for GCP compatibility +# service_name="my-connector", # service_instance_id="robot-123", # service_version="1.2.3", # ) @@ -98,23 +98,23 @@ def setup_prometheus_meter_provider( ): """Install a global OTEL MeterProvider with a Prometheus reader. - OpenTelemetry permits only one provider per process; subsequent calls are - ignored with a warning by the OTEL runtime. + OpenTelemetry permits only one provider per process; subsequent calls may + be ignored with a warning by the OTEL runtime. - Returns True when a provider was built and set. Returns False when the + Returns True when this provider became active. Returns False when the OpenTelemetry / Prometheus exporter dependencies are not installed (in - which case all instrument calls become no-ops). + which case all instrument calls become no-ops), or when OpenTelemetry kept + an existing provider instead. Args: service_name: OTLP ``service.name`` resource attribute. Also used as - the default ``exporter_namespace``. + the default Prometheus metric name prefix. service_instance_id: OTLP ``service.instance.id`` resource attribute. Should be unique per process on a host. service_version: optional OTLP ``service.version``. extra_resource_attributes: optional dict of extra Resource attributes. - exporter_namespace: optional namespace for the - ``PrometheusMetricReader``. Defaults to ``service_name``. Must be - ASCII without hyphens for GCP / Prometheus compatibility. + exporter_namespace: optional Prometheus metric name prefix. Defaults + to ``service_name``. """ if not (OTEL_API_AVAILABLE and PROMETHEUS_EXPORTER_AVAILABLE): return False @@ -129,10 +129,10 @@ def setup_prometheus_meter_provider( attrs.update(extra_resource_attributes) resource = Resource.create(attrs) - reader = PrometheusMetricReader(exporter_namespace or service_name) + reader = PrometheusMetricReader(prefix=exporter_namespace or service_name) provider = _SdkMeterProvider(metric_readers=[reader], resource=resource) _otel_metrics.set_meter_provider(provider) - return True + return _otel_metrics.get_meter_provider() is provider meter = get_meter("inorbit_edge_sdk") diff --git a/inorbit_edge/tests/demo/Dockerfile b/inorbit_edge/tests/demo/Dockerfile index 9049f74..09c5cb5 100644 --- a/inorbit_edge/tests/demo/Dockerfile +++ b/inorbit_edge/tests/demo/Dockerfile @@ -9,7 +9,7 @@ # -e INORBIT_API_KEY=... \ # -e INORBIT_ACCOUNT_ID=... \ # -e INORBIT_USE_SSL=true \ -# -e INORBIT_ROBOT_ID_PREFIX=$(hostname) \ +# -e INORBIT_ROBOT_ID_PREFIX=$(hostname) \ # inorbit-edge-sdk-demo FROM python:3.12-slim-bookworm diff --git a/inorbit_edge/tests/demo/README.md b/inorbit_edge/tests/demo/README.md index 53f46c8..44fe76b 100644 --- a/inorbit_edge/tests/demo/README.md +++ b/inorbit_edge/tests/demo/README.md @@ -17,6 +17,8 @@ cd inorbit_edge/tests/demo export INORBIT_URL="https://control.inorbit.ai" export INORBIT_API_URL="https://api.inorbit.ai" export INORBIT_API_KEY="foobar123" +export INORBIT_ACCOUNT_ID="account123" +export INORBIT_ROBOT_ID_PREFIX="$(hostname)" # TLS to the MQTT broker defaults to on. For a local broker without TLS: INORBIT_USE_SSL=false # Optionally enable video streaming as camera "0" export INORBIT_VIDEO_URL=/dev/video0 diff --git a/inorbit_edge/tests/demo/example.py b/inorbit_edge/tests/demo/example.py index 8419b72..583280a 100644 --- a/inorbit_edge/tests/demo/example.py +++ b/inorbit_edge/tests/demo/example.py @@ -47,6 +47,13 @@ def _mqtt_use_ssl(): return v not in ("false", "0", "no", "off") +def _required_env(name): + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Environment variable {name} is required") + return value + + class FakeRobot: """Class that simulates robot data and generates random data""" @@ -162,11 +169,9 @@ def _init_prometheus_metrics(): service_name = os.environ.get( "INORBIT_METRICS_SERVICE_NAME", "inorbit-edge-sdk-demo" ) - # Note: exporter_namespace must not contain '-' for GCP compatibility. if not setup_prometheus_meter_provider( service_name=service_name, service_instance_id=socket.gethostname(), - exporter_namespace="inorbit_edge_demo", ) or start_http_server is None: logging.warning( "INORBIT_METRICS_PORT=%s set but telemetry packages missing. " @@ -198,23 +203,16 @@ def main(): radius=0.2, ) - inorbit_api_endpoint = os.environ.get("INORBIT_URL") - inorbit_api_url = os.environ.get("INORBIT_API_URL") - inorbit_account_id = os.environ.get("INORBIT_ACCOUNT_ID") - inorbit_api_key = os.environ.get("INORBIT_API_KEY") + inorbit_api_endpoint = _required_env("INORBIT_URL") + inorbit_api_url = _required_env("INORBIT_API_URL") + inorbit_account_id = _required_env("INORBIT_ACCOUNT_ID") + inorbit_api_key = _required_env("INORBIT_API_KEY") # If configured stream video as if it was a robot camera video_url = os.environ.get("INORBIT_VIDEO_URL") - assert inorbit_api_endpoint, "Environment variable INORBIT_URL not specified" - assert inorbit_api_key, "Environment variable INORBIT_API_KEY not specified" - # Required for setting configurations, such as robot footprints. - assert inorbit_api_url, "Environment variable INORBIT_API_URL not specified" - assert inorbit_account_id, "Environment variable INORBIT_ACCOUNT_ID not specified" - - # Robot ids are always "_edgesdk_py_". Prefix is mandatory. - robot_id_prefix = os.environ.get("INORBIT_ROBOT_ID_PREFIX").strip() - assert robot_id_prefix, "Environment variable INORBIT_ROBOT_ID_PREFIX is required" + # Robot ids are always "_edgesdk_demo_". Prefix is mandatory. + robot_id_prefix = _required_env("INORBIT_ROBOT_ID_PREFIX") logging.info("Robot id prefix: %r", robot_id_prefix) # Create robot session factory and session pool diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index 88b4c80..27ea844 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -253,6 +253,93 @@ def test_setup_prometheus_meter_provider_returns_false_when_disabled(monkeypatch assert installed is False +def test_setup_prometheus_meter_provider_uses_service_name_as_prefix(monkeypatch): + captured = {} + + class _Reader: + def __init__(self, *, prefix=""): + captured["prefix"] = prefix + + class _Provider: + def __init__(self, metric_readers, resource): + captured["provider"] = self + self.metric_readers = metric_readers + self.resource = resource + + class _Resource: + @staticmethod + def create(attrs): + return attrs + + monkeypatch.setattr(edge_metrics, "OTEL_API_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PROMETHEUS_EXPORTER_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PrometheusMetricReader", _Reader) + monkeypatch.setattr(edge_metrics, "_SdkMeterProvider", _Provider) + monkeypatch.setattr(edge_metrics, "Resource", _Resource) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "set_meter_provider", + lambda provider: captured.__setitem__("active_provider", provider), + ) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "get_meter_provider", + lambda: captured["active_provider"], + ) + + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="inorbit-connector", + service_instance_id="r-1", + ) + + assert installed is True + assert captured["prefix"] == "inorbit-connector" + + +def test_setup_prometheus_meter_provider_accepts_prefix_override(monkeypatch): + captured = {} + + class _Reader: + def __init__(self, *, prefix=""): + captured["prefix"] = prefix + + class _Provider: + def __init__(self, metric_readers, resource): + captured["provider"] = self + self.metric_readers = metric_readers + self.resource = resource + + class _Resource: + @staticmethod + def create(attrs): + return attrs + + monkeypatch.setattr(edge_metrics, "OTEL_API_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PROMETHEUS_EXPORTER_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PrometheusMetricReader", _Reader) + monkeypatch.setattr(edge_metrics, "_SdkMeterProvider", _Provider) + monkeypatch.setattr(edge_metrics, "Resource", _Resource) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "set_meter_provider", + lambda provider: captured.__setitem__("active_provider", provider), + ) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "get_meter_provider", + lambda: captured["active_provider"], + ) + + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="inorbit-connector", + service_instance_id="r-1", + exporter_namespace="inorbit_connector", + ) + + assert installed is True + assert captured["prefix"] == "inorbit_connector" + + def test_otel_api_available_reflects_import_status(): # In the test environment the telemetry extra is installed, so OTEL is # available. The flag is the source of truth for callers. diff --git a/requirements-telemetry.txt b/requirements-telemetry.txt index cb407fc..1513fa9 100644 --- a/requirements-telemetry.txt +++ b/requirements-telemetry.txt @@ -1,5 +1,5 @@ -opentelemetry-api~=1.39.1 -opentelemetry-sdk~=1.39.1 +opentelemetry-api~=1.41.0 +opentelemetry-sdk~=1.41.0 # 0.x release line; must match the SDK line (see PyPI for each version’s opentelemetry-sdk pin) -opentelemetry-exporter-prometheus~=0.60b0 +opentelemetry-exporter-prometheus~=0.62b0 prometheus-client>=0.20,<1.0 From 9dc2f3c3d04873b165284fd83208b6037f4b8646 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 17:39:06 -0300 Subject: [PATCH 07/17] Add more documentation --- inorbit_edge/metrics.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 509bb54..94d5404 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -19,6 +19,19 @@ # ) # start_http_server(port=9090, addr="0.0.0.0") # +# The helper below wires the pieces as follows: +# +# * OpenTelemetry API (``opentelemetry.metrics``): the stable API used by SDK +# code to create meters and instruments such as counters. +# * OpenTelemetry SDK (``MeterProvider``): the runtime implementation that +# stores metric data and feeds it to configured metric readers/exporters. +# * ``Resource``: metadata attached to all exported metrics, for example +# service name, service instance, and version. +# * ``PrometheusMetricReader``: an OTEL reader that makes collected metric data +# available to the Prometheus client registry when Prometheus scrapes. +# * ``prometheus_client.start_http_server``: not called here; the connector or +# demo starts that HTTP server to expose the registry at ``/metrics``. +# # When the optional ``telemetry`` extra is not installed, all instruments # become no-ops and ``setup_prometheus_meter_provider`` returns False. # @@ -27,6 +40,9 @@ import warnings try: + # OTEL API package: lightweight surface used by the SDK to create meters. + # Importing this alone is enough for no-export metrics, but not enough to + # expose data to Prometheus; that needs the SDK provider and reader below. from opentelemetry import metrics as _otel_metrics from opentelemetry.metrics import Observation @@ -69,6 +85,9 @@ def create_observable_up_down_counter(self, *args, **kwargs): try: + # PrometheusMetricReader bridges OTEL SDK metrics into prometheus-client's + # registry. MeterProvider is the SDK runtime that owns readers. Resource + # carries service metadata exported as Prometheus target_info labels. from opentelemetry.exporter.prometheus import PrometheusMetricReader from opentelemetry.sdk.metrics import MeterProvider as _SdkMeterProvider from opentelemetry.sdk.resources import Resource @@ -81,6 +100,10 @@ def create_observable_up_down_counter(self, *args, **kwargs): def get_meter(name): """Return an OpenTelemetry Meter for ``name``. + A Meter is the factory for instruments (counters, gauges, histograms). SDK + code records through instruments; exporter setup is intentionally separate + so importing the package does not force telemetry dependencies. + When the ``telemetry`` extra is not installed, returns a no-op meter whose instruments accept any call without raising. """ @@ -98,6 +121,17 @@ def setup_prometheus_meter_provider( ): """Install a global OTEL MeterProvider with a Prometheus reader. + This prepares OTEL metric collection but does not open a network port. + Call ``prometheus_client.start_http_server`` after this to serve the + Prometheus scrape endpoint. + + Component roles: + * ``Resource``: service-level labels attached to all metrics. + * ``PrometheusMetricReader``: reads OTEL SDK metric data on scrape and + registers it with prometheus-client. + * ``MeterProvider``: the global OTEL SDK runtime used by meters returned + from ``get_meter``. + OpenTelemetry permits only one provider per process; subsequent calls may be ignored with a warning by the OTEL runtime. @@ -128,13 +162,25 @@ def setup_prometheus_meter_provider( if extra_resource_attributes: attrs.update(extra_resource_attributes) + # Resource attributes are exported as target_info labels. They identify + # which process/service emitted otherwise identical metric names. resource = Resource.create(attrs) + + # The reader translates OTEL metric data into Prometheus metric families. + # ``prefix`` namespaces metric names, e.g. calls_publish_pose_total becomes + # my_connector_calls_publish_pose_total. reader = PrometheusMetricReader(prefix=exporter_namespace or service_name) + + # The provider owns the reader and becomes the implementation behind the + # global OTEL API. Meters created via get_meter() record through it. provider = _SdkMeterProvider(metric_readers=[reader], resource=resource) _otel_metrics.set_meter_provider(provider) return _otel_metrics.get_meter_provider() is provider +# Module-level instruments. If telemetry is installed before this module is +# imported, these are real OTEL counters. Otherwise they are no-op counters so +# callers can use the SDK without installing or configuring OpenTelemetry. meter = get_meter("inorbit_edge_sdk") publish_map_counter = meter.create_counter( From 1f8c1d3bf8a7126b067ce7f1b3aa6cea8348c3b4 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 18:11:22 -0300 Subject: [PATCH 08/17] Add more metrics instructions --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index e6167a6..e1d6246 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,59 @@ if setup_prometheus_meter_provider( ): start_http_server(port=9464, addr="0.0.0.0") ``` + +Custom metrics can use the same meter provider. Define instruments once during +module initialization, then record values where the connector does the work: + +```python +from inorbit_edge.metrics import get_meter + +meter = get_meter("my_connector") +messages_processed_counter = meter.create_counter( + "messages_processed", + unit="1", + description="Number of input messages processed by the connector", +) + + +def process_message(robot_id, message): + # ... connector-specific processing ... + messages_processed_counter.add(1, {"robot_id": robot_id}) +``` + +When exported to Prometheus with `service_name="my-connector"`, this appears as +`my_connector_messages_processed_total` with a `robot_id` label. Without the +`telemetry` extra installed, the same code is safe to run but records no data. + +For call-count metrics, the SDK also provides a decorator. This keeps the +increment close to the function being counted: + +```python +from inorbit_edge.metrics import get_meter, with_counter_metric + +meter = get_meter("my_connector") +command_handler_counter = meter.create_counter( + "command_handler_calls", + unit="1", + description="Number of command handler invocations", +) + + +@with_counter_metric(command_handler_counter, attributes={"command": "dock"}) +def handle_dock_command(command_payload): + # ... handle the command ... + return "accepted" +``` + +If attributes depend on the function arguments, pass a callable instead of a +static dictionary: + +```python +@with_counter_metric( + command_handler_counter, + attributes=lambda robot_id, command_payload: {"robot_id": robot_id}, +) +def handle_command(robot_id, command_payload): + # ... handle the command ... + return "accepted" +``` From 57f7441b7d88257b18c9a33fe915119c18daef79 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Wed, 29 Apr 2026 18:15:21 -0300 Subject: [PATCH 09/17] Linting --- inorbit_edge/robot.py | 10 ++++++++-- inorbit_edge/tests/demo/example.py | 11 +++++++---- inorbit_edge/tests/test_metrics.py | 3 +-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/inorbit_edge/robot.py b/inorbit_edge/robot.py index b6209ee..1ac15fa 100644 --- a/inorbit_edge/robot.py +++ b/inorbit_edge/robot.py @@ -1325,7 +1325,10 @@ def reached_waypoint(self, waypoint: Pose, tolerance: SpatialTolerance): <= tolerance.angularRadians ) - @with_counter_metric(publish_key_values_counter, attributes=attrs_from_self("robot_id")) + @with_counter_metric( + publish_key_values_counter, + attributes=attrs_from_self("robot_id"), + ) def publish_key_values(self, key_values, custom_field="0", is_event=False): """Publish key value pairs @@ -1394,7 +1397,10 @@ def publish_system_stats( self.publish_protobuf(MQTT_SUBTOPIC_SYSTEM_STATS, msg) - @with_counter_metric(publish_odometry_counter, attributes=attrs_from_self("robot_id")) + @with_counter_metric( + publish_odometry_counter, + attributes=attrs_from_self("robot_id"), + ) def publish_odometry( self, ts_start=None, diff --git a/inorbit_edge/tests/demo/example.py b/inorbit_edge/tests/demo/example.py index 583280a..c37b236 100644 --- a/inorbit_edge/tests/demo/example.py +++ b/inorbit_edge/tests/demo/example.py @@ -169,10 +169,13 @@ def _init_prometheus_metrics(): service_name = os.environ.get( "INORBIT_METRICS_SERVICE_NAME", "inorbit-edge-sdk-demo" ) - if not setup_prometheus_meter_provider( - service_name=service_name, - service_instance_id=socket.gethostname(), - ) or start_http_server is None: + if ( + not setup_prometheus_meter_provider( + service_name=service_name, + service_instance_id=socket.gethostname(), + ) + or start_http_server is None + ): logging.warning( "INORBIT_METRICS_PORT=%s set but telemetry packages missing. " "Use: pip install 'inorbit-edge[telemetry]'", diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index 27ea844..fd7f27a 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -6,6 +6,7 @@ import pytest +from opentelemetry.metrics import _internal as _otel_internal from inorbit_edge import metrics as edge_metrics @@ -198,8 +199,6 @@ def test_publish_methods_all_add_robot_id( # --- Tests for the public Prometheus-setup helpers ------------------------ -from opentelemetry.metrics import _internal as _otel_internal - @pytest.fixture(autouse=False) def reset_meter_provider(): From 23401ee9e0c7724e3c1f6939b32d55a287dbcb96 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:10:36 -0300 Subject: [PATCH 10/17] Add comments for Observable instruments --- inorbit_edge/metrics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 94d5404..56db87a 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -44,12 +44,13 @@ # Importing this alone is enough for no-export metrics, but not enough to # expose data to Prometheus; that needs the SDK provider and reader below. from opentelemetry import metrics as _otel_metrics - from opentelemetry.metrics import Observation + # Re-exported for connectors that define observable instruments. + from opentelemetry.metrics import Observation # noqa: F401 OTEL_API_AVAILABLE = True except ImportError: # pragma: no cover - exercised when telemetry extra is missing OTEL_API_AVAILABLE = False - Observation = None # type: ignore[assignment] + Observation = None # type: ignore[assignment] # noqa: F401 class _NoOpInstrument: def add(self, amount, attributes=None): From 0b17c0d7b1bb8517cfb463bb45873d065a4d73f0 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:16:09 -0300 Subject: [PATCH 11/17] Fix noop params --- inorbit_edge/metrics.py | 67 ++++++++++++++++-------------- inorbit_edge/tests/test_metrics.py | 15 +++++++ 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 56db87a..543cf75 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -39,6 +39,41 @@ import inspect import warnings + +class _NoOpInstrument: + def add(self, *_args, **_kwargs): + pass + + def record(self, *_args, **_kwargs): + pass + + def set(self, *_args, **_kwargs): + pass + + +class _NoOpMeter: + def create_counter(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_up_down_counter(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_histogram(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_gauge(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_observable_gauge(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_observable_counter(self, *_args, **_kwargs): + return _NoOpInstrument() + + def create_observable_up_down_counter(self, *_args, **_kwargs): + return _NoOpInstrument() + + try: # OTEL API package: lightweight surface used by the SDK to create meters. # Importing this alone is enough for no-export metrics, but not enough to @@ -52,38 +87,6 @@ OTEL_API_AVAILABLE = False Observation = None # type: ignore[assignment] # noqa: F401 - class _NoOpInstrument: - def add(self, amount, attributes=None): - pass - - def record(self, value, attributes=None): - pass - - def set(self, value, attributes=None): - pass - - class _NoOpMeter: - def create_counter(self, *args, **kwargs): - return _NoOpInstrument() - - def create_up_down_counter(self, *args, **kwargs): - return _NoOpInstrument() - - def create_histogram(self, *args, **kwargs): - return _NoOpInstrument() - - def create_gauge(self, *args, **kwargs): - return _NoOpInstrument() - - def create_observable_gauge(self, *args, **kwargs): - return _NoOpInstrument() - - def create_observable_counter(self, *args, **kwargs): - return _NoOpInstrument() - - def create_observable_up_down_counter(self, *args, **kwargs): - return _NoOpInstrument() - try: # PrometheusMetricReader bridges OTEL SDK metrics into prometheus-client's diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index fd7f27a..d6e5e89 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -252,6 +252,21 @@ def test_setup_prometheus_meter_provider_returns_false_when_disabled(monkeypatch assert installed is False +def test_noop_instruments_accept_otel_kwargs(monkeypatch): + monkeypatch.setattr(edge_metrics, "OTEL_API_AVAILABLE", False) + meter = edge_metrics.get_meter("inorbit_test") + context = object() + + counter = meter.create_counter("counter") + counter.add(1, attributes={"robot_id": "r-1"}, context=context, extra=True) + + histogram = meter.create_histogram("histogram") + histogram.record(1.5, attributes={"robot_id": "r-1"}, context=context) + + gauge = meter.create_gauge("gauge") + gauge.set(2, attributes={"robot_id": "r-1"}, context=context) + + def test_setup_prometheus_meter_provider_uses_service_name_as_prefix(monkeypatch): captured = {} From 73f5d747cc6eff5fb5fa434b6294744c1cfa0a8c Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:45:06 -0300 Subject: [PATCH 12/17] Sanitize default prom prefix --- inorbit_edge/metrics.py | 15 ++++++++- inorbit_edge/tests/test_metrics.py | 49 ++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 543cf75..919e20b 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -37,6 +37,7 @@ # import functools import inspect +import re import warnings @@ -116,6 +117,14 @@ def get_meter(name): return _NoOpMeter() +def _sanitize_prometheus_prefix(prefix): + """Return a Prometheus-safe metric name prefix.""" + sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", prefix) + if sanitized and sanitized[0].isdigit(): + return f"_{sanitized}" + return sanitized + + def setup_prometheus_meter_provider( service_name, service_instance_id, @@ -153,6 +162,9 @@ def setup_prometheus_meter_provider( extra_resource_attributes: optional dict of extra Resource attributes. exporter_namespace: optional Prometheus metric name prefix. Defaults to ``service_name``. + + The final Prometheus prefix is sanitized by replacing Prometheus-unsafe + characters with ``_``. """ if not (OTEL_API_AVAILABLE and PROMETHEUS_EXPORTER_AVAILABLE): return False @@ -173,7 +185,8 @@ def setup_prometheus_meter_provider( # The reader translates OTEL metric data into Prometheus metric families. # ``prefix`` namespaces metric names, e.g. calls_publish_pose_total becomes # my_connector_calls_publish_pose_total. - reader = PrometheusMetricReader(prefix=exporter_namespace or service_name) + prefix = _sanitize_prometheus_prefix(exporter_namespace or service_name) + reader = PrometheusMetricReader(prefix=prefix) # The provider owns the reader and becomes the implementation behind the # global OTEL API. Meters created via get_meter() record through it. diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index d6e5e89..e4b529d 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -307,7 +307,50 @@ def create(attrs): ) assert installed is True - assert captured["prefix"] == "inorbit-connector" + assert captured["prefix"] == "inorbit_connector" + + +def test_setup_prometheus_meter_provider_sanitizes_numeric_prefix(monkeypatch): + captured = {} + + class _Reader: + def __init__(self, *, prefix=""): + captured["prefix"] = prefix + + class _Provider: + def __init__(self, metric_readers, resource): + captured["provider"] = self + self.metric_readers = metric_readers + self.resource = resource + + class _Resource: + @staticmethod + def create(attrs): + return attrs + + monkeypatch.setattr(edge_metrics, "OTEL_API_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PROMETHEUS_EXPORTER_AVAILABLE", True) + monkeypatch.setattr(edge_metrics, "PrometheusMetricReader", _Reader) + monkeypatch.setattr(edge_metrics, "_SdkMeterProvider", _Provider) + monkeypatch.setattr(edge_metrics, "Resource", _Resource) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "set_meter_provider", + lambda provider: captured.__setitem__("active_provider", provider), + ) + monkeypatch.setattr( + edge_metrics._otel_metrics, + "get_meter_provider", + lambda: captured["active_provider"], + ) + + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="123-connector", + service_instance_id="r-1", + ) + + assert installed is True + assert captured["prefix"] == "_123_connector" def test_setup_prometheus_meter_provider_accepts_prefix_override(monkeypatch): @@ -347,11 +390,11 @@ def create(attrs): installed = edge_metrics.setup_prometheus_meter_provider( service_name="inorbit-connector", service_instance_id="r-1", - exporter_namespace="inorbit_connector", + exporter_namespace="custom-namespace", ) assert installed is True - assert captured["prefix"] == "inorbit_connector" + assert captured["prefix"] == "custom_namespace" def test_otel_api_available_reflects_import_status(): From e0ca9a3f221f90f04564bd1aa38a7bdae8141895 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:46:33 -0300 Subject: [PATCH 13/17] Fix Readme --- inorbit_edge/tests/demo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inorbit_edge/tests/demo/README.md b/inorbit_edge/tests/demo/README.md index 44fe76b..0431355 100644 --- a/inorbit_edge/tests/demo/README.md +++ b/inorbit_edge/tests/demo/README.md @@ -32,7 +32,7 @@ python example.py Robot ids are always `_edgesdk_demo_0`, `_edgesdk_demo_1`, … The prefix is `INORBIT_ROBOT_ID_PREFIX` and is mandatory. -With `INORBIT_METRICS_PORT` set, the demo configures OpenTelemetry before importing the SDK, then serves metrics at +With `INORBIT_METRICS_PORT` set, the demo configures OpenTelemetry early in startup, then serves metrics at `http://$INORBIT_METRICS_ADDR:$INORBIT_METRICS_PORT/metrics` (e.g. curl from the host if the port is published). ## Run in a throwaway Docker container From 8520d95579e40f2bda454097e8a9ce180e5a1ceb Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:49:26 -0300 Subject: [PATCH 14/17] Revert version bump --- .bumpversion.cfg | 2 +- inorbit_edge/__init__.py | 2 +- setup.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e60a5a0..c4cf95f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = true -current_version = 2.1.0 +current_version = 2.0.2 tag = true [bumpversion:file(version):setup.py] diff --git a/inorbit_edge/__init__.py b/inorbit_edge/__init__.py index bc808c1..0318055 100644 --- a/inorbit_edge/__init__.py +++ b/inorbit_edge/__init__.py @@ -6,7 +6,7 @@ __email__ = "support@inorbit.ai" # Do not edit this string manually, always use bumpversion # Details in CONTRIBUTING.md -__version__ = "2.1.0" +__version__ = "2.0.2" def get_module_version(): diff --git a/setup.py b/setup.py index 03d804d..e1572fe 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup # Do not edit manually, always use bumpversion (see CONTRIBUTING.rst) -VERSION = "2.1.0" +VERSION = "2.0.2" GITHUB_ORG = "https://github.com/inorbit-ai" GITHUB_REPO = f"{GITHUB_ORG}/edge-sdk-python" @@ -48,7 +48,7 @@ ], description="InOrbit Edge SDK for Python", # Do not edit manually, always use bumpversion (see CONTRIBUTING.rst) - download_url=f"{GITHUB_REPO}/archive/refs/tags/v2.1.0.zip", + download_url=f"{GITHUB_REPO}/archive/refs/tags/v2.0.2.zip", extras_require={ "video": requirements["video"], "telemetry": requirements["telemetry"], From ec5968c6fdd4cf7062378528c393cb090f9cca41 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:53:21 -0300 Subject: [PATCH 15/17] Add log when telemetry deps are missing --- inorbit_edge/metrics.py | 8 ++++++++ inorbit_edge/tests/test_metrics.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index 919e20b..dc51570 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -37,9 +37,12 @@ # import functools import inspect +import logging import re import warnings +logger = logging.getLogger(__name__) + class _NoOpInstrument: def add(self, *_args, **_kwargs): @@ -167,6 +170,11 @@ def setup_prometheus_meter_provider( characters with ``_``. """ if not (OTEL_API_AVAILABLE and PROMETHEUS_EXPORTER_AVAILABLE): + logger.info( + "Prometheus metrics provider not configured because telemetry " + "dependencies are missing. Install the 'telemetry' extra to " + "enable metrics export." + ) return False attrs = { diff --git a/inorbit_edge/tests/test_metrics.py b/inorbit_edge/tests/test_metrics.py index e4b529d..8f85d8a 100644 --- a/inorbit_edge/tests/test_metrics.py +++ b/inorbit_edge/tests/test_metrics.py @@ -244,12 +244,16 @@ def test_setup_prometheus_meter_provider_installs_resource(reset_meter_provider) assert attrs["site"] == "lab" -def test_setup_prometheus_meter_provider_returns_false_when_disabled(monkeypatch): +def test_setup_prometheus_meter_provider_returns_false_when_disabled( + monkeypatch, caplog +): monkeypatch.setattr(edge_metrics, "PROMETHEUS_EXPORTER_AVAILABLE", False) - installed = edge_metrics.setup_prometheus_meter_provider( - service_name="x", service_instance_id="y" - ) + with caplog.at_level("INFO", logger=edge_metrics.__name__): + installed = edge_metrics.setup_prometheus_meter_provider( + service_name="x", service_instance_id="y" + ) assert installed is False + assert "telemetry dependencies are missing" in caplog.text def test_noop_instruments_accept_otel_kwargs(monkeypatch): From 553af0c4f35c3c28d9f823d9b1b774232a531ee5 Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:56:17 -0300 Subject: [PATCH 16/17] Use deprecated decorator --- inorbit_edge/metrics.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index dc51570..c325a94 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -39,7 +39,8 @@ import inspect import logging import re -import warnings + +from deprecated import deprecated logger = logging.getLogger(__name__) @@ -302,6 +303,12 @@ def sync_wrapper(*args, **kwargs): return decorator +@deprecated( + version="2.0.2", + reason=( + "use with_counter_metric(), which now auto-detects async functions" + ), +) def with_counter_metric_async(metric): """Deprecated alias for :func:`with_counter_metric`. @@ -309,10 +316,4 @@ def with_counter_metric_async(metric): automatically. """ - warnings.warn( - "with_counter_metric_async is deprecated; use with_counter_metric " - "which now auto-detects async functions.", - DeprecationWarning, - stacklevel=2, - ) return with_counter_metric(metric) From 0ad563f0cc78193c64445a3b8d8cc7015da5c84d Mon Sep 17 00:00:00 2001 From: Leandro Pineda Date: Thu, 30 Apr 2026 12:57:28 -0300 Subject: [PATCH 17/17] Linting --- inorbit_edge/metrics.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/inorbit_edge/metrics.py b/inorbit_edge/metrics.py index c325a94..3e33c77 100644 --- a/inorbit_edge/metrics.py +++ b/inorbit_edge/metrics.py @@ -84,6 +84,7 @@ def create_observable_up_down_counter(self, *_args, **_kwargs): # Importing this alone is enough for no-export metrics, but not enough to # expose data to Prometheus; that needs the SDK provider and reader below. from opentelemetry import metrics as _otel_metrics + # Re-exported for connectors that define observable instruments. from opentelemetry.metrics import Observation # noqa: F401 @@ -305,9 +306,7 @@ def sync_wrapper(*args, **kwargs): @deprecated( version="2.0.2", - reason=( - "use with_counter_metric(), which now auto-detects async functions" - ), + reason=("use with_counter_metric(), which now auto-detects async functions"), ) def with_counter_metric_async(metric): """Deprecated alias for :func:`with_counter_metric`.