Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Libby: a tiny messaging library which uses Bamboo with pluggable transports (ZMQ
## Documentation

Full docs (installation, keywords, the `Client` library, the `libby` CLI, how
to build a `LibbyDaemon` peer, the keygrabber, plus the generated API
to build a `LibbyDaemon` peer, the keygrabber, optional OpenTelemetry export,
plus the generated API
reference) are
built with Sphinx + the Shibuya theme and published to GitHub Pages:

Expand Down
8 changes: 8 additions & 0 deletions docs/source/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ the docs build does not install the optional `influxdb` extra.
:show-inheritance:
```

## Telemetry

```{eval-rst}
.. automodule:: libby.telemetry
:members:
:show-inheritance:
```

## Responses and errors

```{eval-rst}
Expand Down
3 changes: 3 additions & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ with pluggable transports (ZMQ or RabbitMQ). It gives you:
`wait_for` (libby's `ktl.waitFor`).
- **Keygrabber**: a daemon that polls keywords from other peers and writes them
to a time-series database for dashboarding.
- **Telemetry**: optional OpenTelemetry traces and metrics for every request.
- **`libby` CLI** — a command-line front end for keyword peers
(`show` / `modify` / `list` / `describe` / `waitfor`), with TAB completion
and wildcard listing to find which peers are up.
Expand All @@ -27,6 +28,7 @@ client
cli
daemon
keygrabber
telemetry
api/index
```

Expand All @@ -37,4 +39,5 @@ api/index
- Writing a script or tool that talks to peers? Read {doc}`client`.
- Poking at peers interactively? Read {doc}`cli`.
- Recording keywords for Grafana? Read {doc}`keygrabber`.
- Tracing requests across peers? Read {doc}`telemetry`.
- Looking for a specific class or function? See {doc}`api/index`.
6 changes: 6 additions & 0 deletions docs/source/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ To also pull in the docs toolchain (Sphinx + the Shibuya theme):
pip install -e ".[docs]"
```

For OpenTelemetry export (see {doc}`telemetry`):

```bash
pip install -e ".[otel]"
```

## Testing

```bash
Expand Down
58 changes: 58 additions & 0 deletions docs/source/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Telemetry

libby can export traces and metrics over OTLP to an
[OpenTelemetry](https://opentelemetry.io/) Collector. It is optional and off
by default: without the `otel` extra libby never imports OpenTelemetry, and
with it nothing is exported until an OTLP endpoint is set.

## Turning it on

```bash
pip install "libby[otel]"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
```

Every `LibbyDaemon` turns export on in `start()` and flushes in `stop()`, and
the `libby` CLI exports each command's trace. The SDK stays installed until the
process exits, so a daemon restarted in the same process keeps exporting. The CLI exports no metrics, since every run
would start new series. Exports use OTLP over HTTP and honour the standard
`OTEL_*` variables, e.g. `OTEL_TRACES_SAMPLER` or `OTEL_SDK_DISABLED=true`.

| Process | `service.name` | `service.namespace` |
|---|---|---|
| `LibbyDaemon` | wire id, e.g. `hsfei.adc` | `group_id` |
| `libby` CLI | `libby-cli` | |

An application that installs its own OpenTelemetry SDK can call
`libby.telemetry.use_current_providers()` to have libby record into it.

## What is recorded

**Traces.** Each request is a client span on the sender and a server span on
the handling peer, joined into one trace through the W3C `traceparent` that
bamboo carries in the envelope. A publish is a producer span, and each
subscriber's handling is a consumer span under it. A CLI command is one span
over all the requests it makes. Span names are the key or keyword, e.g.
`positionvalue`.

**Metrics.** Two histograms in seconds, which give request rate, errors and
duration per key:

| Metric | Attributes |
|---|---|
| `rpc.client.call.duration` | `rpc.method`, `server.address`, `error.type` |
| `rpc.server.call.duration` | `rpc.method`, `error.type` |

`error.type` is set only on failures: the bamboo status (`timeout`,
`no_key`, ...) for a request that got no answer, `error_response` for an
answer with `ok: false`, or the exception type.

**Logs.** libby does not export logs. A line logged while a span is active
gets ` trace_id=... span_id=...` appended, in the daemon's configured log
handler and the CLI's log file, so a Collector reading journald or the files
can link it to its trace.

## Cost

Off, each hook costs about 0.1 µs. On, a traced request adds roughly 15 µs on
the sender and 12 µs on the handler.
18 changes: 16 additions & 2 deletions libby/cli/libby_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import argcomplete
from argcomplete.shell_integration import shellcode

from libby import telemetry
from libby.cli.completion import (
CompletionCache,
Listings,
Expand All @@ -42,6 +43,9 @@
from libby.response import unwrap

DEFAULT_SELF_ID = "cli"
CLI_SERVICE_NAME = "libby-cli"
# Bound the exit flush, so a down collector cannot stall every command
CLI_EXPORT_TIMEOUT_S = 1.0
DEFAULT_TIMEOUT_S = 3.0

DEFAULT_LOG_FILE = Path.home() / ".libby" / "cli.log"
Expand Down Expand Up @@ -115,7 +119,7 @@ def _setup_logging(namespace: argparse.Namespace, config: Dict[str, Any]) -> Non
)
except OSError:
file_handler = logging.NullHandler()
file_handler.setFormatter(logging.Formatter(
file_handler.setFormatter(telemetry.TraceContextFormatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s"
))
setattr(file_handler, "_libby_cli_handler", True)
Expand Down Expand Up @@ -793,9 +797,19 @@ def main(argv: Optional[List[str]] = None) -> int:
argv_display = shlex.join(_redact_argv(argv if argv is not None else sys.argv[1:]))
logger.info("invoke cmd=%s argv=%s", namespace.cmd, argv_display)

telemetry.configure(CLI_SERVICE_NAME, export_timeout_s=CLI_EXPORT_TIMEOUT_S,
export_metrics=False)
try:
return _run_command(namespace)
finally:
telemetry.flush()


def _run_command(namespace: argparse.Namespace) -> int:
start = time.monotonic()
try:
rc = namespace.func(namespace)
with telemetry.operation(f"libby {namespace.cmd}"):
rc = namespace.func(namespace)
except LibbyError as ex:
# Core raises LibbyError; the CLI is the boundary that exits
logger.warning("cmd=%s failed: %s", namespace.cmd, ex)
Expand Down
9 changes: 8 additions & 1 deletion libby/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
from dataclasses import asdict, is_dataclass
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Type, TypeVar

from . import telemetry
from .config import ConfigError, DaemonConfigLoader, with_env_overrides
from .keyword import Keyword
from .libby import Libby
from .naming import qualified_peer_id


Payload = Dict[str, Any]
Expand Down Expand Up @@ -227,7 +229,7 @@ def _setup_logging(self) -> None:

handler.setLevel(level)
handler.setFormatter(
logging.Formatter(str(raw.get("format", DEFAULT_LOG_FORMAT)))
telemetry.TraceContextFormatter(str(raw.get("format", DEFAULT_LOG_FORMAT)))
)
setattr(handler, "_libby_daemon_handler", True)
self.logger.addHandler(handler)
Expand Down Expand Up @@ -389,6 +391,10 @@ def start(self) -> None:
self._stop_event.clear()
self._start_time = time.monotonic()
try:
telemetry.configure(
qualified_peer_id(self.config_peer_id(), self.config_group_id()),
self.config_group_id(),
)
self.libby = self.build_libby()
self._register_services(self.services)
self._register_topics(self.topics)
Expand Down Expand Up @@ -456,6 +462,7 @@ def stop(self) -> None:
self._started = False
self._stop_event.set()
self.logger.info("stopped")
telemetry.flush()

def serve(self) -> None:
"""Start the daemon and block until a signal or stop request."""
Expand Down
31 changes: 21 additions & 10 deletions libby/libby.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from bamboo.protocol import Protocol
from bamboo.discovery import Discovery

from . import telemetry
from .keyword import Keyword, match_pattern
from .keyword_registry import KeywordRegistry
from .naming import qualified_peer_id
Expand Down Expand Up @@ -48,8 +49,7 @@ def __init__(

# bamboo owns the semantics
if keys:
for k in keys:
self.proto.serve(k, callback if callback else (lambda *_: None))
self.serve_keys(keys, callback if callback else (lambda *_: None))

# Periodic discovery (provided by bamboo)
self._disco: Optional[Discovery] = None
Expand Down Expand Up @@ -186,7 +186,13 @@ def __exit__(self, exc_type, exc, tb): self.stop()

# thin passthroughs to bamboo.Protocol
def request(self, peer_id: str, key: str, payload: Dict[str, Any], ttl_ms: int = 8000):
return self.proto.request_peer(peer_id, key, payload, timeout_s=ttl_ms / 1000.0)
with telemetry.client_call(key, peer_id) as call:
result = self.proto.request_peer(
peer_id, key, payload, timeout_s=ttl_ms / 1000.0,
traceparent=call.traceparent, tracestate=call.tracestate,
)
call.check_request(result)
return result

def rpc(self, peer_id: str, key: str, payload: Dict[str, Any], ttl_ms: int = 8000):
return self.request(peer_id, key, payload, ttl_ms)
Expand All @@ -204,10 +210,12 @@ def broadcast_request(
address book. Our own reply is dropped: on RabbitMQ our queue sits on
the fanout exchange like everyone else's.
"""
msg = MessageBuilder(sourceid=self.self_id).req(key, payload).to(None).build()
with self._reply_queue(msg.env.transid) as replies:
self.proto.send(msg)
return list(self._drain_replies(replies, timeout_s))
with telemetry.client_call(key, None) as call:
msg = (MessageBuilder(sourceid=self.self_id).req(key, payload).to(None)
.trace(call.traceparent, call.tracestate).build())
with self._reply_queue(msg.env.transid) as replies:
self.proto.send(msg)
return list(self._drain_replies(replies, timeout_s))

@contextmanager
def _reply_queue(self, transid: str) -> Iterator["queue.Queue[Any]"]:
Expand All @@ -234,8 +242,9 @@ def _drain_replies(self, replies: "queue.Queue[Any]",
yield BroadcastReply(resp.env.sourceid, dict(resp.env.payload or {}))

def serve_keys(self, keys: List[str], callback: Callable[[dict, dict], Optional[dict]]) -> None:
traced = telemetry.traced_handler(callback)
for k in keys:
self.proto.serve(k, callback)
self.proto.serve(k, traced)

def register_keyword(self, keyword: Keyword) -> None:
if keyword.name in self._keywords:
Expand Down Expand Up @@ -333,14 +342,16 @@ def _keys_read(self, payload: dict, _ctx: dict) -> dict:
return {"ok": True, "values": values}

def listen(self, topic: str, handler: Callable[[Any], None]) -> None:
self.proto.listen(topic, handler)
self.proto.listen(topic, telemetry.traced_listener(topic, handler))

def listen_many(self, handlers: Dict[str, Callable[[Any], None]]) -> None:
for topic, h in handlers.items():
self.listen(topic, h)

def publish(self, topic: str, payload: Dict[str, Any]) -> int:
return self.proto.publish(topic, payload)
with telemetry.publish_call(topic) as call:
return self.proto.publish(topic, payload, traceparent=call.traceparent,
tracestate=call.tracestate)

def emit(self, topic: str, payload: Dict[str, Any]) -> int:
return self.publish(topic, payload)
Expand Down
Loading
Loading