-
Notifications
You must be signed in to change notification settings - Fork 726
feat: dimos spy - universal transport spy over LCM + Zenoh #2735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
leshy
wants to merge
40
commits into
main
Choose a base branch
from
feat/ivan/spy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,440
−561
Open
Changes from 8 commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
de664a8
spec: dimos spy task package — universal transport spy
leshdaemon 7c7720c
feat: dimos spy — universal transport spy over LCM + Zenoh
leshdaemon fae1dc7
chore: drop internal task package file before publishing
leshdaemon 1ed1963
refactor: harden spy lifecycle + transport selection
leshdaemon e55a1a2
refactor: make lcmspy a thin alias for spy --transport lcm
leshdaemon d29c740
fix: degrade default spy on missing backends, forward web-mode filters
leshdaemon 22f5442
fix: route lcmspy web through spy web mode
leshdaemon bf1dac1
revert: keep zenoh subscribe_all conflating; spy stays protocol-agnostic
leshdaemon 851d864
feat: remove spy web mode; fix lcmspy console entry arg handling
leshdaemon 3f4337f
Merge branch 'main' into feat/ivan/spy
leshy 1937f19
fix: spy rejects stray positionals; default_sources degrades on any e…
leshdaemon 2d710f7
fix: spy default path survives per-source start failures
leshdaemon d517f28
docs: fold dimos spy page into the transports doc
leshdaemon a2e02c1
comment correction
leshy 00071ad
refactor: move spy core out of protocol/pubsub into the spy tool package
leshdaemon 506a3a0
Merge branch 'main' into feat/ivan/spy
leshy c97b24f
docs: document dimos spy in cli.md, mark lcmspy as deprecated alias
leshdaemon 3608002
refactor(spy): rename _lcm_only_argv to lcm_only_argv
paul-nechifor 4f7a6f6
refactor(spy): move inline imports to the top of their modules
paul-nechifor 92695be
refactor(spy): share the default history window as one constant
paul-nechifor 7ab39df
refactor(spy): name the gradient saturation magic numbers
paul-nechifor 6cc7b26
fix(spy): resolve type ignores in run_spy with real types
paul-nechifor 9a19f03
refactor(spy): extract duplicated unknown-transport validation
paul-nechifor 2e0df3e
refactor(spy): stop doing work in SpyApp.__init__
paul-nechifor a01cbe1
fix(spy): emit degrade warnings via the project logger
paul-nechifor e914ff9
fix(spy): don't abort TransportSpy.stop() on the first error
paul-nechifor 095feec
test: guarantee spy subscription/lifecycle cleanup on assertion failure
leshdaemon 79967db
refactor(spy): remove unused TopicStats.avg_size
paul-nechifor f705bce
fix(spy): read shared TopicStats fields under the lock, in one pass
paul-nechifor 0329aaf
chore(spy): drop stale TASK.md reference from test docstring
paul-nechifor 510a8ea
refactor(spy): tear down test resources via fixtures
paul-nechifor fe5859e
fix: dedupe sys import and type SpyApp's App generic in run_spy
leshdaemon 86c3f56
refactor(spy): poll for subscription establishment instead of sleeping
paul-nechifor bab0fe4
fix: reject root-level --transport before the spy subcommand
leshdaemon 52e6de0
add fix
paul-nechifor bf819c1
merge: Paul's spy autofixes (PR #2748) into feat/ivan/spy
leshdaemon 9224503
Merge branch 'main' into feat/ivan/spy
leshy bed7ea2
Merge branch 'main' into feat/ivan/spy
leshy 86fa1b7
Merge remote-tracking branch 'origin/main' into feat/ivan/spy
leshy 6181be2
Merge branch 'main' into feat/ivan/spy
leshy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| # Copyright 2026 Dimensional Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Transport-agnostic pubsub spy: topic discovery, rates, sizes, liveness. | ||
|
|
||
| Design doc: docs/usage/transports/spy.md. Task spec: TASK.md (branch agent/spy-architect). | ||
|
|
||
| HARD CONSTRAINT: the spy never decodes message payloads. Sources tap the | ||
| raw-bytes pubsub layer (LCMPubSubBase, ZenohPubSubBase — beneath the encoder | ||
| mixins), so the hot path per message is (topic string, payload length, | ||
| timestamp) and nothing else. Message *types* are still visible because they | ||
| are embedded in the topic string ("/cmd_vel#geometry_msgs.Twist"). | ||
|
|
||
| Decoding is a separate, per-topic, opt-in concern: SpySource.subscribe_decoded | ||
| is the spec'd hook, not implemented in v1. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections import deque | ||
| from dataclasses import dataclass | ||
| import sys | ||
| import threading | ||
| import time | ||
| from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Callable, Sequence | ||
|
|
||
| # The entire hot-path event: (topic string incl. '#type' suffix, wire payload length). | ||
| TapCallback = Callable[[str, int], None] | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class SpyKey: | ||
| """Identity of one spied topic: which transport saw it + its raw topic string.""" | ||
|
|
||
| transport: str # SpySource.name, e.g. "lcm", "zenoh" | ||
| topic: str # raw transport topic, e.g. "/cmd_vel#geometry_msgs.Twist" | ||
|
|
||
|
|
||
| def split_type_suffix(topic: str) -> tuple[str, str | None]: | ||
| """Split a spied topic string into (base_topic, msg_type_name or None). | ||
|
|
||
| Both LCM and zenoh sources deliver topics in the uniform str(Topic) form | ||
| "base#pkg.Msg" (zenoh keys are converted back by _key_expr_to_topic). | ||
| Render-time helper — never called on the hot path. | ||
|
|
||
| >>> split_type_suffix("/cmd_vel#geometry_msgs.Twist") | ||
| ('/cmd_vel', 'geometry_msgs.Twist') | ||
| >>> split_type_suffix("/plain") | ||
| ('/plain', None) | ||
| """ | ||
| base, sep, suffix = topic.partition("#") | ||
| return (base, suffix) if sep else (topic, None) | ||
|
|
||
|
|
||
| class TopicStats: | ||
| """Sliding-window traffic statistics for one spied topic. | ||
|
|
||
| Records only (timestamp, nbytes) pairs — no payloads are retained. | ||
| Timestamps are passed in explicitly (callers use time.time(); tests inject | ||
| values), so all stats are deterministic functions of recorded data. | ||
|
|
||
| Thread-safety: record() may be called from transport threads while readers | ||
| query from a UI thread. | ||
| """ | ||
|
|
||
| total_bytes: int | ||
| total_msgs: int | ||
| last_seen: float | None # timestamp of newest recorded message, None if none yet | ||
|
|
||
| def __init__(self, history_window: float = 60.0) -> None: | ||
| """history_window: seconds of per-message history kept for windowed stats. | ||
|
|
||
| total_bytes/total_msgs/last_seen survive eviction; only windowed stats | ||
| (freq/bytes_per_sec/avg_size) forget evicted messages. | ||
| """ | ||
| self.history_window = history_window | ||
| self._history: deque[tuple[float, int]] = deque() # (timestamp, nbytes) | ||
| self._lock = threading.Lock() | ||
| self.total_bytes = 0 | ||
| self.total_msgs = 0 | ||
| self.last_seen = None | ||
|
|
||
| def record(self, nbytes: int, timestamp: float) -> None: | ||
| """Hot path: O(1) amortized append + eviction of entries older than history_window.""" | ||
| with self._lock: | ||
| self._history.append((timestamp, nbytes)) | ||
| self.total_bytes += nbytes | ||
| self.total_msgs += 1 | ||
| self.last_seen = timestamp | ||
| cutoff = timestamp - self.history_window | ||
| while self._history and self._history[0][0] < cutoff: | ||
| self._history.popleft() | ||
|
|
||
| def _in_window(self, window: float, now: float) -> list[tuple[float, int]]: | ||
| cutoff = now - window | ||
| with self._lock: | ||
| return [(ts, n) for ts, n in self._history if ts >= cutoff] | ||
|
|
||
| def freq(self, window: float, now: float) -> float: | ||
| """Messages per second over [now - window, now]. 0.0 if none.""" | ||
| msgs = self._in_window(window, now) | ||
| return len(msgs) / window if msgs else 0.0 | ||
|
|
||
| def bytes_per_sec(self, window: float, now: float) -> float: | ||
| """Payload bytes per second over [now - window, now]. 0.0 if none.""" | ||
| msgs = self._in_window(window, now) | ||
| return sum(n for _, n in msgs) / window if msgs else 0.0 | ||
|
|
||
| def avg_size(self, window: float, now: float) -> float: | ||
| """Mean payload size in bytes over [now - window, now]. 0.0 if none.""" | ||
| msgs = self._in_window(window, now) | ||
| return sum(n for _, n in msgs) / len(msgs) if msgs else 0.0 | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class SpySource(Protocol): | ||
| """One transport's raw firehose feeding the spy. | ||
|
|
||
| Invariants for implementations: | ||
| - tap() rides the raw-bytes bus's subscribe_all; delivery scope and | ||
| conflation follow that transport's semantics (LCM delivers every | ||
| message; zenoh's subscribe_all is latest-per-topic over dimos/**). | ||
| - The tap callback receives (topic_str, nbytes) where topic_str is the | ||
| uniform str(Topic) form and nbytes is the wire payload length. | ||
| - Never decodes payloads, never retains them past the callback. | ||
| - start() is required before tap() delivers; stop() releases the bus. | ||
| """ | ||
|
|
||
| name: str | ||
|
|
||
| def start(self) -> None: ... | ||
|
|
||
| def stop(self) -> None: ... | ||
|
|
||
| def tap(self, callback: Callable[[str, int], None]) -> Callable[[], None]: ... | ||
|
|
||
| def subscribe_decoded( | ||
| self, topic: str, callback: Callable[[Any], None] | ||
| ) -> Callable[[], None]: ... | ||
|
|
||
|
|
||
| class LCMSpySource: | ||
| """Spy source over LCM, via LCMPubSubBase.subscribe_all (raw regex '.*'). | ||
|
|
||
| Delivers raw channel strings incl. the '#pkg.Msg' suffix. Payloads are the | ||
| LCM-encoded bytes; nbytes = len(payload). | ||
| """ | ||
|
|
||
| name = "lcm" | ||
|
|
||
| def __init__(self, **lcm_kwargs: Any) -> None: | ||
| """lcm_kwargs forwarded to LCMPubSubBase (e.g. lcm_url).""" | ||
| from dimos.protocol.pubsub.impl.lcmpubsub import LCMPubSubBase | ||
|
|
||
| self._bus = LCMPubSubBase(**lcm_kwargs) | ||
|
|
||
| def start(self) -> None: | ||
| self._bus.start() | ||
|
|
||
| def stop(self) -> None: | ||
| self._bus.stop() | ||
|
|
||
| def tap(self, callback: Callable[[str, int], None]) -> Callable[[], None]: | ||
| return self._bus.subscribe_all(lambda msg, topic: callback(str(topic), len(msg))) | ||
|
|
||
| def subscribe_decoded(self, topic: str, callback: Callable[[Any], None]) -> Callable[[], None]: | ||
| """Opt-in per-topic decoded tap — OFF the spy hot path. Not implemented in v1.""" | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class ZenohSpySource: | ||
| """Spy source over zenoh, via ZenohPubSubBase.subscribe_all. | ||
|
|
||
| zenoh's subscribe_all is latest-per-topic over dimos/** (best-effort), so | ||
| same-topic bursts between drains conflate away. nbytes = payload length; | ||
| topics arrive as str(Topic) with the type suffix reconstructed from the key. | ||
| """ | ||
|
|
||
| name = "zenoh" | ||
|
|
||
| def __init__(self, **zenoh_kwargs: Any) -> None: | ||
| """zenoh_kwargs forwarded to ZenohPubSubBase (e.g. mode/connect/listen, session_pool).""" | ||
| from dimos.protocol.pubsub.impl.zenohpubsub import ZenohPubSubBase | ||
|
|
||
| self._bus = ZenohPubSubBase(**zenoh_kwargs) | ||
|
|
||
| def start(self) -> None: | ||
| self._bus.start() | ||
|
|
||
| def stop(self) -> None: | ||
| self._bus.stop() | ||
|
|
||
| def tap(self, callback: Callable[[str, int], None]) -> Callable[[], None]: | ||
| return self._bus.subscribe_all(lambda msg, topic: callback(str(topic), len(msg))) | ||
|
|
||
| def subscribe_decoded(self, topic: str, callback: Callable[[Any], None]) -> Callable[[], None]: | ||
| """Opt-in per-topic decoded tap — OFF the spy hot path. Not implemented in v1.""" | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class TransportSpy: | ||
| """Aggregates SpySources into per-(transport, topic) stats plus global totals. | ||
|
|
||
| Owns the sources' lifecycle: start() starts every source and taps it; | ||
| stop() untaps and stops them. Stats rows appear lazily as topics are first | ||
| seen (a topic with no traffic since start is invisible — the spy observes, | ||
| it does not enumerate). | ||
|
|
||
| Thread-safety: tap callbacks arrive on transport threads; snapshot() may be | ||
| called from any thread and returns a consistent view for rendering. | ||
| """ | ||
|
|
||
| totals: TopicStats # all messages across all sources | ||
|
|
||
| def __init__( | ||
| self, sources: Sequence[SpySource] | None = None, history_window: float = 60.0 | ||
| ) -> None: | ||
| """sources=None means default_sources().""" | ||
| self._sources = list(sources) if sources is not None else default_sources() | ||
| self._history_window = history_window | ||
| self._stats: dict[SpyKey, TopicStats] = {} | ||
| self._lock = threading.Lock() | ||
| self._untaps: list[Callable[[], None]] = [] | ||
| self.totals = TopicStats(history_window=history_window) | ||
|
|
||
| def _tap_callback(self, transport: str) -> Callable[[str, int], None]: | ||
| def on_message(topic: str, nbytes: int) -> None: | ||
| now = time.time() | ||
| key = SpyKey(transport, topic) | ||
| with self._lock: | ||
| stats = self._stats.get(key) | ||
| if stats is None: | ||
| stats = TopicStats(history_window=self._history_window) | ||
| self._stats[key] = stats | ||
| stats.record(nbytes, now) | ||
| self.totals.record(nbytes, now) | ||
|
|
||
| return on_message | ||
|
|
||
| def start(self) -> None: | ||
| """Start and tap every source; on failure roll back the ones already started.""" | ||
| started: list[SpySource] = [] | ||
| try: | ||
| for source in self._sources: | ||
| source.start() | ||
| started.append(source) | ||
| self._untaps.append(source.tap(self._tap_callback(source.name))) | ||
| except BaseException: | ||
| for untap in self._untaps: | ||
| untap() | ||
| self._untaps.clear() | ||
| for source in reversed(started): | ||
| source.stop() | ||
| raise | ||
|
|
||
| def stop(self) -> None: | ||
| for untap in self._untaps: | ||
| untap() | ||
| self._untaps.clear() | ||
| for source in self._sources: | ||
| source.stop() | ||
|
|
||
| def snapshot(self) -> dict[SpyKey, TopicStats]: | ||
| """Current per-topic stats, safe to iterate while messages keep arriving.""" | ||
| with self._lock: | ||
| return dict(self._stats) | ||
|
|
||
|
|
||
| SOURCE_FACTORIES: dict[str, Callable[[], SpySource]] = { | ||
| LCMSpySource.name: LCMSpySource, | ||
| ZenohSpySource.name: ZenohSpySource, | ||
| } | ||
| """Known transports by name; each factory constructs its source only when called. | ||
|
|
||
| v1: lcm + zenoh. SHM/ROS/DDS/Redis are future sources. | ||
| """ | ||
|
|
||
|
|
||
| def default_sources() -> list[SpySource]: | ||
| """The spy observes ALL transports simultaneously, regardless of DIMOS_TRANSPORT. | ||
|
|
||
| A backend that is not importable is skipped with a warning, so the default | ||
| spy degrades to whatever transports are available. Requesting a transport | ||
| explicitly (constructing its SOURCE_FACTORIES entry) keeps the hard error. | ||
| """ | ||
| sources: list[SpySource] = [] | ||
| for name, factory in SOURCE_FACTORIES.items(): | ||
| try: | ||
| sources.append(factory()) | ||
| except ImportError as exc: | ||
| print(f"spy: skipping unavailable transport {name!r}: {exc}", file=sys.stderr) | ||
| if not sources: | ||
| raise RuntimeError(f"no spy transports available (tried: {', '.join(SOURCE_FACTORIES)})") | ||
| return sources | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.