setaur 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setaur/__init__.py +294 -0
- setaur/_client.py +294 -0
- setaur/_envelope.py +67 -0
- setaur/_span.py +255 -0
- setaur/_span_context.py +13 -0
- setaur/_types.py +25 -0
- setaur/py.typed +0 -0
- setaur-0.1.0.dist-info/METADATA +373 -0
- setaur-0.1.0.dist-info/RECORD +11 -0
- setaur-0.1.0.dist-info/WHEEL +4 -0
- setaur-0.1.0.dist-info/licenses/LICENSE +201 -0
setaur/__init__.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from ._types import SourceType, EventSourceType, EventSeverity, SpanKind
|
|
3
|
+
from ._client import init, get_client, shutdown, flush
|
|
4
|
+
from ._span import Span, Tracer, get_active_span
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def sensor(
|
|
8
|
+
source_id: str,
|
|
9
|
+
source_type: SourceType,
|
|
10
|
+
timestamp_ns: int,
|
|
11
|
+
data: dict,
|
|
12
|
+
) -> int:
|
|
13
|
+
"""Publish a sensor reading.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
source_id: Unique identifier for the sensor (e.g. ``"lidar_front"``).
|
|
17
|
+
source_type: Category of the sensor (``SourceType.SENSOR``, ``STATE_MACHINE``, etc.).
|
|
18
|
+
timestamp_ns: Acquisition timestamp in nanoseconds (use ``time.time_ns()``).
|
|
19
|
+
data: Sensor payload as a dict. Must be CBOR-serializable.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Monotonically increasing sequence number for this source, useful for
|
|
23
|
+
detecting dropped messages on the receiving side.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
TypeError: If ``data`` contains a type that cannot be CBOR-encoded.
|
|
27
|
+
RuntimeError: If ``setaur.init()`` has not been called.
|
|
28
|
+
"""
|
|
29
|
+
return get_client().sensor(source_id, source_type, timestamp_ns, data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def event(
|
|
33
|
+
source_id: str,
|
|
34
|
+
event_type: str,
|
|
35
|
+
message: str,
|
|
36
|
+
severity: EventSeverity,
|
|
37
|
+
*,
|
|
38
|
+
start_ns: int | None = None,
|
|
39
|
+
end_ns: int = 0,
|
|
40
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
41
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
42
|
+
trace_id: str | None = None,
|
|
43
|
+
span_id: str | None = None,
|
|
44
|
+
parent_id: str | None = None,
|
|
45
|
+
attrs: dict[str, Any] | None = None,
|
|
46
|
+
data: Any = None,
|
|
47
|
+
) -> int:
|
|
48
|
+
"""Publish a single event.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
source_id: Component emitting the event (e.g. ``"navigation_controller"``).
|
|
52
|
+
event_type: Machine-readable event class (e.g. ``"state_transition"``).
|
|
53
|
+
message: Human-readable description of what happened.
|
|
54
|
+
severity: Importance level — ``INFO``, ``WARNING``, ``ERROR``, or ``CRITICAL``.
|
|
55
|
+
start_ns: Event timestamp in nanoseconds. Defaults to ``time.time_ns()`` when omitted.
|
|
56
|
+
end_ns: End timestamp for a span event. ``0`` means instantaneous (point-in-time).
|
|
57
|
+
source_type: Origin of the event. Defaults to ``EventSourceType.USER``.
|
|
58
|
+
kind: Operation kind hint for the visualizer (``SENSOR``, ``ACTUATOR``, etc.).
|
|
59
|
+
trace_id: 32-char hex string grouping related spans into one operation.
|
|
60
|
+
span_id: 16-char hex string uniquely identifying this span.
|
|
61
|
+
parent_id: ``span_id`` of the parent span; ``None`` means root.
|
|
62
|
+
attrs: Typed key-value metadata (e.g. ``{"motor_id": "m2", "current_amps": 12.5}``).
|
|
63
|
+
data: Arbitrary CBOR-serializable payload for large or unstructured data.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Monotonically increasing sequence number for this source.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
TypeError: If ``data`` or ``attrs`` contain a type that cannot be CBOR-encoded.
|
|
70
|
+
RuntimeError: If ``setaur.init()`` has not been called.
|
|
71
|
+
"""
|
|
72
|
+
return get_client().event(
|
|
73
|
+
source_id, event_type, message, severity,
|
|
74
|
+
source_type=source_type, start_ns=start_ns, end_ns=end_ns, kind=kind,
|
|
75
|
+
trace_id=trace_id, span_id=span_id, parent_id=parent_id,
|
|
76
|
+
attrs=attrs, data=data,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def info(
|
|
81
|
+
source_id: str,
|
|
82
|
+
event_type: str,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
start_ns: int | None = None,
|
|
86
|
+
end_ns: int = 0,
|
|
87
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
88
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
89
|
+
trace_id: str | None = None,
|
|
90
|
+
span_id: str | None = None,
|
|
91
|
+
parent_id: str | None = None,
|
|
92
|
+
attrs: dict[str, Any] | None = None,
|
|
93
|
+
data: Any = None,
|
|
94
|
+
) -> int:
|
|
95
|
+
"""Publish an INFO-severity event.
|
|
96
|
+
|
|
97
|
+
Shorthand for ``event(..., severity=EventSeverity.INFO)``.
|
|
98
|
+
See :func:`event` for full parameter documentation.
|
|
99
|
+
"""
|
|
100
|
+
return event(source_id, event_type, message, EventSeverity.INFO,
|
|
101
|
+
start_ns=start_ns, end_ns=end_ns, source_type=source_type, kind=kind,
|
|
102
|
+
trace_id=trace_id, span_id=span_id, parent_id=parent_id,
|
|
103
|
+
attrs=attrs, data=data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def warning(
|
|
107
|
+
source_id: str,
|
|
108
|
+
event_type: str,
|
|
109
|
+
message: str,
|
|
110
|
+
*,
|
|
111
|
+
start_ns: int | None = None,
|
|
112
|
+
end_ns: int = 0,
|
|
113
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
114
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
115
|
+
trace_id: str | None = None,
|
|
116
|
+
span_id: str | None = None,
|
|
117
|
+
parent_id: str | None = None,
|
|
118
|
+
attrs: dict[str, Any] | None = None,
|
|
119
|
+
data: Any = None,
|
|
120
|
+
) -> int:
|
|
121
|
+
"""Publish a WARNING-severity event.
|
|
122
|
+
|
|
123
|
+
Shorthand for ``event(..., severity=EventSeverity.WARNING)``.
|
|
124
|
+
See :func:`event` for full parameter documentation.
|
|
125
|
+
"""
|
|
126
|
+
return event(source_id, event_type, message, EventSeverity.WARNING,
|
|
127
|
+
start_ns=start_ns, end_ns=end_ns, source_type=source_type, kind=kind,
|
|
128
|
+
trace_id=trace_id, span_id=span_id, parent_id=parent_id,
|
|
129
|
+
attrs=attrs, data=data)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def error(
|
|
133
|
+
source_id: str,
|
|
134
|
+
event_type: str,
|
|
135
|
+
message: str,
|
|
136
|
+
*,
|
|
137
|
+
start_ns: int | None = None,
|
|
138
|
+
end_ns: int = 0,
|
|
139
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
140
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
141
|
+
trace_id: str | None = None,
|
|
142
|
+
span_id: str | None = None,
|
|
143
|
+
parent_id: str | None = None,
|
|
144
|
+
attrs: dict[str, Any] | None = None,
|
|
145
|
+
data: Any = None,
|
|
146
|
+
) -> int:
|
|
147
|
+
"""Publish an ERROR-severity event.
|
|
148
|
+
|
|
149
|
+
Shorthand for ``event(..., severity=EventSeverity.ERROR)``.
|
|
150
|
+
See :func:`event` for full parameter documentation.
|
|
151
|
+
"""
|
|
152
|
+
return event(source_id, event_type, message, EventSeverity.ERROR,
|
|
153
|
+
start_ns=start_ns, end_ns=end_ns, source_type=source_type, kind=kind,
|
|
154
|
+
trace_id=trace_id, span_id=span_id, parent_id=parent_id,
|
|
155
|
+
attrs=attrs, data=data)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def critical(
|
|
159
|
+
source_id: str,
|
|
160
|
+
event_type: str,
|
|
161
|
+
message: str,
|
|
162
|
+
*,
|
|
163
|
+
start_ns: int | None = None,
|
|
164
|
+
end_ns: int = 0,
|
|
165
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
166
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
167
|
+
trace_id: str | None = None,
|
|
168
|
+
span_id: str | None = None,
|
|
169
|
+
parent_id: str | None = None,
|
|
170
|
+
attrs: dict[str, Any] | None = None,
|
|
171
|
+
data: Any = None,
|
|
172
|
+
) -> int:
|
|
173
|
+
"""Publish a CRITICAL-severity event.
|
|
174
|
+
|
|
175
|
+
Shorthand for ``event(..., severity=EventSeverity.CRITICAL)``.
|
|
176
|
+
See :func:`event` for full parameter documentation.
|
|
177
|
+
"""
|
|
178
|
+
return event(source_id, event_type, message, EventSeverity.CRITICAL,
|
|
179
|
+
start_ns=start_ns, end_ns=end_ns, source_type=source_type, kind=kind,
|
|
180
|
+
trace_id=trace_id, span_id=span_id, parent_id=parent_id,
|
|
181
|
+
attrs=attrs, data=data)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def span(
|
|
185
|
+
source_id: str,
|
|
186
|
+
event_type: str,
|
|
187
|
+
message: str,
|
|
188
|
+
severity: EventSeverity = EventSeverity.INFO,
|
|
189
|
+
*,
|
|
190
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
191
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
192
|
+
trace_id: str | None = None,
|
|
193
|
+
parent_id: str | None = None,
|
|
194
|
+
data: Any = None,
|
|
195
|
+
) -> Span:
|
|
196
|
+
"""Return a :class:`Span` context manager that publishes a timed event on exit.
|
|
197
|
+
|
|
198
|
+
Trace context is wired automatically — nested spans inherit ``trace_id`` and
|
|
199
|
+
``parent_id`` from the enclosing span with no manual bookkeeping. Pass
|
|
200
|
+
``trace_id`` / ``parent_id`` explicitly only when linking across thread or
|
|
201
|
+
process boundaries.
|
|
202
|
+
|
|
203
|
+
For components that emit many spans, prefer :func:`get_tracer` to avoid
|
|
204
|
+
repeating ``source_id`` at every call site.
|
|
205
|
+
|
|
206
|
+
Example::
|
|
207
|
+
|
|
208
|
+
# Simple span
|
|
209
|
+
with setaur.span("nav", "path_planning", "Plan route to dock") as s:
|
|
210
|
+
s.set_attr("goal", "charging_dock")
|
|
211
|
+
do_planning()
|
|
212
|
+
|
|
213
|
+
# Nested spans — trace context flows automatically
|
|
214
|
+
with setaur.span("nav", "mission_leg", "Execute leg"):
|
|
215
|
+
with setaur.span("drive", "waypoint_exec", "Drive to wp-1"):
|
|
216
|
+
do_drive()
|
|
217
|
+
# this event is also linked to the active trace automatically
|
|
218
|
+
setaur.error("drive", "waypoint_failed", "Missed wp-1")
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
source_id: Component emitting the span (e.g. ``"navigation_controller"``).
|
|
222
|
+
event_type: Machine-readable operation name (e.g. ``"navigate_to_waypoint"``).
|
|
223
|
+
message: Human-readable description of the operation.
|
|
224
|
+
severity: Defaults to ``INFO``.
|
|
225
|
+
source_type: Origin of the event. Defaults to ``EventSourceType.USER``.
|
|
226
|
+
kind: Operation kind hint — use ``SpanKind.ACTUATOR``, ``SENSOR``, etc.
|
|
227
|
+
trace_id: Override the inherited trace ID (cross-thread / cross-process only).
|
|
228
|
+
parent_id: Override the inherited parent span ID (cross-thread / cross-process only).
|
|
229
|
+
data: Arbitrary CBOR-serializable payload attached to the span event.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
A :class:`Span` context manager. After the ``with`` block, ``span.sequence_num``
|
|
233
|
+
holds the published sequence number.
|
|
234
|
+
"""
|
|
235
|
+
return Span(
|
|
236
|
+
get_client(), source_id, event_type, message, severity,
|
|
237
|
+
source_type=source_type, kind=kind,
|
|
238
|
+
trace_id=trace_id, parent_id=parent_id, data=data,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_tracer(source_id: str) -> Tracer:
|
|
243
|
+
"""Return a :class:`Tracer` scoped to ``source_id``.
|
|
244
|
+
|
|
245
|
+
A tracer binds a ``source_id`` once so you don't repeat it on every span
|
|
246
|
+
or event call. All spans and events emitted through a tracer participate in
|
|
247
|
+
automatic trace context propagation identically to :func:`span` and
|
|
248
|
+
:func:`event`.
|
|
249
|
+
|
|
250
|
+
Example::
|
|
251
|
+
|
|
252
|
+
nav = setaur.get_tracer("nav")
|
|
253
|
+
drive = setaur.get_tracer("drive_controller")
|
|
254
|
+
|
|
255
|
+
with nav.span("mission_leg", "Execute leg"):
|
|
256
|
+
with drive.span("waypoint_exec", "Drive to wp-1"):
|
|
257
|
+
do_drive()
|
|
258
|
+
drive.error("waypoint_failed", "Missed wp-1")
|
|
259
|
+
# trace_id and parent_id inherited automatically
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
source_id: Identifier for the component (e.g. ``"navigation_controller"``).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A :class:`Tracer` bound to ``source_id``.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
RuntimeError: If ``setaur.init()`` has not been called.
|
|
269
|
+
"""
|
|
270
|
+
return Tracer(get_client(), source_id)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_active_trace_id() -> str | None:
|
|
274
|
+
"""Return the trace ID of the currently active span, or ``None``.
|
|
275
|
+
|
|
276
|
+
Useful for correlating structured log lines with the active trace without
|
|
277
|
+
importing :func:`get_active_span` and handling the ``None`` check yourself::
|
|
278
|
+
|
|
279
|
+
logger.info("planning route", extra={"trace_id": setaur.get_active_trace_id()})
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
A 32-char hex trace ID string, or ``None`` if called outside a span context.
|
|
283
|
+
"""
|
|
284
|
+
span = get_active_span()
|
|
285
|
+
return span.trace_id if span is not None else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = [
|
|
289
|
+
"SourceType", "EventSourceType", "EventSeverity", "SpanKind", "Span", "Tracer",
|
|
290
|
+
"init", "shutdown", "flush", "get_client", "get_tracer", "get_active_span", "get_active_trace_id",
|
|
291
|
+
"sensor", "event",
|
|
292
|
+
"info", "warning", "error", "critical",
|
|
293
|
+
"span",
|
|
294
|
+
]
|
setaur/_client.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
import cbor2
|
|
9
|
+
import nats
|
|
10
|
+
|
|
11
|
+
from ._envelope import EnvelopeBuilder
|
|
12
|
+
from ._span_context import get_active_span
|
|
13
|
+
from ._types import SourceType, EventSourceType, EventSeverity, SpanKind
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_instance: "_Client | None" = None
|
|
18
|
+
|
|
19
|
+
_QUEUE_MAX = 2048
|
|
20
|
+
_NATS_URL = "nats://localhost:4222"
|
|
21
|
+
_CREDS_ENV = "SETAUR_CREDS_FILE"
|
|
22
|
+
_ROBOT_KEY_RE = re.compile(r'^rbt-[a-z0-9]+$')
|
|
23
|
+
_LABEL_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NatsClient(Protocol):
|
|
27
|
+
async def publish(self, subject: str, payload: bytes) -> None: ...
|
|
28
|
+
async def close(self) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NatsConnector(Protocol):
|
|
32
|
+
async def __call__(self, url: str, credentials: str | None) -> NatsClient: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _default_connector(url: str, credentials: str | None) -> NatsClient:
|
|
36
|
+
opts: dict = {"servers": [url]}
|
|
37
|
+
if credentials:
|
|
38
|
+
opts["credentials"] = credentials
|
|
39
|
+
return await nats.connect(**opts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_robot_key(value: str) -> None:
|
|
43
|
+
if not _ROBOT_KEY_RE.match(value):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"setaur: robot_key '{value}' must match rbt-[a-z0-9]+ (e.g. rbt-nkjttwwvw4z7)."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_label(field: str, value: str) -> None:
|
|
50
|
+
if not _LABEL_RE.match(value):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"setaur: {field} '{value}' contains characters not allowed. "
|
|
53
|
+
"Use only letters, digits, hyphens, and underscores."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Client:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
robot_key: str,
|
|
61
|
+
creds_file: str | None,
|
|
62
|
+
connector: NatsConnector = _default_connector,
|
|
63
|
+
):
|
|
64
|
+
_validate_robot_key(robot_key)
|
|
65
|
+
|
|
66
|
+
creds = creds_file or os.environ.get(_CREDS_ENV)
|
|
67
|
+
if creds and not os.path.isfile(creds):
|
|
68
|
+
raise FileNotFoundError(f"setaur: credentials file not found: {creds}")
|
|
69
|
+
|
|
70
|
+
self._robot_key = robot_key
|
|
71
|
+
self._creds = creds
|
|
72
|
+
self._connector = connector
|
|
73
|
+
self._envelope = EnvelopeBuilder()
|
|
74
|
+
self._subjects: dict[str, str] = {}
|
|
75
|
+
self._validated_ids: set[str] = set()
|
|
76
|
+
self._q: asyncio.Queue[tuple[str, bytes] | None] = asyncio.Queue(maxsize=_QUEUE_MAX)
|
|
77
|
+
self._loop = asyncio.new_event_loop()
|
|
78
|
+
self._ready = threading.Event()
|
|
79
|
+
self._error: BaseException | None = None
|
|
80
|
+
self._thread = threading.Thread(target=self._run, name="setaur-sdk", daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
|
|
83
|
+
if not self._ready.wait(timeout=10):
|
|
84
|
+
msg = str(self._error) if self._error else f"timed out connecting to {_NATS_URL}"
|
|
85
|
+
raise RuntimeError(f"setaur: {msg}")
|
|
86
|
+
|
|
87
|
+
if self._error:
|
|
88
|
+
raise RuntimeError(f"setaur: {self._error}") from self._error
|
|
89
|
+
|
|
90
|
+
def _run(self) -> None:
|
|
91
|
+
asyncio.set_event_loop(self._loop)
|
|
92
|
+
try:
|
|
93
|
+
self._loop.run_until_complete(self._connect())
|
|
94
|
+
self._loop.run_until_complete(self._drain())
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
self._error = exc
|
|
97
|
+
self._ready.set()
|
|
98
|
+
|
|
99
|
+
async def _connect(self) -> None:
|
|
100
|
+
self._nc = await self._connector(_NATS_URL, self._creds)
|
|
101
|
+
self._ready.set()
|
|
102
|
+
|
|
103
|
+
async def _drain(self) -> None:
|
|
104
|
+
while True:
|
|
105
|
+
item = await self._q.get() # yield until at least one item arrives
|
|
106
|
+
if item is None:
|
|
107
|
+
break
|
|
108
|
+
while item is not None: # batch: consume all already-queued items
|
|
109
|
+
subject, payload = item
|
|
110
|
+
await self._nc.publish(subject, payload)
|
|
111
|
+
try:
|
|
112
|
+
item = self._q.get_nowait()
|
|
113
|
+
except asyncio.QueueEmpty:
|
|
114
|
+
item = None
|
|
115
|
+
await self._nc.close()
|
|
116
|
+
|
|
117
|
+
def _publish(self, subject: str, payload: bytes, source_id: str) -> None:
|
|
118
|
+
item = (subject, payload)
|
|
119
|
+
def _enqueue():
|
|
120
|
+
try:
|
|
121
|
+
self._q.put_nowait(item)
|
|
122
|
+
except asyncio.QueueFull:
|
|
123
|
+
log.warning("setaur: publish queue full, dropping message for %s", source_id)
|
|
124
|
+
self._loop.call_soon_threadsafe(_enqueue)
|
|
125
|
+
|
|
126
|
+
def sensor(
|
|
127
|
+
self,
|
|
128
|
+
source_id: str,
|
|
129
|
+
source_type: SourceType,
|
|
130
|
+
timestamp_ns: int,
|
|
131
|
+
data: dict,
|
|
132
|
+
) -> int:
|
|
133
|
+
if source_id not in self._subjects:
|
|
134
|
+
_validate_label("source_id", source_id)
|
|
135
|
+
self._subjects[source_id] = f"sensors.{self._robot_key}.{source_id}"
|
|
136
|
+
|
|
137
|
+
envelope = self._envelope.sensor(source_id, source_type, timestamp_ns, data)
|
|
138
|
+
try:
|
|
139
|
+
payload = cbor2.dumps(envelope)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise TypeError(
|
|
142
|
+
f"setaur: sensor data for '{source_id}' is not CBOR-serializable: {exc}"
|
|
143
|
+
) from exc
|
|
144
|
+
self._publish(self._subjects[source_id], payload, source_id)
|
|
145
|
+
return envelope['sequence_num']
|
|
146
|
+
|
|
147
|
+
def event(
|
|
148
|
+
self,
|
|
149
|
+
source_id: str,
|
|
150
|
+
event_type: str,
|
|
151
|
+
message: str,
|
|
152
|
+
severity: EventSeverity,
|
|
153
|
+
*,
|
|
154
|
+
start_ns: int | None = None,
|
|
155
|
+
end_ns: int = 0,
|
|
156
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
157
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
158
|
+
trace_id: str | None = None,
|
|
159
|
+
span_id: str | None = None,
|
|
160
|
+
parent_id: str | None = None,
|
|
161
|
+
attrs: dict[str, Any] | None = None,
|
|
162
|
+
data: Any = None,
|
|
163
|
+
) -> int:
|
|
164
|
+
if source_id not in self._validated_ids:
|
|
165
|
+
_validate_label("source_id", source_id)
|
|
166
|
+
self._validated_ids.add(source_id)
|
|
167
|
+
|
|
168
|
+
if event_type not in self._validated_ids:
|
|
169
|
+
_validate_label("event_type", event_type)
|
|
170
|
+
self._validated_ids.add(event_type)
|
|
171
|
+
|
|
172
|
+
resolved_start_ns = start_ns if start_ns is not None else time.time_ns()
|
|
173
|
+
|
|
174
|
+
if trace_id is None or parent_id is None:
|
|
175
|
+
active = get_active_span()
|
|
176
|
+
if active is not None:
|
|
177
|
+
if trace_id is None:
|
|
178
|
+
trace_id = active.trace_id
|
|
179
|
+
if parent_id is None:
|
|
180
|
+
parent_id = active.span_id
|
|
181
|
+
|
|
182
|
+
subject = f"events.{self._robot_key}.{severity}.{event_type}"
|
|
183
|
+
envelope = self._envelope.event(
|
|
184
|
+
source_id, event_type, message, severity, resolved_start_ns,
|
|
185
|
+
source_type=source_type, end_ns=end_ns, kind=kind,
|
|
186
|
+
trace_id=trace_id, span_id=span_id,
|
|
187
|
+
parent_id=parent_id, attrs=attrs, data=data,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
payload = cbor2.dumps(envelope)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
raise TypeError(
|
|
193
|
+
f"setaur: event data for '{source_id}' is not CBOR-serializable: {exc}"
|
|
194
|
+
) from exc
|
|
195
|
+
self._publish(subject, payload, source_id)
|
|
196
|
+
return envelope['sequence_num']
|
|
197
|
+
|
|
198
|
+
def flush(self, timeout: float = 5.0) -> bool:
|
|
199
|
+
"""Block until the publish queue is empty or *timeout* seconds elapse.
|
|
200
|
+
|
|
201
|
+
Returns True if the queue drained within the timeout, False otherwise.
|
|
202
|
+
The client remains running after this call.
|
|
203
|
+
"""
|
|
204
|
+
done = threading.Event()
|
|
205
|
+
|
|
206
|
+
def _signal():
|
|
207
|
+
done.set()
|
|
208
|
+
|
|
209
|
+
# Schedule a no-op sentinel onto the event loop; when it executes the
|
|
210
|
+
# queue has been fully consumed up to this point.
|
|
211
|
+
async def _wait_for_empty():
|
|
212
|
+
while not self._q.empty():
|
|
213
|
+
await asyncio.sleep(0)
|
|
214
|
+
self._loop.call_soon_threadsafe(_signal)
|
|
215
|
+
|
|
216
|
+
asyncio.run_coroutine_threadsafe(_wait_for_empty(), self._loop)
|
|
217
|
+
flushed = done.wait(timeout=timeout)
|
|
218
|
+
if not flushed:
|
|
219
|
+
log.warning("setaur: flush() timed out after %.1fs — some events may not be published", timeout)
|
|
220
|
+
return flushed
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
self._loop.call_soon_threadsafe(self._q.put_nowait, None)
|
|
224
|
+
self._thread.join(timeout=5)
|
|
225
|
+
if self._thread.is_alive():
|
|
226
|
+
log.warning("setaur: background thread did not stop cleanly within timeout")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def init(robot_key: str, creds_file: str | None = None) -> "_Client":
|
|
230
|
+
"""Connect to setaur-edge and return the active client.
|
|
231
|
+
|
|
232
|
+
Blocks until the connection is established or raises on failure.
|
|
233
|
+
Calling ``init()`` a second time closes the previous client first.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
robot_key: Your robot's unique key (e.g. ``"rbt-nkjttwwvw4z7"``).
|
|
237
|
+
creds_file: Path to the NATS credentials file. Falls back to the
|
|
238
|
+
``SETAUR_CREDS_FILE`` environment variable when omitted.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The connected :class:`_Client`. Store it if you need direct access;
|
|
242
|
+
all module-level functions (``setaur.info``, ``setaur.span``, etc.)
|
|
243
|
+
use it automatically via the global singleton.
|
|
244
|
+
"""
|
|
245
|
+
global _instance
|
|
246
|
+
if _instance is not None:
|
|
247
|
+
log.warning("setaur: init() called while a client is already running; replacing it")
|
|
248
|
+
_instance.close()
|
|
249
|
+
_instance = _Client(robot_key, creds_file)
|
|
250
|
+
return _instance
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def shutdown(timeout: float = 5.0) -> None:
|
|
254
|
+
"""Flush pending events and shut down the client.
|
|
255
|
+
|
|
256
|
+
Blocks until the publish queue drains or *timeout* seconds elapse, then
|
|
257
|
+
closes the connection. Safe to call at process exit.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
timeout: Maximum seconds to wait for the queue to drain before closing.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
RuntimeError: If ``setaur.init()`` has not been called.
|
|
264
|
+
"""
|
|
265
|
+
global _instance
|
|
266
|
+
if _instance is None:
|
|
267
|
+
raise RuntimeError("setaur.init() has not been called")
|
|
268
|
+
_instance.flush(timeout=timeout)
|
|
269
|
+
_instance.close()
|
|
270
|
+
_instance = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
274
|
+
"""Block until all queued events are published or *timeout* seconds elapse.
|
|
275
|
+
|
|
276
|
+
The client remains running after this call — use :func:`shutdown` for
|
|
277
|
+
final process cleanup.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
timeout: Maximum seconds to wait.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
``True`` if the queue drained within the timeout, ``False`` otherwise.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
RuntimeError: If ``setaur.init()`` has not been called.
|
|
287
|
+
"""
|
|
288
|
+
return get_client().flush(timeout=timeout)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_client() -> "_Client":
|
|
292
|
+
if _instance is None:
|
|
293
|
+
raise RuntimeError("setaur.init() has not been called")
|
|
294
|
+
return _instance
|
setaur/_envelope.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from typing import Any
|
|
4
|
+
from ._types import SourceType, EventSourceType, EventSeverity, SpanKind
|
|
5
|
+
|
|
6
|
+
class EnvelopeBuilder:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._seq: defaultdict[str, int] = defaultdict(int)
|
|
9
|
+
self._lock = threading.Lock()
|
|
10
|
+
|
|
11
|
+
def sensor(self, source_id: str, source_type: SourceType, timestamp_ns: int, data: dict) -> dict:
|
|
12
|
+
key = f"sensor:{source_id}"
|
|
13
|
+
with self._lock:
|
|
14
|
+
self._seq[key] += 1
|
|
15
|
+
seq = self._seq[key]
|
|
16
|
+
return {
|
|
17
|
+
'timestamp_ns': timestamp_ns,
|
|
18
|
+
'source_id': source_id,
|
|
19
|
+
'source_type': str(source_type),
|
|
20
|
+
'sequence_num': seq,
|
|
21
|
+
'data': data,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def event(
|
|
25
|
+
self,
|
|
26
|
+
source_id: str,
|
|
27
|
+
event_type: str,
|
|
28
|
+
message: str,
|
|
29
|
+
severity: EventSeverity,
|
|
30
|
+
start_ns: int,
|
|
31
|
+
*,
|
|
32
|
+
end_ns: int = 0,
|
|
33
|
+
source_type: EventSourceType = EventSourceType.USER,
|
|
34
|
+
kind: SpanKind = SpanKind.UNSPECIFIED,
|
|
35
|
+
trace_id: str | None = None,
|
|
36
|
+
span_id: str | None = None,
|
|
37
|
+
parent_id: str | None = None,
|
|
38
|
+
attrs: dict[str, Any] | None = None,
|
|
39
|
+
data: Any = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
key = f"event:{source_id}"
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._seq[key] += 1
|
|
44
|
+
seq = self._seq[key]
|
|
45
|
+
envelope: dict[str, Any] = {
|
|
46
|
+
'start_ns': start_ns,
|
|
47
|
+
'source_id': source_id,
|
|
48
|
+
'source_type': str(source_type),
|
|
49
|
+
'event_type': event_type,
|
|
50
|
+
'severity': str(severity),
|
|
51
|
+
'message': message,
|
|
52
|
+
'kind': str(kind),
|
|
53
|
+
'sequence_num': seq,
|
|
54
|
+
}
|
|
55
|
+
if end_ns:
|
|
56
|
+
envelope['end_ns'] = end_ns
|
|
57
|
+
if trace_id is not None:
|
|
58
|
+
envelope['trace_id'] = trace_id
|
|
59
|
+
if span_id is not None:
|
|
60
|
+
envelope['span_id'] = span_id
|
|
61
|
+
if parent_id is not None:
|
|
62
|
+
envelope['parent_id'] = parent_id
|
|
63
|
+
if attrs:
|
|
64
|
+
envelope['attrs'] = attrs
|
|
65
|
+
if data is not None:
|
|
66
|
+
envelope['data'] = data
|
|
67
|
+
return envelope
|