Metrics¶
The framework ships an OpenTelemetry-based metrics subsystem that connectors can expose over a Prometheus HTTP endpoint. The defaults aim at a single use case: knowing when something breaks, suitable for alerting. Concrete connectors add their own domain metrics on top using the same OpenTelemetry primitives the inorbit-edge SDK uses internally.
What you get out of the box¶
When metrics.enabled = true in your connector configuration, the framework starts a Prometheus HTTP server and exposes:
Every framework metric uses the constant inorbit_connector wire prefix.
The connector type is NOT part of the metric name — it rides on every
series as the inorbit_connector_type Prometheus label (sourced from the
inorbit.connector.type OpenTelemetry Resource attribute). One descriptor
per metric covers every connector type; cross-type aggregation works
without metric-name fan-out.
Metric |
Type |
Attributes |
Meaning |
|---|---|---|---|
|
Gauge |
— |
1 while the connector’s main thread is alive |
|
Gauge |
|
1 when the per-robot MQTT session to InOrbit is connected. Catches the “process running but robot offline” failure mode where MQTT drops and reconnect fails |
|
Counter |
— |
Successful iterations of |
|
Counter |
— |
Exceptions caught in the run loop |
Plus the canonical upstream-HTTP family (recorded whenever you call the
helpers in inorbit_connector.metrics.http) and the per-robot publish
counters that come from the SDK:
Metric |
Attributes |
Meaning |
|---|---|---|
|
|
Successful upstream HTTP calls |
|
|
Failed upstream HTTP calls |
|
|
Latency histogram of upstream calls (both paths) |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
|
|
Calls to |
Every series also carries the inorbit_connector_id label (one value per process, sourced from service.instance.id), so two connectors on the same host never collide on (metric, labels) even though they share the wire prefix.
These signals are usually enough for an MVP alerting setup:
# Process is dead or scrape failing — covers every connector type
inorbit_connector_up == 0
# Process is up but its MQTT link to InOrbit is down (robot appears offline)
inorbit_connector_session_connected == 0
# Process is up but not progressing — slice by connector type if needed
rate(inorbit_connector_execution_loop_ticks_total[5m]) == 0
# Process is up but erroring
rate(inorbit_connector_execution_loop_errors_total[5m]) > 0
# Same query, scoped to one connector type
rate(inorbit_connector_execution_loop_errors_total{inorbit_connector_type="acme"}[5m]) > 0
Enabling metrics¶
Add a metrics: block to your connector configuration:
metrics:
enabled: true
bind_host: 127.0.0.1 # bind interface; use 0.0.0.0 for bridge networking
bind_port: 9090 # 0 picks an ephemeral free port
connector_id: my-bot # unique per process on a host
discovery_dir: /var/run/inorbit-metrics # for OTel collector file_sd
When enabled, the connector also writes a Prometheus file_sd-format JSON file to discovery_dir, naming the bound host:port. A host-side OTel collector can mount this directory and discover every connector running on the host — see examples/metrics/ for a reference compose stack.
If your scraper is configured statically (e.g. its prometheus.yaml already lists host:port targets, or you only run a single connector behind a known address), set discovery_dir: null to skip writing the discovery file entirely. The HTTP endpoint still serves /metrics as usual.
When enabled is false (the default), no server is started and all instruments become no-ops with zero overhead.
Configuration reference¶
Field |
Default |
Notes |
|---|---|---|
|
|
Master switch. When false, the rest of the block is ignored. |
|
|
Address the HTTP server binds to. |
|
|
TCP port. Use |
|
|
Hostname written to the discovery file. |
|
|
Auto-created on start. Set to |
|
|
Used as |
|
|
Added to every metric as OTel Resource attributes (low-cardinality only). |
The wire-level metric prefix is always inorbit_connector. The connector type rides on every metric as the inorbit.connector.type Resource attribute (a Prometheus label), not as part of the metric name — cross-connector aggregation works on a single descriptor per metric.
Adding metrics to your connector¶
This section covers domain metrics — counters and histograms for vendor-specific business state (mission outcomes, command results, queue depths, device state transitions). For outbound HTTP calls to your upstream API, use the canonical helpers instead: record_upstream_http_request() / record_upstream_http_error() from inorbit_connector.metrics.http. They own the request/error/duration descriptors and the endpoint-cardinality normalizers (EndpointMapper, PathTemplater) — don’t reimplement them as domain counters.
Step 1 — Declare a meter and instruments¶
# my_connector/metrics.py
from inorbit_connector.metrics import get_connector_meter
meter = get_connector_meter("acme") # match your connector_type
mission_failures = meter.create_counter(
"mission.failures",
unit="1",
description="Missions that ended in failure (attribute: reason)",
)
command_executions = meter.create_counter(
"command.executions",
unit="1",
description="Vendor commands executed (attribute: command_name, result)",
)
Module-level declaration, same pattern the SDK uses for its own counters. get_connector_meter wraps an OTel Meter so every instrument name is automatically prefixed with <connector_type>. — mission.failures above is created on the underlying meter as acme.mission.failures and exports on the wire as inorbit_connector_acme_mission_failures_total.
Naming rule: don’t repeat the connector type in instrument names¶
The wrapper adds the prefix structurally; doing it again duplicates it on the wire.
✅
meter.create_counter("mission.failures", ...)→inorbit_connector_acme_mission_failures_total❌
meter.create_counter("acme.mission.failures", ...)→inorbit_connector_acme_acme_mission_failures_total
A double-prefixed wire name can only be cleaned up by a collector-side metric_relabel_configs rule that rewrites __name__. Those rewrites strip the Prometheus # TYPE line, so the metric arrives at the OTel pipeline as UNKNOWN and is exported as a Gauge regardless of its real type. Downstream metric stores that pin descriptor kind on first write (GCP Cloud Monitoring, for example) then silently drop later writes of the correct type.
Step 2 — Instrument call sites¶
Two patterns; pick whichever fits the site:
Inline (the common case — record on the path that actually produces the event)
async def _handle_mission(self, mission):
try:
await self._executor.run(mission)
except MissionError as exc:
mission_failures.add(1, {"reason": exc.category})
raise
reason is a bounded enum — drawn from a known set (timeout, aborted, precondition_failed, …). Don’t pass the raw exception message; it explodes the descriptor’s label space.
Decorator (counts every call to a method, regardless of outcome)
from inorbit_edge.metrics import with_counter_metric, attrs_from_self
class MissionExecutor:
def __init__(self, robot_id):
self.robot_id = robot_id
@with_counter_metric(command_executions, attributes=attrs_from_self("robot_id"))
async def run(self, command):
...
with_counter_metric works on sync and async methods. attributes may be a static dict or a callable that returns one. Use attrs_from_self("robot_id") to forward instance attributes onto each recorded sample.
When to use which scope¶
The single decision that drives metric design is: how many upstream entities does one connector process talk to?
N=1 (single-robot connector, single-PLC connector, etc.):
service.instance.idalready identifies the process. Don’t add arobot_id/device_idattribute on per-call metrics — it would duplicate the Resource attribute that the OTel collector already attaches.N>1 (
FleetConnectorfor a fleet manager API, gateway controlling many doors, etc.): add the entity id as a per-call attribute. Useattrs_from_self("robot_id")for instance-bound calls; pass it explicitly to.add()/.record()for ad-hoc sites.
For non-robot connectors, name the attribute after the domain entity: device_id, plc_id, door_id, elevator_id. Same pattern, different label name.
Cardinality guardrails¶
OTel attributes become Prometheus labels. Each unique label-value combination is a separate time series, and series count is the dominant cost driver for both Prometheus and managed services like GCP Cloud Monitoring. Use bounded enums; never put unbounded values in attributes.
Attribute |
Examples (good) |
Examples (bad) |
|---|---|---|
|
|
exception messages |
|
|
free-form server-returned phrase |
|
|
dynamic command strings from upstream |
|
|
|
Forbidden in attributes: full URLs containing IDs, exception messages, query strings, free-form user input, anything sourced from the upstream API without classification.
If you need to mask out an ID-like segment from a value before recording, do it in the connector before the .add() call. For HTTP endpoint labels, use EndpointMapper / PathTemplater from inorbit_connector.metrics.http.
Observable instruments¶
For state derived from connector internals (battery level, broker connected, queue depth), prefer create_observable_gauge with a callback that reads state at scrape time:
from inorbit_edge.metrics import Observation
from inorbit_connector.metrics import get_connector_meter
meter = get_connector_meter("acme")
class DeviceClient:
def __init__(self):
self._connected = False
meter.create_observable_gauge(
"broker.connected",
callbacks=[self._connected_cb],
unit="1",
description="1 when the connector is connected to the broker",
)
def _connected_cb(self, _options):
return [Observation(1 if self._connected else 0)]
The callback runs on every scrape, so it should be cheap and side-effect free.
Production deployment¶
For multi-container deployments, see examples/metrics/ for a reference OTel collector compose stack that:
Discovers all connector containers on a host via Prometheus
file_sd.Exports to GCP Cloud Monitoring (other backends straightforward to swap in).
Works with both bridge and host Docker networking modes.