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 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