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.
- xyberos_observability-0.1.0/PKG-INFO +95 -0
- xyberos_observability-0.1.0/README.md +77 -0
- xyberos_observability-0.1.0/pyproject.toml +29 -0
- xyberos_observability-0.1.0/setup.cfg +4 -0
- xyberos_observability-0.1.0/tests/test_langfuse.py +41 -0
- xyberos_observability-0.1.0/tests/test_otel.py +33 -0
- xyberos_observability-0.1.0/tests/test_plugin.py +62 -0
- xyberos_observability-0.1.0/tests/test_prometheus.py +37 -0
- xyberos_observability-0.1.0/tests/test_sentry.py +23 -0
- xyberos_observability-0.1.0/xyberos_observability/__init__.py +20 -0
- xyberos_observability-0.1.0/xyberos_observability/http.py +60 -0
- xyberos_observability-0.1.0/xyberos_observability/langfuse.py +76 -0
- xyberos_observability-0.1.0/xyberos_observability/otel.py +75 -0
- xyberos_observability-0.1.0/xyberos_observability/plugin.py +91 -0
- xyberos_observability-0.1.0/xyberos_observability/prometheus.py +52 -0
- xyberos_observability-0.1.0/xyberos_observability/sentry.py +74 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/PKG-INFO +95 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/SOURCES.txt +20 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/dependency_links.txt +1 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/entry_points.txt +2 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/requires.txt +13 -0
- xyberos_observability-0.1.0/xyberos_observability.egg-info/top_level.txt +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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_observability
|