elinaobs 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.
- elinaobs-0.1.0/LICENSE +23 -0
- elinaobs-0.1.0/PKG-INFO +120 -0
- elinaobs-0.1.0/README.md +100 -0
- elinaobs-0.1.0/pyproject.toml +28 -0
- elinaobs-0.1.0/setup.cfg +4 -0
- elinaobs-0.1.0/src/elinaobs/__init__.py +7 -0
- elinaobs-0.1.0/src/elinaobs/context.py +6 -0
- elinaobs-0.1.0/src/elinaobs/decorators.py +106 -0
- elinaobs-0.1.0/src/elinaobs/processing.py +57 -0
- elinaobs-0.1.0/src/elinaobs/runtime.py +253 -0
- elinaobs-0.1.0/src/elinaobs/serialization.py +62 -0
- elinaobs-0.1.0/src/elinaobs/types.py +20 -0
- elinaobs-0.1.0/src/elinaobs.egg-info/PKG-INFO +120 -0
- elinaobs-0.1.0/src/elinaobs.egg-info/SOURCES.txt +23 -0
- elinaobs-0.1.0/src/elinaobs.egg-info/dependency_links.txt +1 -0
- elinaobs-0.1.0/src/elinaobs.egg-info/requires.txt +10 -0
- elinaobs-0.1.0/src/elinaobs.egg-info/top_level.txt +1 -0
- elinaobs-0.1.0/tests/test_collector.py +83 -0
- elinaobs-0.1.0/tests/test_diagnostics.py +84 -0
- elinaobs-0.1.0/tests/test_error_trace_example.py +10 -0
- elinaobs-0.1.0/tests/test_feedback.py +46 -0
- elinaobs-0.1.0/tests/test_observation_types_example.py +12 -0
- elinaobs-0.1.0/tests/test_otlp_http.py +173 -0
- elinaobs-0.1.0/tests/test_serialization.py +53 -0
- elinaobs-0.1.0/tests/test_trace.py +162 -0
elinaobs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ElinaObs Contributors
|
|
4
|
+
|
|
5
|
+
Third-party files retain their original copyright notices and licenses.
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
elinaobs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elinaobs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic Python LLM tracing using OpenTelemetry
|
|
5
|
+
Author: ElinaObs Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: opentelemetry-api<2,>=1.36
|
|
11
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.36
|
|
12
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.36
|
|
13
|
+
Requires-Dist: requests<3,>=2
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
18
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1,>=0.57b0; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# ElinaObs Python SDK
|
|
22
|
+
|
|
23
|
+
Python tracing SDK for self-hosted Elina. Licensed under MIT.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Once published on PyPI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install elinaobs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For development from this repository:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install -e '.[dev]'
|
|
37
|
+
pytest -q
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Public API
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from elinaobs import init, trace, flush, shutdown, diagnose, submit_feedback
|
|
44
|
+
|
|
45
|
+
init(api_key="...", endpoint="http://localhost:3000/v1/traces", service_name="my-agent")
|
|
46
|
+
|
|
47
|
+
@trace(name="Agent", as_type="agent")
|
|
48
|
+
async def run(question):
|
|
49
|
+
return await your_code(question)
|
|
50
|
+
|
|
51
|
+
# The existing OTel-instrumented operations called inside run() are children.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Application feedback is optional. After a trace has finished, an app with a verdict can send it using the same project key. Inside a decorated function, capture the trace ID and keep it with the result:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from opentelemetry import trace as otel_trace
|
|
58
|
+
trace_id = f"{otel_trace.get_current_span().get_span_context().trace_id:032x}"
|
|
59
|
+
# Later, after the traced function returns:
|
|
60
|
+
submit_feedback(trace_id, "bad", note="Wrong answer") # trace_id is 32 lowercase hex characters
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Apps without a feedback signal do not call this function. Feedback is stored separately from the dashboard's human review. Keep the project key in server-side code, not in a browser.
|
|
64
|
+
|
|
65
|
+
To send a demo trace to the local Elina server, create a project in the dashboard and copy its one-time ingestion key. Then, from this directory in PowerShell:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
$env:ELINA_WRITE_KEY = "paste-project-ingestion-key"
|
|
69
|
+
$env:ELINA_BASE_URL = "http://127.0.0.1:3000"
|
|
70
|
+
python examples/agent.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The script prints the trace ID and reports whether export succeeded. Open the project’s Traces page and select that ID to inspect the three spans and their captured input/output.
|
|
74
|
+
|
|
75
|
+
Only operations that are explicitly decorated or already instrumented by a compatible OpenTelemetry instrumentation package create spans. **No framework integrations are installed, imported, or configured by ElinaObs.**
|
|
76
|
+
|
|
77
|
+
The Elina receiver lives in the sibling `elina-server` directory. Start it before running `examples/agent.py`. The default tests use in-memory and local HTTP receivers.
|
|
78
|
+
|
|
79
|
+
To also verify export through a real OpenTelemetry Collector, set `OTELCOL_BINARY` to the Collector executable path and run `python -m pytest -q tests/test_collector.py`. This optional test starts a local Collector with a file exporter and checks the received spans.
|
|
80
|
+
|
|
81
|
+
## What it does
|
|
82
|
+
|
|
83
|
+
- Sync + async function decorator with native OTel parent context.
|
|
84
|
+
- 10 span display classifications using `elinaobs.observation.type`.
|
|
85
|
+
- Selective export: only spans starting inside a `@trace()` scope.
|
|
86
|
+
- OTLP/HTTP protobuf, bearer-token auth, background batching.
|
|
87
|
+
- Optional bounded input/output capture, disabled by default.
|
|
88
|
+
- An optional `redact` callback can remove secrets from captured free text before export.
|
|
89
|
+
- `attribute_allowlist` and `attribute_denylist` filter span, event, and link attributes in ElinaObs exports; the denylist wins when both contain a key.
|
|
90
|
+
- Exceptions automatically record OTel error status/events.
|
|
91
|
+
- Shares an existing OpenTelemetry SDK TracerProvider when present.
|
|
92
|
+
- `flush()` reports failed export batches or dropped spans. `diagnose()` returns `failed_batches`, `dropped_spans`, and `retry_attempts` without exposing credentials. Drop counts are updated after a successful processor flush; retry attempts count extra HTTP requests made for a batch.
|
|
93
|
+
|
|
94
|
+
## Non-goals for v0.1
|
|
95
|
+
|
|
96
|
+
- Framework adapters or `integrations=[...]`.
|
|
97
|
+
- Raw threads, multiprocessing, distributed orchestration, or generator tracing.
|
|
98
|
+
- Automatic patching of uninstrumented Python libraries.
|
|
99
|
+
- Prompt management, evaluations, or server/UI implementation.
|
|
100
|
+
|
|
101
|
+
## Known limitations before production
|
|
102
|
+
|
|
103
|
+
- SDK processor cannot modify existing OTel provider sampling or resources.
|
|
104
|
+
- Capture filtering uses a ContextVar marker; parent spans begun before a
|
|
105
|
+
decorator scope are not exported to ElinaObs unless also eligible.
|
|
106
|
+
- `as_type="event"` is a timed span category, not an OTel instant event.
|
|
107
|
+
- Incoming external spans from `opentelemetry-instrument` share global provider
|
|
108
|
+
and may require controlling initialization order.
|
|
109
|
+
- Bounded batches can drop data on overload. `dropped_spans` is current only
|
|
110
|
+
after `flush()` or `shutdown()`. `retry_attempts` is `None` when an OpenTelemetry
|
|
111
|
+
credential-provider session is configured. Export failures remain counted
|
|
112
|
+
even if later batches succeed.
|
|
113
|
+
- Input/output redaction is best-effort only. Explicitly inspect anything you
|
|
114
|
+
opt in to collect.
|
|
115
|
+
- Attribute lists do not change an application's other OpenTelemetry exporters
|
|
116
|
+
or resource metadata. Keys are matched exactly.
|
|
117
|
+
- `shutdown()` ends only ElinaObs's processor. Do not reinitialize the SDK in
|
|
118
|
+
the same interpreter after shutdown.
|
|
119
|
+
|
|
120
|
+
See [docs/LLD.md](docs/LLD.md) for the finalized architecture and build checklist.
|
elinaobs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# ElinaObs Python SDK
|
|
2
|
+
|
|
3
|
+
Python tracing SDK for self-hosted Elina. Licensed under MIT.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Once published on PyPI:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install elinaobs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For development from this repository:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install -e '.[dev]'
|
|
17
|
+
pytest -q
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Public API
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from elinaobs import init, trace, flush, shutdown, diagnose, submit_feedback
|
|
24
|
+
|
|
25
|
+
init(api_key="...", endpoint="http://localhost:3000/v1/traces", service_name="my-agent")
|
|
26
|
+
|
|
27
|
+
@trace(name="Agent", as_type="agent")
|
|
28
|
+
async def run(question):
|
|
29
|
+
return await your_code(question)
|
|
30
|
+
|
|
31
|
+
# The existing OTel-instrumented operations called inside run() are children.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Application feedback is optional. After a trace has finished, an app with a verdict can send it using the same project key. Inside a decorated function, capture the trace ID and keep it with the result:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from opentelemetry import trace as otel_trace
|
|
38
|
+
trace_id = f"{otel_trace.get_current_span().get_span_context().trace_id:032x}"
|
|
39
|
+
# Later, after the traced function returns:
|
|
40
|
+
submit_feedback(trace_id, "bad", note="Wrong answer") # trace_id is 32 lowercase hex characters
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Apps without a feedback signal do not call this function. Feedback is stored separately from the dashboard's human review. Keep the project key in server-side code, not in a browser.
|
|
44
|
+
|
|
45
|
+
To send a demo trace to the local Elina server, create a project in the dashboard and copy its one-time ingestion key. Then, from this directory in PowerShell:
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
$env:ELINA_WRITE_KEY = "paste-project-ingestion-key"
|
|
49
|
+
$env:ELINA_BASE_URL = "http://127.0.0.1:3000"
|
|
50
|
+
python examples/agent.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The script prints the trace ID and reports whether export succeeded. Open the project’s Traces page and select that ID to inspect the three spans and their captured input/output.
|
|
54
|
+
|
|
55
|
+
Only operations that are explicitly decorated or already instrumented by a compatible OpenTelemetry instrumentation package create spans. **No framework integrations are installed, imported, or configured by ElinaObs.**
|
|
56
|
+
|
|
57
|
+
The Elina receiver lives in the sibling `elina-server` directory. Start it before running `examples/agent.py`. The default tests use in-memory and local HTTP receivers.
|
|
58
|
+
|
|
59
|
+
To also verify export through a real OpenTelemetry Collector, set `OTELCOL_BINARY` to the Collector executable path and run `python -m pytest -q tests/test_collector.py`. This optional test starts a local Collector with a file exporter and checks the received spans.
|
|
60
|
+
|
|
61
|
+
## What it does
|
|
62
|
+
|
|
63
|
+
- Sync + async function decorator with native OTel parent context.
|
|
64
|
+
- 10 span display classifications using `elinaobs.observation.type`.
|
|
65
|
+
- Selective export: only spans starting inside a `@trace()` scope.
|
|
66
|
+
- OTLP/HTTP protobuf, bearer-token auth, background batching.
|
|
67
|
+
- Optional bounded input/output capture, disabled by default.
|
|
68
|
+
- An optional `redact` callback can remove secrets from captured free text before export.
|
|
69
|
+
- `attribute_allowlist` and `attribute_denylist` filter span, event, and link attributes in ElinaObs exports; the denylist wins when both contain a key.
|
|
70
|
+
- Exceptions automatically record OTel error status/events.
|
|
71
|
+
- Shares an existing OpenTelemetry SDK TracerProvider when present.
|
|
72
|
+
- `flush()` reports failed export batches or dropped spans. `diagnose()` returns `failed_batches`, `dropped_spans`, and `retry_attempts` without exposing credentials. Drop counts are updated after a successful processor flush; retry attempts count extra HTTP requests made for a batch.
|
|
73
|
+
|
|
74
|
+
## Non-goals for v0.1
|
|
75
|
+
|
|
76
|
+
- Framework adapters or `integrations=[...]`.
|
|
77
|
+
- Raw threads, multiprocessing, distributed orchestration, or generator tracing.
|
|
78
|
+
- Automatic patching of uninstrumented Python libraries.
|
|
79
|
+
- Prompt management, evaluations, or server/UI implementation.
|
|
80
|
+
|
|
81
|
+
## Known limitations before production
|
|
82
|
+
|
|
83
|
+
- SDK processor cannot modify existing OTel provider sampling or resources.
|
|
84
|
+
- Capture filtering uses a ContextVar marker; parent spans begun before a
|
|
85
|
+
decorator scope are not exported to ElinaObs unless also eligible.
|
|
86
|
+
- `as_type="event"` is a timed span category, not an OTel instant event.
|
|
87
|
+
- Incoming external spans from `opentelemetry-instrument` share global provider
|
|
88
|
+
and may require controlling initialization order.
|
|
89
|
+
- Bounded batches can drop data on overload. `dropped_spans` is current only
|
|
90
|
+
after `flush()` or `shutdown()`. `retry_attempts` is `None` when an OpenTelemetry
|
|
91
|
+
credential-provider session is configured. Export failures remain counted
|
|
92
|
+
even if later batches succeed.
|
|
93
|
+
- Input/output redaction is best-effort only. Explicitly inspect anything you
|
|
94
|
+
opt in to collect.
|
|
95
|
+
- Attribute lists do not change an application's other OpenTelemetry exporters
|
|
96
|
+
or resource metadata. Keys are matched exactly.
|
|
97
|
+
- `shutdown()` ends only ElinaObs's processor. Do not reinitialize the SDK in
|
|
98
|
+
the same interpreter after shutdown.
|
|
99
|
+
|
|
100
|
+
See [docs/LLD.md](docs/LLD.md) for the finalized architecture and build checklist.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "elinaobs"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Framework-agnostic Python LLM tracing using OpenTelemetry"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{name = "ElinaObs Contributors"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"opentelemetry-api>=1.36,<2",
|
|
15
|
+
"opentelemetry-sdk>=1.36,<2",
|
|
16
|
+
"opentelemetry-exporter-otlp-proto-http>=1.36,<2",
|
|
17
|
+
"requests>=2,<3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.10", "opentelemetry-instrumentation-requests>=0.57b0,<1"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
asyncio_mode = "auto"
|
elinaobs-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""ElinaObs Python SDK: decorator first, framework agnostic, OTLP native."""
|
|
2
|
+
from .decorators import trace
|
|
3
|
+
from .runtime import diagnose, flush, init, shutdown, submit_feedback
|
|
4
|
+
from .types import ObservationType
|
|
5
|
+
|
|
6
|
+
__all__ = ["init", "trace", "flush", "shutdown", "diagnose", "submit_feedback", "ObservationType"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""ElinaObs capture selection, NOT a replacement for OTel parent context."""
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
|
|
4
|
+
# Inherited by normal sync nesting and asyncio tasks.
|
|
5
|
+
# The OTel context still owns trace and parent/span IDs.
|
|
6
|
+
capture_active: ContextVar[bool] = ContextVar("elinaobs_capture_active", default=False)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Framework-agnostic decorator for ordinary sync and async Python functions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar, cast, overload
|
|
8
|
+
|
|
9
|
+
from opentelemetry import trace as otel_trace
|
|
10
|
+
|
|
11
|
+
from .context import capture_active
|
|
12
|
+
from .runtime import get_config
|
|
13
|
+
from .serialization import serialize
|
|
14
|
+
from .types import OBSERVATION_TYPES, ObservationType
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def trace(func: F, /) -> F: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@overload
|
|
24
|
+
def trace(*, name: str | None = None, as_type: ObservationType = "span") -> Callable[[F], F]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def trace(
|
|
28
|
+
func: F | None = None,
|
|
29
|
+
/,
|
|
30
|
+
*,
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
as_type: ObservationType = "span",
|
|
33
|
+
) -> F | Callable[[F], F]:
|
|
34
|
+
"""Time a Python function and attach any OTel-instrumented children.
|
|
35
|
+
|
|
36
|
+
This v0.1 deliberately rejects generator functions. Their lifetime is
|
|
37
|
+
iteration, not function invocation, so naïve wrappers would lie.
|
|
38
|
+
"""
|
|
39
|
+
if as_type not in OBSERVATION_TYPES:
|
|
40
|
+
raise ValueError(f"Unknown observation type: {as_type!r}")
|
|
41
|
+
if name is not None and not name.strip():
|
|
42
|
+
raise ValueError("name must be nonempty")
|
|
43
|
+
|
|
44
|
+
def decorate(fn: F) -> F:
|
|
45
|
+
if inspect.isgeneratorfunction(fn) or inspect.isasyncgenfunction(fn):
|
|
46
|
+
raise TypeError("@trace does not support generator functions in v0.1")
|
|
47
|
+
label = name or fn.__qualname__
|
|
48
|
+
|
|
49
|
+
def attach_input(span, args, kwargs, settings):
|
|
50
|
+
if not settings.capture_input or not span.is_recording():
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
54
|
+
value = dict(bound.arguments)
|
|
55
|
+
value.pop("self", None)
|
|
56
|
+
value.pop("cls", None)
|
|
57
|
+
span.set_attribute("elinaobs.input", serialize(value, max_chars=settings.max_content_chars, redact=settings.redact))
|
|
58
|
+
except Exception: # instrumentation must not break the application
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def attach_output(span, value, settings):
|
|
62
|
+
if not settings.capture_output or not span.is_recording():
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
span.set_attribute("elinaobs.output", serialize(value, max_chars=settings.max_content_chars, redact=settings.redact))
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
if inspect.iscoroutinefunction(fn):
|
|
70
|
+
@functools.wraps(fn)
|
|
71
|
+
async def async_wrapper(*args, **kwargs):
|
|
72
|
+
settings = get_config()
|
|
73
|
+
token = capture_active.set(True)
|
|
74
|
+
try:
|
|
75
|
+
tracer = otel_trace.get_tracer("elinaobs.sdk", "0.1.0")
|
|
76
|
+
with tracer.start_as_current_span(
|
|
77
|
+
label, attributes={"elinaobs.observation.type": as_type}
|
|
78
|
+
) as span:
|
|
79
|
+
attach_input(span, args, kwargs, settings)
|
|
80
|
+
output = await fn(*args, **kwargs)
|
|
81
|
+
attach_output(span, output, settings)
|
|
82
|
+
return output
|
|
83
|
+
finally:
|
|
84
|
+
capture_active.reset(token)
|
|
85
|
+
return cast(F, async_wrapper)
|
|
86
|
+
|
|
87
|
+
@functools.wraps(fn)
|
|
88
|
+
def sync_wrapper(*args, **kwargs):
|
|
89
|
+
settings = get_config()
|
|
90
|
+
token = capture_active.set(True)
|
|
91
|
+
try:
|
|
92
|
+
tracer = otel_trace.get_tracer("elinaobs.sdk", "0.1.0")
|
|
93
|
+
with tracer.start_as_current_span(
|
|
94
|
+
label, attributes={"elinaobs.observation.type": as_type}
|
|
95
|
+
) as span:
|
|
96
|
+
attach_input(span, args, kwargs, settings)
|
|
97
|
+
output = fn(*args, **kwargs)
|
|
98
|
+
attach_output(span, output, settings)
|
|
99
|
+
return output
|
|
100
|
+
finally:
|
|
101
|
+
capture_active.reset(token)
|
|
102
|
+
return cast(F, sync_wrapper)
|
|
103
|
+
|
|
104
|
+
if func is not None:
|
|
105
|
+
return decorate(func)
|
|
106
|
+
return decorate
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Selectively forward spans created inside @trace scope to an OTel processor."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from threading import Lock
|
|
5
|
+
|
|
6
|
+
from opentelemetry.sdk.trace import SpanProcessor
|
|
7
|
+
|
|
8
|
+
from .context import capture_active
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ScopedSpanProcessor(SpanProcessor):
|
|
12
|
+
"""Keep our exporter from sending unrelated global OTel spans.
|
|
13
|
+
|
|
14
|
+
on_start runs synchronously while the calling ContextVar is available.
|
|
15
|
+
on_end may run after the capture scope has exited, so eligibility is
|
|
16
|
+
remembered by the pair (trace_id, span_id), not re-checked on end.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, downstream: SpanProcessor) -> None:
|
|
20
|
+
self._downstream = downstream
|
|
21
|
+
self._eligible: set[tuple[int, int]] = set()
|
|
22
|
+
self._lock = Lock()
|
|
23
|
+
self.ended_spans = 0
|
|
24
|
+
self.flushed_spans = 0
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _key(span) -> tuple[int, int]:
|
|
28
|
+
ctx = span.get_span_context()
|
|
29
|
+
return (ctx.trace_id, ctx.span_id)
|
|
30
|
+
|
|
31
|
+
def on_start(self, span, parent_context=None) -> None:
|
|
32
|
+
if not capture_active.get():
|
|
33
|
+
return
|
|
34
|
+
key = self._key(span)
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._eligible.add(key)
|
|
37
|
+
self._downstream.on_start(span, parent_context=parent_context)
|
|
38
|
+
|
|
39
|
+
def on_end(self, span) -> None:
|
|
40
|
+
key = self._key(span)
|
|
41
|
+
with self._lock:
|
|
42
|
+
if key not in self._eligible:
|
|
43
|
+
return
|
|
44
|
+
self._eligible.remove(key)
|
|
45
|
+
if span.context.trace_flags.sampled:
|
|
46
|
+
self.ended_spans += 1
|
|
47
|
+
self._downstream.on_end(span)
|
|
48
|
+
|
|
49
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
50
|
+
with self._lock:
|
|
51
|
+
result = self._downstream.force_flush(timeout_millis=timeout_millis)
|
|
52
|
+
if result:
|
|
53
|
+
self.flushed_spans = self.ended_spans
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def shutdown(self) -> None:
|
|
57
|
+
self._downstream.shutdown()
|