Telemetry
xronos provides several mechanisms for collecting telemetry data to gain insight into an application.
Enabling Telemetry
An application needs to explicitly enable the collection of telemetry data by
calling enable_telemetry()
on the environment.
By default, this records trace data for each reaction handler that executes.
For instance, you can modify the Hello World example like shown below.
def main():
env = xronos.Environment()
env.create_reactor("hello", Hello)
env.enable_telemetry(application_name="hello")
env.execute()
Then execute the program.
$ python hello.py
Hello, World!
[Error] File: /xronos/xronos-telemetry/build/_deps/opentelemetry-cpp-src/exporters/otlp/src/otlp_grpc_exporter.cc:114 [OTLP TRACE GRPC Exporter] Export() failed with status_code: "UNAVAILABLE" error_message: "failed to connect to all addresses; last error: UNKNOWN: ipv4:127.0.0.1:4317: Failed to connect to remote host: connect: Connection refused (111)"
This prints an error because we ran the program with telemetry enabled, but did not provide an endpoint for receiving the telemetry data. See Dashboard for instructions on how to start and use the dashboard for receiving and visualizing telemetry data.
Attributes
All elements within the xronos framework may be annotated with attributes. The attributes provide a mechanism for labeling the recorded data and provide a mechanism for filtering the telemetry data in the dashboard. By default, the following attributes are recorded:
host.name
: Name of the host executing the xronos program.process.pid
: The process ID of the running xronos program.service.name
: Name of the application as given provided in the call toenable_telemetry()
.xronos.fqn
: Fully qualified name of the element that produces telemetry data.xronos.name
: Name of the element that produces telemetry data.xronos.element_type
: Type of the element that produces telemetry data (e.g., “reactor”, “reaction”, “metric”)xronos.container_fqn
: Fully qualified name of the reactor that contains the element that produces telemetry data.
This set of attributes is sufficient to uniquely identify the origin of each
datapoint. However, you may specify additional attributes that will help you
to better identify the data you are looking for. For this, each element
has an add_attribute()
and
add_attributes()
method.
Each element inherits all attributes of the reactor that it is contained in. Consider the following simplified example program. It shows the structure of a control program for a pick-and-place robot that uses two delta arms, where each arm consists of 3 motors.
import xronos
class Motor(xronos.Reactor):
angle = xronos.InputPortDeclaration()
def __init__(self):
super().__init__()
self.add_attribute("motor", self.name)
class DeltaArm(xronos.Reactor):
pos = xronos.InputPortDeclaration()
def __init__(self):
super().__init__()
self.add_attribute("arm", self.name)
self.create_reactor("A", Motor)
self.create_reactor("B", Motor)
self.create_reactor("C", Motor)
class PickAndPlaceRobot(xronos.Reactor):
def __init__(self):
super().__init__()
self.create_reactor("Arm1", DeltaArm)
self.create_reactor("Arm2", DeltaArm)
def main():
env = xronos.Environment()
pick_and_place = env.create_reactor("pick_and_place", PickAndPlaceRobot)
pick_and_place.add_attribute("location", "factory1")
env.execute()
if __name__ == "__main__":
main()
import xronos
class Motor(xronos.Reactor):
angle = xronos.InputPortDeclaration[float]()
def __init__(self) -> None:
super().__init__()
self.add_attribute("motor", self.name)
class DeltaArm(xronos.Reactor):
pos = xronos.InputPortDeclaration[tuple[float, float]]()
def __init__(self) -> None:
super().__init__()
self.add_attribute("arm", self.name)
self.create_reactor("A", Motor)
self.create_reactor("B", Motor)
self.create_reactor("C", Motor)
class PickAndPlaceRobot(xronos.Reactor):
def __init__(self) -> None:
super().__init__()
self.create_reactor("Arm1", DeltaArm)
self.create_reactor("Arm2", DeltaArm)
def main() -> None:
env = xronos.Environment()
pick_and_place = env.create_reactor("pick_and_place", PickAndPlaceRobot)
pick_and_place.add_attribute("location", "factory1")
env.execute()
if __name__ == "__main__":
main()
The pick_and_place
reactor has an attribute “location” that can be used to
identify the location that the robot operates in. Here it is set to “factory1”.
This attribute will also be added to any reactor and element contained within
pick_and_place
. The DeltaArm
reactor additionally sets the “arm” attribute
to its reactor name. Finally, the Motor
reactor sets the “motor”
attribute to its reactor name. Consequently, the reactor
pick_and_place.Arm1.B
has the following attributes:
“motor: “A”
“arm”: “Arm1”
“location”: “factory1”
This makes it convenient to filter telemetry data. For instance, it lets us show all data matching a specific location or show data relating to a specific motor, independent of the concrete arm and location. See Visualizing Metrics for instructions on how to filter telemetry data.
Note that the same attribute may not be added twice for the same element. Once an attribute is added, its value cannot be changed. However, it is possible for contained elements to overwrite attributes that are defined higher in the reactor hierarchy.
Metrics
Metrics provide a convenient mechanism for recording information about the
system’s state. Metrics are declared and used similarly to all other reactor
elements. A MetricDeclaration
can be used to declare a metric
as part of a reactor. Consider the following example, which extends the Motor
reactor from above so that it has a metric _current
, which is used to record
the current measured on the motor.
class Motor(xronos.Reactor):
angle = xronos.InputPortDeclaration()
_timer = xronos.PeriodicTimerDeclaration(
period=datetime.timedelta(microseconds=500)
)
_current = xronos.MetricDeclaration(description="Motor current", unit="mA")
def __init__(self):
super().__init__()
self.add_attribute("motor", self.name)
@xronos.reaction
def control(self, interface):
interface.add_trigger(self._timer)
current_effect = interface.add_effect(self._current)
def handler():
current_effect.record(read_current())
# do control
return handler
class Motor(xronos.Reactor):
angle = xronos.InputPortDeclaration[float]()
_timer = xronos.PeriodicTimerDeclaration(
period=datetime.timedelta(microseconds=500)
)
_current = xronos.MetricDeclaration(description="Motor current", unit="mA")
def __init__(self) -> None:
super().__init__()
self.add_attribute("motor", self.name)
@xronos.reaction
def control(self, interface: xronos.ReactionInterface) -> Callable[[], None]:
interface.add_trigger(self._timer)
current_effect = interface.add_effect(self._current)
def handler() -> None:
current_effect.record(read_current())
# do control
return handler
When declaring a metric, a description needs to be provided and optionally a unit
can be set. It is also possible to provide additional attributes via
MetricDeclaration
or by using
add_attribute()
or add_attributes()
on
the metric.
In the example above, the control
reaction executes every 500 microseconds. It
reads and records the measured current using
record()
. The control algorithm is omitted from the
example.
See Visualizing Metrics for instruction on how to visualize data recorded from metrics.