xyberos-observability 0.1.0__tar.gz

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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyberos-observability
3
+ Version: 0.1.0
4
+ Summary: Observability exporters plugin (RFC-0019, M10): OTel, Prometheus, Langfuse, Sentry as thin EventBus exporters
5
+ License: Apache-2.0
6
+ Keywords: xyberos,plugin,observability,opentelemetry,prometheus,langfuse,sentry
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: xyberos>=1.0
10
+ Provides-Extra: otel
11
+ Requires-Dist: opentelemetry-sdk; extra == "otel"
12
+ Provides-Extra: prometheus
13
+ Requires-Dist: prometheus-client; extra == "prometheus"
14
+ Provides-Extra: sentry
15
+ Requires-Dist: sentry-sdk; extra == "sentry"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+
19
+ # xyberos-observability (M10)
20
+
21
+ Observability/telemetry exporters for Xyberos. Turns the runtime's event stream
22
+ into traces and metrics for **OpenTelemetry**, **Prometheus**, **Langfuse**, and
23
+ **Sentry** — satisfying RFC-0019 Track L, milestone M10.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install -e ./observability
29
+ ```
30
+
31
+ Optional extras:
32
+
33
+ ```bash
34
+ pip install -e "./observability[otel]" # opentelemetry-sdk (in-memory spans by default)
35
+ pip install -e "./observability[prometheus]"# prometheus-client
36
+ pip install -e "./observability[sentry]" # sentry-sdk
37
+ ```
38
+
39
+ ## How it works
40
+
41
+ Xyberos emits events (`runtime.request_started`, `brain.response_produced`,
42
+ `memory.stored`, ...) on its `EventBus`. This plugin attaches an
43
+ `EventRecorder` that forwards each event to the configured exporters:
44
+
45
+ | Exporter | Destination | What it does |
46
+ | --- | --- | --- |
47
+ | `OpenTelemetryExporter` | OTel span pipeline | One span per event, named `event.name`, with `event.*` attributes (plus `event.prompt`). Defaults to an `InMemorySpanExporter` so traces are inspectable without a collector. |
48
+ | `PrometheusExporter` | Prometheus registry | `xyberos_events_total{event="..."}` counter, one per event name. |
49
+ | `LangfuseExporter` | Langfuse `/api/public/ingestion` | One `observation-create` item per event with `name`, `input` (prompt) and `output` (event data). Basic auth with `public_key:secret_key`. |
50
+ | `SentryExporter` | Sentry SDK | Adds a breadcrumb per event; captures a message for failures (`runtime.request_failed`, `brain.error`). |
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ from xyberos import create_app
56
+ from xyberos_observability import ObservabilityPlugin, OpenTelemetryExporter
57
+
58
+ app = create_app()
59
+ app.load_plugin(ObservabilityPlugin(exporters=[OpenTelemetryExporter()]))
60
+
61
+ reply = app.chat("hello") # emits events -> spans
62
+ ```
63
+
64
+ Select exporters by environment variable (comma-separated) instead:
65
+
66
+ ```bash
67
+ export OBSERVABILITY_EXPORTERS=otel,prometheus,langfuse,sentry
68
+ ```
69
+
70
+ ```python
71
+ from xyberos_observability import ObservabilityPlugin
72
+
73
+ app.load_plugin(ObservabilityPlugin())
74
+ ```
75
+
76
+ For Langfuse, set `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and
77
+ `LANGFUSE_HOST` (defaults `https://cloud.langfuse.com`).
78
+
79
+ ## Example
80
+
81
+ ```bash
82
+ python examples/trace_a_chat.py # inspect in-memory spans
83
+ python examples/trace_a_chat.py --prometheus # also track a counter
84
+ python examples/trace_a_chat.py --langfuse # POST traces to Langfuse
85
+ ```
86
+
87
+ ## Tests
88
+
89
+ ```bash
90
+ python -m pytest observability/tests -q
91
+ ```
92
+
93
+ Tests use injectable transports / in-memory exporters, so nothing touches the
94
+ network or a real collector. The DoD test (`tests/test_plugin.py`) runs
95
+ `app.chat(...)` and asserts a trace lands in OTel and Langfuse.
@@ -0,0 +1,77 @@
1
+ # xyberos-observability (M10)
2
+
3
+ Observability/telemetry exporters for Xyberos. Turns the runtime's event stream
4
+ into traces and metrics for **OpenTelemetry**, **Prometheus**, **Langfuse**, and
5
+ **Sentry** — satisfying RFC-0019 Track L, milestone M10.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install -e ./observability
11
+ ```
12
+
13
+ Optional extras:
14
+
15
+ ```bash
16
+ pip install -e "./observability[otel]" # opentelemetry-sdk (in-memory spans by default)
17
+ pip install -e "./observability[prometheus]"# prometheus-client
18
+ pip install -e "./observability[sentry]" # sentry-sdk
19
+ ```
20
+
21
+ ## How it works
22
+
23
+ Xyberos emits events (`runtime.request_started`, `brain.response_produced`,
24
+ `memory.stored`, ...) on its `EventBus`. This plugin attaches an
25
+ `EventRecorder` that forwards each event to the configured exporters:
26
+
27
+ | Exporter | Destination | What it does |
28
+ | --- | --- | --- |
29
+ | `OpenTelemetryExporter` | OTel span pipeline | One span per event, named `event.name`, with `event.*` attributes (plus `event.prompt`). Defaults to an `InMemorySpanExporter` so traces are inspectable without a collector. |
30
+ | `PrometheusExporter` | Prometheus registry | `xyberos_events_total{event="..."}` counter, one per event name. |
31
+ | `LangfuseExporter` | Langfuse `/api/public/ingestion` | One `observation-create` item per event with `name`, `input` (prompt) and `output` (event data). Basic auth with `public_key:secret_key`. |
32
+ | `SentryExporter` | Sentry SDK | Adds a breadcrumb per event; captures a message for failures (`runtime.request_failed`, `brain.error`). |
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from xyberos import create_app
38
+ from xyberos_observability import ObservabilityPlugin, OpenTelemetryExporter
39
+
40
+ app = create_app()
41
+ app.load_plugin(ObservabilityPlugin(exporters=[OpenTelemetryExporter()]))
42
+
43
+ reply = app.chat("hello") # emits events -> spans
44
+ ```
45
+
46
+ Select exporters by environment variable (comma-separated) instead:
47
+
48
+ ```bash
49
+ export OBSERVABILITY_EXPORTERS=otel,prometheus,langfuse,sentry
50
+ ```
51
+
52
+ ```python
53
+ from xyberos_observability import ObservabilityPlugin
54
+
55
+ app.load_plugin(ObservabilityPlugin())
56
+ ```
57
+
58
+ For Langfuse, set `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and
59
+ `LANGFUSE_HOST` (defaults `https://cloud.langfuse.com`).
60
+
61
+ ## Example
62
+
63
+ ```bash
64
+ python examples/trace_a_chat.py # inspect in-memory spans
65
+ python examples/trace_a_chat.py --prometheus # also track a counter
66
+ python examples/trace_a_chat.py --langfuse # POST traces to Langfuse
67
+ ```
68
+
69
+ ## Tests
70
+
71
+ ```bash
72
+ python -m pytest observability/tests -q
73
+ ```
74
+
75
+ Tests use injectable transports / in-memory exporters, so nothing touches the
76
+ network or a real collector. The DoD test (`tests/test_plugin.py`) runs
77
+ `app.chat(...)` and asserts a trace lands in OTel and Langfuse.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xyberos-observability"
7
+ version = "0.1.0"
8
+ description = "Observability exporters plugin (RFC-0019, M10): OTel, Prometheus, Langfuse, Sentry as thin EventBus exporters"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = ["xyberos>=1.0"]
13
+ keywords = ["xyberos", "plugin", "observability", "opentelemetry", "prometheus", "langfuse", "sentry"]
14
+
15
+ [project.optional-dependencies]
16
+ otel = ["opentelemetry-sdk"]
17
+ prometheus = ["prometheus-client"]
18
+ sentry = ["sentry-sdk"]
19
+ test = ["pytest"]
20
+
21
+ [project.entry-points."xyberos.plugins"]
22
+ observability = "xyberos_observability.plugin:plugin"
23
+
24
+ [tool.setuptools]
25
+ packages = ["xyberos_observability"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """Tests for the Langfuse exporter (injectable transport, no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ from xyberos.events import Event
9
+ from xyberos.exceptions.provider import ProviderError
10
+
11
+ from xyberos_observability import LangfuseExporter
12
+
13
+
14
+ def _fake_request(seen: dict):
15
+ def request(method, url, **kwargs):
16
+ seen.update({"url": url, "headers": kwargs["headers"], "payload": kwargs["json_body"]})
17
+ return 200, {"success": True}
18
+
19
+ return request
20
+
21
+
22
+ def test_export_sends_batch_item():
23
+ seen: dict = {}
24
+ exporter = LangfuseExporter("pk", "sk", host="https://langfuse.example", request=_fake_request(seen))
25
+ exporter.export(
26
+ Event(name="brain.response_produced", context=SimpleNamespace(prompt="hi"), data={"response": "hello"})
27
+ )
28
+
29
+ assert exporter.count == 1
30
+ assert seen["url"] == "https://langfuse.example/api/public/ingestion"
31
+ assert seen["headers"]["Authorization"].startswith("Basic ")
32
+ batch = seen["payload"]["batch"]
33
+ assert batch[0]["name"] == "brain.response_produced"
34
+ assert batch[0]["input"] == "hi"
35
+ assert batch[0]["output"] == {"response": "hello"}
36
+
37
+
38
+ def test_requires_keys():
39
+ exporter = LangfuseExporter(public_key=None, secret_key=None, request=lambda *a, **k: (200, {}))
40
+ with pytest.raises(ProviderError, match="LANGFUSE_PUBLIC_KEY"):
41
+ exporter.export(Event(name="kernel.started"))
@@ -0,0 +1,33 @@
1
+ """Tests for the OpenTelemetry exporter (in-memory spans, no collector)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from types import SimpleNamespace
7
+
8
+ import pytest
9
+ from xyberos.events import Event
10
+
11
+ from xyberos_observability import OpenTelemetryExporter
12
+
13
+ pytestmark = pytest.mark.skipif(
14
+ importlib.util.find_spec("opentelemetry") is None,
15
+ reason="opentelemetry-sdk is not installed",
16
+ )
17
+
18
+
19
+ def test_events_become_spans():
20
+ exporter = OpenTelemetryExporter()
21
+ exporter.export(Event(name="runtime.request_started", context=SimpleNamespace(prompt="hi"), data={"x": 1}))
22
+ exporter.export(Event(name="brain.response_produced", data={"response": "hello"}))
23
+
24
+ spans = exporter.spans()
25
+ assert [span.name for span in spans] == ["runtime.request_started", "brain.response_produced"]
26
+ assert spans[0].attributes["event.prompt"] == "hi"
27
+ assert spans[1].attributes["event.response"] == "hello"
28
+
29
+
30
+ def test_exporter_is_callable():
31
+ exporter = OpenTelemetryExporter()
32
+ exporter(Event(name="kernel.started"))
33
+ assert [span.name for span in exporter.spans()] == ["kernel.started"]
@@ -0,0 +1,62 @@
1
+ """M10 DoD: a trace lands in OTel / Langfuse from ``app.chat(...)``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ import pytest
8
+ from xyberos import create_app
9
+
10
+ from xyberos_observability import LangfuseExporter, ObservabilityPlugin, OpenTelemetryExporter
11
+
12
+
13
+ def test_plugin_conforms_to_contract():
14
+ plugin = ObservabilityPlugin()
15
+ assert plugin.name == "observability"
16
+ assert callable(plugin.register) and callable(plugin.unregister)
17
+
18
+
19
+ def test_unconfigured_register_is_safe():
20
+ app = create_app()
21
+ app.load_plugin(ObservabilityPlugin()) # no exporters -> no-op
22
+ assert app.plugins.names == ("observability",)
23
+ app.unload_plugin("observability")
24
+
25
+
26
+ @pytest.mark.skipif(importlib.util.find_spec("opentelemetry") is None, reason="opentelemetry-sdk not installed")
27
+ def test_trace_lands_in_otel_from_chat():
28
+ otel = OpenTelemetryExporter()
29
+ app = create_app()
30
+ app.load_plugin(ObservabilityPlugin(exporters=[otel]))
31
+
32
+ app.chat("hello") # the DoD: a trace lands in OTel from app.chat(...)
33
+
34
+ app.unload_plugin("observability")
35
+ names = [span.name for span in otel.spans()]
36
+ assert any("request" in name for name in names)
37
+ assert any("response" in name for name in names)
38
+
39
+
40
+ def test_trace_lands_in_langfuse_from_chat():
41
+ sent: list[dict] = []
42
+
43
+ def request(method, url, **kwargs):
44
+ sent.append(kwargs["json_body"])
45
+ return 200, {}
46
+
47
+ langfuse = LangfuseExporter("pk", "sk", host="https://langfuse.example", request=request)
48
+ app = create_app()
49
+ app.load_plugin(ObservabilityPlugin(exporters=[langfuse]))
50
+
51
+ app.chat("hello")
52
+
53
+ app.unload_plugin("observability")
54
+ assert sent, "expected at least one ingestion payload"
55
+ names = {item["batch"][0]["name"] for item in sent}
56
+ assert "brain.response_produced" in names
57
+
58
+
59
+ def test_unknown_exporter_raises(monkeypatch):
60
+ monkeypatch.setenv("OBSERVABILITY_EXPORTERS", "bogus")
61
+ with pytest.raises(ValueError, match="unknown exporter"):
62
+ ObservabilityPlugin().exporters()
@@ -0,0 +1,37 @@
1
+ """Tests for the Prometheus exporter (isolated registry)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ import pytest
8
+ from xyberos.events import Event
9
+
10
+ from xyberos_observability import PrometheusExporter
11
+
12
+ pytestmark = pytest.mark.skipif(
13
+ importlib.util.find_spec("prometheus_client") is None,
14
+ reason="prometheus-client is not installed",
15
+ )
16
+
17
+
18
+ def test_counter_increments_per_event_name():
19
+ from prometheus_client import CollectorRegistry
20
+
21
+ registry = CollectorRegistry()
22
+ exporter = PrometheusExporter(registry=registry)
23
+ exporter.export(Event(name="brain.response_produced"))
24
+ exporter.export(Event(name="brain.response_produced"))
25
+ exporter.export(Event(name="memory.stored"))
26
+
27
+ assert exporter.value("brain.response_produced") == 2.0
28
+ assert exporter.value("memory.stored") == 1.0
29
+ assert exporter.value("never.emitted") == 0.0
30
+
31
+
32
+ def test_value_without_injected_registry():
33
+ # default (global) registry — value() must still reflect counts
34
+ exporter = PrometheusExporter()
35
+ exporter.export(Event(name="kernel.started"))
36
+ exporter.export(Event(name="kernel.started"))
37
+ assert exporter.value("kernel.started") == 2.0
@@ -0,0 +1,23 @@
1
+ """Tests for the Sentry exporter (injected capture/breadcrumb functions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xyberos.events import Event
6
+
7
+ from xyberos_observability import SentryExporter
8
+
9
+
10
+ def test_breadcrumb_and_capture():
11
+ breadcrumbs: list[dict] = []
12
+ captures: list[str] = []
13
+
14
+ exporter = SentryExporter(
15
+ add_breadcrumb=lambda b: breadcrumbs.append(b),
16
+ capture_message=lambda m: captures.append(m),
17
+ )
18
+ exporter.export(Event(name="brain.response_produced", data={"response": "hello"}))
19
+ exporter.export(Event(name="runtime.request_failed", data={"error": "boom"}))
20
+
21
+ assert breadcrumbs[0]["message"] == "brain.response_produced"
22
+ assert breadcrumbs[0]["data"] == {"response": "hello"}
23
+ assert captures[0].startswith("Xyberos runtime.request_failed")
@@ -0,0 +1,20 @@
1
+ """Observability exporters plugin (RFC-0019, M10).
2
+
3
+ Thin :class:`~xyberos.events.Exporter` callables that plug into the core
4
+ ``EventBus`` through an ``EventRecorder`` — the observability hub stays inside
5
+ Xyberos. Provides OpenTelemetry, Prometheus, Langfuse and Sentry exporters.
6
+ """
7
+
8
+ from .langfuse import LangfuseExporter
9
+ from .otel import OpenTelemetryExporter
10
+ from .plugin import ObservabilityPlugin
11
+ from .prometheus import PrometheusExporter
12
+ from .sentry import SentryExporter
13
+
14
+ __all__ = [
15
+ "LangfuseExporter",
16
+ "ObservabilityPlugin",
17
+ "OpenTelemetryExporter",
18
+ "PrometheusExporter",
19
+ "SentryExporter",
20
+ ]
@@ -0,0 +1,60 @@
1
+ """A tiny stdlib HTTP helper (no third-party deps).
2
+
3
+ ``default_request`` performs one HTTP request with ``urllib`` and returns
4
+ ``(status, body)`` where ``body`` is parsed JSON when the response is JSON,
5
+ otherwise raw text. Injectable so tests run without a network.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any, Callable
15
+
16
+ #: (method, url, *, json_body, raw_body, headers, query, timeout) -> (status, body)
17
+ RequestTransport = Callable[..., tuple[int, Any]]
18
+
19
+
20
+ def default_request(
21
+ method: str,
22
+ url: str,
23
+ *,
24
+ json_body: Any = None,
25
+ raw_body: bytes | None = None,
26
+ headers: dict[str, str] | None = None,
27
+ query: dict[str, Any] | None = None,
28
+ timeout: float = 30.0,
29
+ ) -> tuple[int, Any]:
30
+ """Send one request and return ``(status, parsed_json_or_text)``."""
31
+ final_url = url
32
+ if query:
33
+ separator = "&" if "?" in url else "?"
34
+ final_url = url + separator + urllib.parse.urlencode(query)
35
+
36
+ data: bytes | None = None
37
+ request_headers = dict(headers or {})
38
+ if raw_body is not None:
39
+ data = raw_body
40
+ elif json_body is not None:
41
+ data = json.dumps(json_body).encode("utf-8")
42
+ request_headers.setdefault("Content-Type", "application/json")
43
+
44
+ request = urllib.request.Request(
45
+ final_url, data=data, headers=request_headers, method=method
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(request, timeout=timeout) as response:
49
+ raw = response.read()
50
+ content_type = response.headers.get("Content-Type", "")
51
+ except urllib.error.HTTPError as exc:
52
+ return exc.code, exc.read().decode("utf-8", errors="replace")
53
+
54
+ text = raw.decode("utf-8", errors="replace")
55
+ if "application/json" in content_type or text.lstrip().startswith("{"):
56
+ try:
57
+ return 200, json.loads(text)
58
+ except json.JSONDecodeError:
59
+ return 200, text
60
+ return 200, text
@@ -0,0 +1,76 @@
1
+ """Langfuse exporter — pushes events to the Langfuse ingestion API (stdlib HTTP).
2
+
3
+ Each event becomes one ``observation-create`` batch item. The transport is
4
+ injectable so tests run without a network; the exporter also records what it
5
+ has sent for inspection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+ import time
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from xyberos.events import Event
17
+ from xyberos.exceptions.provider import ProviderError
18
+
19
+ from .http import RequestTransport, default_request
20
+
21
+
22
+ class LangfuseExporter:
23
+ """Sends each Xyberos event to Langfuse's ``/api/public/ingestion``."""
24
+
25
+ def __init__(
26
+ self,
27
+ public_key: str | None = None,
28
+ secret_key: str | None = None,
29
+ *,
30
+ host: str | None = None,
31
+ request: RequestTransport | None = None,
32
+ timeout: float = 30.0,
33
+ ) -> None:
34
+ self._public_key = public_key if public_key is not None else os.getenv("LANGFUSE_PUBLIC_KEY")
35
+ self._secret_key = secret_key if secret_key is not None else os.getenv("LANGFUSE_SECRET_KEY")
36
+ self._host = (host or os.getenv("LANGFUSE_HOST") or "https://cloud.langfuse.com").rstrip("/")
37
+ self._request = request or default_request
38
+ self._timeout = timeout
39
+ self.sent: list[dict[str, Any]] = []
40
+ self._last_status: int | None = None
41
+
42
+ @property
43
+ def count(self) -> int:
44
+ return len(self.sent)
45
+
46
+ def export(self, event: Event) -> None:
47
+ if not (self._public_key and self._secret_key):
48
+ raise ProviderError(
49
+ "Langfuse requires LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY"
50
+ )
51
+ prompt = getattr(event.context, "prompt", None)
52
+ payload: dict[str, Any] = {
53
+ "batch": [
54
+ {
55
+ "id": uuid.uuid4().hex,
56
+ "type": "observation-create",
57
+ "timestamp": time.time() * 1000,
58
+ "name": event.name,
59
+ "input": str(prompt) if prompt else None,
60
+ "output": dict(event.data or {}),
61
+ }
62
+ ]
63
+ }
64
+ credentials = base64.b64encode(f"{self._public_key}:{self._secret_key}".encode()).decode("ascii")
65
+ status, _body = self._request(
66
+ "POST",
67
+ f"{self._host}/api/public/ingestion",
68
+ json_body=payload,
69
+ headers={"Authorization": f"Basic {credentials}"},
70
+ timeout=self._timeout,
71
+ )
72
+ self._last_status = status
73
+ self.sent.append(payload)
74
+
75
+ def __call__(self, event: Event) -> None:
76
+ self.export(event)
@@ -0,0 +1,75 @@
1
+ """OpenTelemetry exporter — one span per Xyberos event (lazy SDK).
2
+
3
+ Builds a default SDK ``TracerProvider`` with a ``SimpleSpanProcessor`` on an
4
+ ``InMemorySpanExporter`` so traces are inspectable in tests; a ``tracer`` (or a
5
+ ``span_exporter``) can be injected. Import ``opentelemetry-sdk`` lazily.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from typing import Any
12
+
13
+ from xyberos.events import Event
14
+ from xyberos.exceptions.provider import ProviderError
15
+
16
+
17
+ class OpenTelemetryExporter:
18
+ """Turns each :class:`~xyberos.events.Event` into an OTel span."""
19
+
20
+ def __init__(self, *, tracer: Any | None = None, span_exporter: Any | None = None) -> None:
21
+ self._tracer = tracer
22
+ self._span_exporter = span_exporter
23
+
24
+ def export(self, event: Event) -> None:
25
+ tracer = self._get_tracer()
26
+ with tracer.start_as_current_span(event.name) as span:
27
+ span.set_attribute("event.name", event.name)
28
+ for key, value in (event.data or {}).items():
29
+ span.set_attribute(f"event.{key}", str(value))
30
+ prompt = getattr(event.context, "prompt", None)
31
+ if prompt:
32
+ span.set_attribute("event.prompt", str(prompt))
33
+
34
+ def __call__(self, event: Event) -> None:
35
+ self.export(event)
36
+
37
+ def spans(self) -> list[Any]:
38
+ """Return finished spans from the injected/exposed span exporter."""
39
+ exporter = self._get_span_exporter()
40
+ return list(exporter.get_finished_spans()) if exporter is not None else []
41
+
42
+ # -- internals ----------------------------------------------------------
43
+
44
+ def _get_tracer(self) -> Any:
45
+ if self._tracer is not None:
46
+ return self._tracer
47
+ try:
48
+ opentelemetry_trace = importlib.import_module("opentelemetry.trace")
49
+ otel_sdk = importlib.import_module("opentelemetry.sdk.trace")
50
+ except ImportError as exc:
51
+ raise ProviderError(
52
+ "OpenTelemetry requires 'opentelemetry-sdk'; install with "
53
+ "'pip install xyberos-observability[otel]'"
54
+ ) from exc
55
+ provider = otel_sdk.TracerProvider()
56
+ provider.add_span_processor(self._span_processor(self._get_span_exporter()))
57
+ self._tracer = opentelemetry_trace.get_tracer("xyberos", tracer_provider=provider)
58
+ return self._tracer
59
+
60
+ def _get_span_exporter(self) -> Any:
61
+ if self._span_exporter is not None:
62
+ return self._span_exporter
63
+ try:
64
+ in_memory = importlib.import_module(
65
+ "opentelemetry.sdk.trace.export.in_memory_span_exporter"
66
+ )
67
+ except ImportError as exc: # pragma: no cover - guarded by _get_tracer
68
+ raise ProviderError("OpenTelemetry SDK is not installed") from exc
69
+ self._span_exporter = in_memory.InMemorySpanExporter()
70
+ return self._span_exporter
71
+
72
+ @staticmethod
73
+ def _span_processor(exporter: Any) -> Any:
74
+ export_module = importlib.import_module("opentelemetry.sdk.trace.export")
75
+ return export_module.SimpleSpanProcessor(exporter)
@@ -0,0 +1,91 @@
1
+ """Observability plugin entry point (RFC-0019, M10).
2
+
3
+ Wires an :class:`~xyberos.events.EventRecorder` (with the configured exporters)
4
+ into the app's ``EventBus``. Exporters are selected by name
5
+ (``OBSERVABILITY_EXPORTERS`` = comma-separated ``otel``, ``prometheus``,
6
+ ``langfuse``, ``sentry``) or passed explicitly. With none configured the
7
+ plugin registers nothing (logs a warning).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Any
14
+
15
+ from xyberos.contracts import Plugin
16
+ from xyberos.events import EventRecorder, Exporter
17
+
18
+ from .langfuse import LangfuseExporter
19
+ from .otel import OpenTelemetryExporter
20
+ from .prometheus import PrometheusExporter
21
+ from .sentry import SentryExporter
22
+
23
+ EXPORTER_BUILDERS: dict[str, Any] = {
24
+ "otel": lambda: OpenTelemetryExporter(),
25
+ "prometheus": lambda: PrometheusExporter(),
26
+ "langfuse": lambda: LangfuseExporter(),
27
+ "sentry": lambda: SentryExporter(),
28
+ }
29
+
30
+
31
+ class ObservabilityPlugin(Plugin):
32
+ """Subscribes an ``EventRecorder`` with the configured exporters."""
33
+
34
+ def __init__(
35
+ self,
36
+ exporters: list[Exporter] | None = None,
37
+ *,
38
+ env_prefix: str = "OBSERVABILITY",
39
+ ) -> None:
40
+ self._exporters_arg = exporters
41
+ self._env_prefix = env_prefix
42
+ self._recorder: EventRecorder | None = None
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ return "observability"
47
+
48
+ def exporters(self) -> list[Exporter]:
49
+ if self._exporters_arg is not None:
50
+ return list(self._exporters_arg)
51
+ names = [
52
+ name.strip().lower()
53
+ for name in os.getenv(f"{self._env_prefix}_EXPORTERS", "").split(",")
54
+ if name.strip()
55
+ ]
56
+ built: list[Exporter] = []
57
+ for name in names:
58
+ if name not in EXPORTER_BUILDERS:
59
+ raise ValueError(
60
+ f"unknown exporter '{name}' (choose from {sorted(EXPORTER_BUILDERS)})"
61
+ )
62
+ built.append(EXPORTER_BUILDERS[name]())
63
+ return built
64
+
65
+ def register(self, kernel: object) -> None:
66
+ try:
67
+ exporters = self.exporters()
68
+ except ValueError as exc:
69
+ logger = getattr(kernel, "logger", None)
70
+ if logger is not None and callable(getattr(logger, "warning", None)):
71
+ logger.warning("observability plugin not configured: %s", exc)
72
+ return
73
+ if not exporters:
74
+ logger = getattr(kernel, "logger", None)
75
+ if logger is not None and callable(getattr(logger, "warning", None)):
76
+ logger.warning(
77
+ "observability plugin not configured: set %s_EXPORTERS", self._env_prefix
78
+ )
79
+ return
80
+ events = kernel.resolve("events")
81
+ self._recorder = EventRecorder(exporters=exporters).subscribe_to(events)
82
+ kernel.register("observability", self._recorder, replace=True)
83
+
84
+ def unregister(self, kernel: object) -> None:
85
+ if self._recorder is not None:
86
+ self._recorder.unsubscribe_from(kernel.resolve("events"))
87
+ self._recorder = None
88
+
89
+
90
+ #: Auto-discovered by ``app.load_entry_points()``.
91
+ plugin = ObservabilityPlugin()
@@ -0,0 +1,52 @@
1
+ """Prometheus exporter — counts Xyberos events per name (lazy ``prometheus_client``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+ from xyberos.events import Event
10
+ from xyberos.exceptions.provider import ProviderError
11
+
12
+
13
+ class PrometheusExporter:
14
+ """Increments a ``xyberos_events_total`` counter labeled by event name."""
15
+
16
+ METRIC_NAME = "xyberos_events_total"
17
+
18
+ def __init__(self, *, registry: Any | None = None) -> None:
19
+ self._registry = registry
20
+ self._counter: Any = None
21
+ self._counts: dict[str, int] = defaultdict(int)
22
+
23
+ def export(self, event: Event) -> None:
24
+ self._counts[event.name] += 1
25
+ self._get_counter().labels(event=event.name).inc()
26
+
27
+ def __call__(self, event: Event) -> None:
28
+ self.export(event)
29
+
30
+ def value(self, event_name: str) -> float:
31
+ """The current count for ``event_name`` (0.0 when never emitted)."""
32
+ return float(self._counts.get(event_name, 0.0))
33
+
34
+ # -- internals ----------------------------------------------------------
35
+
36
+ def _get_counter(self) -> Any:
37
+ if self._counter is not None:
38
+ return self._counter
39
+ try:
40
+ prometheus_client = importlib.import_module("prometheus_client")
41
+ except ImportError as exc:
42
+ raise ProviderError(
43
+ "Prometheus requires 'prometheus-client'; install with "
44
+ "'pip install xyberos-observability[prometheus]'"
45
+ ) from exc
46
+ self._counter = prometheus_client.Counter(
47
+ self.METRIC_NAME,
48
+ "Xyberos events by name",
49
+ ["event"],
50
+ registry=self._registry,
51
+ )
52
+ return self._counter
@@ -0,0 +1,74 @@
1
+ """Sentry exporter — breadcrumbs for events, captures for failures (lazy SDK).
2
+
3
+ The capture/breadcrumb functions are injectable so the mapping logic is fully
4
+ testable without ``sentry-sdk``; when not injected the SDK is imported lazily.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import os
11
+ from typing import Any, Callable
12
+
13
+ from xyberos.events import BRAIN_ERROR, REQUEST_FAILED, Event
14
+ from xyberos.exceptions.provider import ProviderError
15
+
16
+ #: Callables injectable for tests (mirror sentry_sdk's add_breadcrumb/capture_message).
17
+ AddBreadcrumb = Callable[[dict[str, Any]], None]
18
+ CaptureMessage = Callable[[str], None]
19
+
20
+
21
+ class SentryExporter:
22
+ """Adds a breadcrumb per event; captures failures as Sentry messages."""
23
+
24
+ _FAILURE_EVENTS = {REQUEST_FAILED, BRAIN_ERROR}
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ dsn: str | None = None,
30
+ add_breadcrumb: AddBreadcrumb | None = None,
31
+ capture_message: CaptureMessage | None = None,
32
+ ) -> None:
33
+ self._dsn = dsn if dsn is not None else os.getenv("SENTRY_DSN")
34
+ self._add_breadcrumb = add_breadcrumb
35
+ self._capture_message = capture_message
36
+
37
+ def export(self, event: Event) -> None:
38
+ if event.name in self._FAILURE_EVENTS:
39
+ self._capture(f"Xyberos {event.name}: {dict(event.data or {})}")
40
+ else:
41
+ self._breadcrumb(event)
42
+
43
+ def __call__(self, event: Event) -> None:
44
+ self.export(event)
45
+
46
+ def _breadcrumb(self, event: Event) -> None:
47
+ if self._add_breadcrumb is not None:
48
+ self._add_breadcrumb(
49
+ {"category": "xyberos.event", "message": event.name, "data": dict(event.data or {})}
50
+ )
51
+ return
52
+ self._ensure_sdk()
53
+ sentry_sdk.add_breadcrumb(category="xyberos.event", message=event.name, data=dict(event.data or {}))
54
+
55
+ def _capture(self, message: str) -> None:
56
+ if self._capture_message is not None:
57
+ self._capture_message(message)
58
+ return
59
+ self._ensure_sdk()
60
+ sentry_sdk.capture_message(message)
61
+
62
+ def _ensure_sdk(self) -> None:
63
+ global sentry_sdk # noqa: PLW0603 - lazy module binding
64
+ if "sentry_sdk" not in globals():
65
+ try:
66
+ sentry_sdk = importlib.import_module("sentry_sdk")
67
+ except ImportError as exc:
68
+ raise ProviderError(
69
+ "the 'sentry-sdk' package is required; install with "
70
+ "'pip install xyberos-observability[sentry]'"
71
+ ) from exc
72
+ globals()["sentry_sdk"] = sentry_sdk
73
+ if self._dsn:
74
+ sentry_sdk.init(dsn=self._dsn, traces_sample_rate=1.0)
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyberos-observability
3
+ Version: 0.1.0
4
+ Summary: Observability exporters plugin (RFC-0019, M10): OTel, Prometheus, Langfuse, Sentry as thin EventBus exporters
5
+ License: Apache-2.0
6
+ Keywords: xyberos,plugin,observability,opentelemetry,prometheus,langfuse,sentry
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: xyberos>=1.0
10
+ Provides-Extra: otel
11
+ Requires-Dist: opentelemetry-sdk; extra == "otel"
12
+ Provides-Extra: prometheus
13
+ Requires-Dist: prometheus-client; extra == "prometheus"
14
+ Provides-Extra: sentry
15
+ Requires-Dist: sentry-sdk; extra == "sentry"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+
19
+ # xyberos-observability (M10)
20
+
21
+ Observability/telemetry exporters for Xyberos. Turns the runtime's event stream
22
+ into traces and metrics for **OpenTelemetry**, **Prometheus**, **Langfuse**, and
23
+ **Sentry** — satisfying RFC-0019 Track L, milestone M10.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install -e ./observability
29
+ ```
30
+
31
+ Optional extras:
32
+
33
+ ```bash
34
+ pip install -e "./observability[otel]" # opentelemetry-sdk (in-memory spans by default)
35
+ pip install -e "./observability[prometheus]"# prometheus-client
36
+ pip install -e "./observability[sentry]" # sentry-sdk
37
+ ```
38
+
39
+ ## How it works
40
+
41
+ Xyberos emits events (`runtime.request_started`, `brain.response_produced`,
42
+ `memory.stored`, ...) on its `EventBus`. This plugin attaches an
43
+ `EventRecorder` that forwards each event to the configured exporters:
44
+
45
+ | Exporter | Destination | What it does |
46
+ | --- | --- | --- |
47
+ | `OpenTelemetryExporter` | OTel span pipeline | One span per event, named `event.name`, with `event.*` attributes (plus `event.prompt`). Defaults to an `InMemorySpanExporter` so traces are inspectable without a collector. |
48
+ | `PrometheusExporter` | Prometheus registry | `xyberos_events_total{event="..."}` counter, one per event name. |
49
+ | `LangfuseExporter` | Langfuse `/api/public/ingestion` | One `observation-create` item per event with `name`, `input` (prompt) and `output` (event data). Basic auth with `public_key:secret_key`. |
50
+ | `SentryExporter` | Sentry SDK | Adds a breadcrumb per event; captures a message for failures (`runtime.request_failed`, `brain.error`). |
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ from xyberos import create_app
56
+ from xyberos_observability import ObservabilityPlugin, OpenTelemetryExporter
57
+
58
+ app = create_app()
59
+ app.load_plugin(ObservabilityPlugin(exporters=[OpenTelemetryExporter()]))
60
+
61
+ reply = app.chat("hello") # emits events -> spans
62
+ ```
63
+
64
+ Select exporters by environment variable (comma-separated) instead:
65
+
66
+ ```bash
67
+ export OBSERVABILITY_EXPORTERS=otel,prometheus,langfuse,sentry
68
+ ```
69
+
70
+ ```python
71
+ from xyberos_observability import ObservabilityPlugin
72
+
73
+ app.load_plugin(ObservabilityPlugin())
74
+ ```
75
+
76
+ For Langfuse, set `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` and
77
+ `LANGFUSE_HOST` (defaults `https://cloud.langfuse.com`).
78
+
79
+ ## Example
80
+
81
+ ```bash
82
+ python examples/trace_a_chat.py # inspect in-memory spans
83
+ python examples/trace_a_chat.py --prometheus # also track a counter
84
+ python examples/trace_a_chat.py --langfuse # POST traces to Langfuse
85
+ ```
86
+
87
+ ## Tests
88
+
89
+ ```bash
90
+ python -m pytest observability/tests -q
91
+ ```
92
+
93
+ Tests use injectable transports / in-memory exporters, so nothing touches the
94
+ network or a real collector. The DoD test (`tests/test_plugin.py`) runs
95
+ `app.chat(...)` and asserts a trace lands in OTel and Langfuse.
@@ -0,0 +1,20 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_langfuse.py
4
+ tests/test_otel.py
5
+ tests/test_plugin.py
6
+ tests/test_prometheus.py
7
+ tests/test_sentry.py
8
+ xyberos_observability/__init__.py
9
+ xyberos_observability/http.py
10
+ xyberos_observability/langfuse.py
11
+ xyberos_observability/otel.py
12
+ xyberos_observability/plugin.py
13
+ xyberos_observability/prometheus.py
14
+ xyberos_observability/sentry.py
15
+ xyberos_observability.egg-info/PKG-INFO
16
+ xyberos_observability.egg-info/SOURCES.txt
17
+ xyberos_observability.egg-info/dependency_links.txt
18
+ xyberos_observability.egg-info/entry_points.txt
19
+ xyberos_observability.egg-info/requires.txt
20
+ xyberos_observability.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [xyberos.plugins]
2
+ observability = xyberos_observability.plugin:plugin
@@ -0,0 +1,13 @@
1
+ xyberos>=1.0
2
+
3
+ [otel]
4
+ opentelemetry-sdk
5
+
6
+ [prometheus]
7
+ prometheus-client
8
+
9
+ [sentry]
10
+ sentry-sdk
11
+
12
+ [test]
13
+ pytest
@@ -0,0 +1 @@
1
+ xyberos_observability