novastack-telemetry 1.0.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.
- novastack_telemetry-1.0.0/.gitignore +85 -0
- novastack_telemetry-1.0.0/PKG-INFO +31 -0
- novastack_telemetry-1.0.0/README.md +9 -0
- novastack_telemetry-1.0.0/novastack_telemetry/__init__.py +9 -0
- novastack_telemetry-1.0.0/novastack_telemetry/_dispatcher_core.py +370 -0
- novastack_telemetry-1.0.0/novastack_telemetry/dispatcher.py +43 -0
- novastack_telemetry-1.0.0/novastack_telemetry/events/__init__.py +6 -0
- novastack_telemetry-1.0.0/novastack_telemetry/events/base.py +30 -0
- novastack_telemetry-1.0.0/novastack_telemetry/mixin.py +66 -0
- novastack_telemetry-1.0.0/novastack_telemetry/observability/__init__.py +9 -0
- novastack_telemetry-1.0.0/novastack_telemetry/observability/base.py +59 -0
- novastack_telemetry-1.0.0/novastack_telemetry/observability/novastack_debug.py +233 -0
- novastack_telemetry-1.0.0/novastack_telemetry/span/__init__.py +10 -0
- novastack_telemetry-1.0.0/novastack_telemetry/span/base.py +25 -0
- novastack_telemetry-1.0.0/pyproject.toml +40 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
#IDE
|
|
10
|
+
.DS_Store
|
|
11
|
+
.idea
|
|
12
|
+
.vscode
|
|
13
|
+
|
|
14
|
+
# Distribution / packaging
|
|
15
|
+
.Python
|
|
16
|
+
build/
|
|
17
|
+
develop-eggs/
|
|
18
|
+
dist/
|
|
19
|
+
downloads/
|
|
20
|
+
eggs/
|
|
21
|
+
.eggs/
|
|
22
|
+
lib/
|
|
23
|
+
lib64/
|
|
24
|
+
parts/
|
|
25
|
+
sdist/
|
|
26
|
+
var/
|
|
27
|
+
wheels/
|
|
28
|
+
share/python-wheels/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.installed.cfg
|
|
31
|
+
*.egg
|
|
32
|
+
MANIFEST
|
|
33
|
+
|
|
34
|
+
# PyInstaller
|
|
35
|
+
# Usually these files are written by a python script from a template
|
|
36
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
# Installer logs
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
# Unit test / coverage reports
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py,cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
# Mkdocs documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
docs/api_reference/site/
|
|
62
|
+
|
|
63
|
+
# Ruff
|
|
64
|
+
.ruff_cache/
|
|
65
|
+
|
|
66
|
+
# PyBuilder
|
|
67
|
+
.pybuilder/
|
|
68
|
+
target/
|
|
69
|
+
|
|
70
|
+
# Jupyter Notebook
|
|
71
|
+
.ipynb_checkpoints
|
|
72
|
+
|
|
73
|
+
# pyenv
|
|
74
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
75
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
76
|
+
.python-version
|
|
77
|
+
|
|
78
|
+
# Environments
|
|
79
|
+
.env
|
|
80
|
+
.venv
|
|
81
|
+
env/
|
|
82
|
+
venv/
|
|
83
|
+
ENV/
|
|
84
|
+
env.bak/
|
|
85
|
+
venv.bak/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: novastack-telemetry
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Provides the telemetry hooks for observability in Novastack.
|
|
5
|
+
Project-URL: Repository, https://github.com/novastack-project/novastack/tree/main/novastack-telemetry
|
|
6
|
+
Author-email: Leonardo Furnielis <leonardofurnielis@outlook.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Keywords: AI,LLM,QA,RAG,data,observability,retrieval,semantic-search
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Requires-Python: <3.14,>=3.11
|
|
13
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.5
|
|
14
|
+
Requires-Dist: treelib<2.0.0,>=1.8.0
|
|
15
|
+
Requires-Dist: wrapt<3.0.0,>=2.2.1
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-asyncio<2.0.0,>=1.3.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.15.1; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest<10.0.0,>=9.0.3; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff<1.0.0,>=0.15.8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Novastack telemetry
|
|
24
|
+
|
|
25
|
+
This project provides the telemetry hooks for observability in Novastack.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install novastack-telemetry
|
|
31
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from novastack_telemetry.dispatcher import get_dispatcher
|
|
2
|
+
from novastack_telemetry.events import SpanExceptionEvent
|
|
3
|
+
from novastack_telemetry.mixin import DispatcherSpanMixin
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"DispatcherSpanMixin",
|
|
7
|
+
"get_dispatcher",
|
|
8
|
+
"SpanExceptionEvent",
|
|
9
|
+
]
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import Context, ContextVar, Token, copy_context
|
|
7
|
+
from functools import partial
|
|
8
|
+
from typing import Any, Callable, Generator, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
import wrapt
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from novastack_telemetry.events import BaseEvent, SpanExceptionEvent
|
|
14
|
+
from novastack_telemetry.observability import BaseObservability
|
|
15
|
+
from novastack_telemetry.span import _active_span_id
|
|
16
|
+
|
|
17
|
+
_CONTEXT_METADATA_KEY = "context_metadata"
|
|
18
|
+
_DISPATCHER_SPAN_DECORATED_ATTR = "__dispatcher_span_decorated__"
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ContextVar for managing active context metadata
|
|
23
|
+
_active_context_metadata: ContextVar[dict[str, Any]] = ContextVar(
|
|
24
|
+
"_context_metadata", default={}
|
|
25
|
+
)
|
|
26
|
+
_R = TypeVar("_R")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def _context_metadata(new_metadata: dict[str, Any]) -> Generator[None, None, None]:
|
|
31
|
+
token = _active_context_metadata.set(new_metadata)
|
|
32
|
+
try:
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
_active_context_metadata.reset(token)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Dispatcher(BaseModel):
|
|
39
|
+
"""
|
|
40
|
+
Orchestrates telemetry events and span lifecycle management.
|
|
41
|
+
|
|
42
|
+
Routes events and span signals to registered handlers through a hierarchical
|
|
43
|
+
propagation chain. Provides a decorator-based API (@dispatcher.span) for
|
|
44
|
+
automatic span tracking in both sync and async contexts. Supports thread-safe
|
|
45
|
+
and async-safe operations using ContextVars, with automatic parent-child span
|
|
46
|
+
relationships, trace context management, and silent exception handling to
|
|
47
|
+
prevent telemetry failures from affecting application code.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
51
|
+
name: str = Field(default_factory=str, description="The name of dispatcher")
|
|
52
|
+
handlers: list[BaseObservability] = Field(
|
|
53
|
+
default=[], description="List of unified handlers"
|
|
54
|
+
)
|
|
55
|
+
parent_name: str = Field(
|
|
56
|
+
default_factory=str, description="The name of parent Dispatcher."
|
|
57
|
+
)
|
|
58
|
+
dispatcher_manager: Optional["_DispatcherManager"] = Field(
|
|
59
|
+
default=None, description="Dispatcher manager."
|
|
60
|
+
)
|
|
61
|
+
root_name: str = Field(
|
|
62
|
+
default="root", description="The name of Dispatcher root tree."
|
|
63
|
+
)
|
|
64
|
+
propagate: bool = Field(
|
|
65
|
+
default=True,
|
|
66
|
+
description="Whether to propagate the event to parent dispatchers and their handlers",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def parent(self) -> "Dispatcher":
|
|
71
|
+
assert self.dispatcher_manager is not None
|
|
72
|
+
return self.dispatcher_manager.dispatchers[self.parent_name]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def root(self) -> "Dispatcher":
|
|
76
|
+
assert self.dispatcher_manager is not None
|
|
77
|
+
return self.dispatcher_manager.dispatchers[self.root_name]
|
|
78
|
+
|
|
79
|
+
def _get_handler_hierarchy(self) -> Generator[BaseObservability, None, None]:
|
|
80
|
+
"""Retrieve every handlers reachable via the propagation chain."""
|
|
81
|
+
h: Dispatcher | None = self
|
|
82
|
+
while h:
|
|
83
|
+
yield from h.handlers
|
|
84
|
+
if not h.propagate:
|
|
85
|
+
break
|
|
86
|
+
h = h.parent
|
|
87
|
+
|
|
88
|
+
def _dispatch_to_handlers(
|
|
89
|
+
self, handler_method: str, *args: Any, **kwargs: Any
|
|
90
|
+
) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Invoke handler method across the propagation chain with error isolation.
|
|
93
|
+
|
|
94
|
+
Calls the specified method on all handlers in the chain. Handler exceptions
|
|
95
|
+
are silently caught to ensure telemetry failures don't break application code.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
handler_method: Name of the handler method to call
|
|
99
|
+
*args: Positional arguments to pass to the handler method
|
|
100
|
+
**kwargs: Keyword arguments to pass to the handler method
|
|
101
|
+
"""
|
|
102
|
+
for h in self._get_handler_hierarchy():
|
|
103
|
+
try:
|
|
104
|
+
getattr(h, handler_method)(*args, **kwargs)
|
|
105
|
+
except BaseException:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def add_handler(self, handler: BaseObservability) -> None:
|
|
109
|
+
"""Add handler to set of handlers."""
|
|
110
|
+
self.handlers += [handler]
|
|
111
|
+
|
|
112
|
+
def event(self, event: BaseEvent, **kwargs: Any) -> None:
|
|
113
|
+
"""Dispatch event to all registered handlers."""
|
|
114
|
+
event.metadata.update(_active_context_metadata.get())
|
|
115
|
+
self._dispatch_to_handlers("on_event", event, **kwargs)
|
|
116
|
+
|
|
117
|
+
def _span_start(
|
|
118
|
+
self,
|
|
119
|
+
id_: str,
|
|
120
|
+
bound_args: inspect.BoundArguments,
|
|
121
|
+
instance: Any | None = None,
|
|
122
|
+
parent_id: str | None = None,
|
|
123
|
+
metadata: dict[str, Any] | None = None,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Internal: Send notice to handlers that a span with id_ has started."""
|
|
127
|
+
self._dispatch_to_handlers(
|
|
128
|
+
"on_span_start",
|
|
129
|
+
id_=id_,
|
|
130
|
+
bound_args=bound_args,
|
|
131
|
+
instance=instance,
|
|
132
|
+
parent_id=parent_id,
|
|
133
|
+
metadata=metadata,
|
|
134
|
+
**kwargs,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _span_end(
|
|
138
|
+
self,
|
|
139
|
+
id_: str,
|
|
140
|
+
bound_args: inspect.BoundArguments,
|
|
141
|
+
instance: Any | None = None,
|
|
142
|
+
result: Any | None = None,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Internal: Send notice to handlers that a span with id_ is exiting."""
|
|
146
|
+
self._dispatch_to_handlers(
|
|
147
|
+
"on_span_end",
|
|
148
|
+
id_=id_,
|
|
149
|
+
bound_args=bound_args,
|
|
150
|
+
instance=instance,
|
|
151
|
+
result=result,
|
|
152
|
+
**kwargs,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _span_exception(
|
|
156
|
+
self,
|
|
157
|
+
id_: str,
|
|
158
|
+
bound_args: inspect.BoundArguments,
|
|
159
|
+
instance: Any | None = None,
|
|
160
|
+
err: BaseException | None = None,
|
|
161
|
+
**kwargs: Any,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Internal: Send notice to handlers that a span with id_ is being exited due an exception."""
|
|
164
|
+
self._dispatch_to_handlers(
|
|
165
|
+
"on_span_exception",
|
|
166
|
+
id_=id_,
|
|
167
|
+
bound_args=bound_args,
|
|
168
|
+
instance=instance,
|
|
169
|
+
err=err,
|
|
170
|
+
**kwargs,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def capture_propagation_context(self) -> dict[str, Any]:
|
|
174
|
+
"""
|
|
175
|
+
Capture trace propagation context from all handlers and active metadata.
|
|
176
|
+
|
|
177
|
+
Returns a serializable dictionary with namespaced handler data and context
|
|
178
|
+
metadata, suitable for cross-process propagation via restore_propagation_context().
|
|
179
|
+
"""
|
|
180
|
+
result: dict[str, Any] = {}
|
|
181
|
+
for h in self._get_handler_hierarchy():
|
|
182
|
+
try:
|
|
183
|
+
result.update(h.capture_propagation_context())
|
|
184
|
+
except BaseException:
|
|
185
|
+
_logger.warning("Error capturing propagation context", exc_info=True)
|
|
186
|
+
metadata = _active_context_metadata.get()
|
|
187
|
+
if metadata:
|
|
188
|
+
result[_CONTEXT_METADATA_KEY] = dict(metadata)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def restore_propagation_context(self, context: dict[str, Any]) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Restore trace propagation context across all handlers and metadata.
|
|
194
|
+
|
|
195
|
+
Applies the context to all handlers in the chain and updates active
|
|
196
|
+
context metadata for subsequent span operations.
|
|
197
|
+
"""
|
|
198
|
+
for h in self._get_handler_hierarchy():
|
|
199
|
+
try:
|
|
200
|
+
h.restore_propagation_context(context)
|
|
201
|
+
except BaseException:
|
|
202
|
+
_logger.warning("Error restoring propagation context", exc_info=True)
|
|
203
|
+
metadata = context.get(_CONTEXT_METADATA_KEY)
|
|
204
|
+
if metadata:
|
|
205
|
+
_active_context_metadata.set(dict(metadata))
|
|
206
|
+
|
|
207
|
+
def shutdown(self) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Gracefully shutdown all handlers in the propagation chain.
|
|
210
|
+
|
|
211
|
+
Invokes shutdown() on each handler while suppressing exceptions to ensure
|
|
212
|
+
complete cleanup even if individual handler fail.
|
|
213
|
+
"""
|
|
214
|
+
for h in self._get_handler_hierarchy():
|
|
215
|
+
try:
|
|
216
|
+
h.shutdown()
|
|
217
|
+
except BaseException:
|
|
218
|
+
_logger.warning("Error closing handler %s", h, exc_info=True)
|
|
219
|
+
|
|
220
|
+
def span(self, func: Callable[..., _R]) -> Callable[..., _R]:
|
|
221
|
+
try:
|
|
222
|
+
if hasattr(func, _DISPATCHER_SPAN_DECORATED_ATTR):
|
|
223
|
+
return func
|
|
224
|
+
setattr(func, _DISPATCHER_SPAN_DECORATED_ATTR, True)
|
|
225
|
+
except AttributeError:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
@wrapt.decorator
|
|
229
|
+
def wrapper(func: Callable, instance: Any, args: list, kwargs: dict) -> Any:
|
|
230
|
+
bound_args = inspect.signature(func).bind(*args, **kwargs)
|
|
231
|
+
if instance is not None:
|
|
232
|
+
actual_class = type(instance).__name__
|
|
233
|
+
method_name = func.__name__
|
|
234
|
+
id_ = f"{actual_class}.{method_name}-{uuid.uuid4()}"
|
|
235
|
+
else:
|
|
236
|
+
id_ = f"{func.__qualname__}-{uuid.uuid4()}"
|
|
237
|
+
metadata = _active_context_metadata.get()
|
|
238
|
+
result = None
|
|
239
|
+
|
|
240
|
+
# Copy the current context
|
|
241
|
+
context = copy_context()
|
|
242
|
+
|
|
243
|
+
token = _active_span_id.set(id_)
|
|
244
|
+
parent_id = None if token.old_value is Token.MISSING else token.old_value
|
|
245
|
+
self._span_start(
|
|
246
|
+
id_=id_,
|
|
247
|
+
bound_args=bound_args,
|
|
248
|
+
instance=instance,
|
|
249
|
+
parent_id=parent_id,
|
|
250
|
+
metadata=metadata,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def handle_future_result(
|
|
254
|
+
future: asyncio.Future,
|
|
255
|
+
span_id: str,
|
|
256
|
+
bound_args: inspect.BoundArguments,
|
|
257
|
+
instance: Any,
|
|
258
|
+
context: Context,
|
|
259
|
+
) -> None:
|
|
260
|
+
try:
|
|
261
|
+
result = None if future.exception() else future.result()
|
|
262
|
+
|
|
263
|
+
self._span_end(
|
|
264
|
+
id_=span_id,
|
|
265
|
+
bound_args=bound_args,
|
|
266
|
+
instance=instance,
|
|
267
|
+
result=result,
|
|
268
|
+
)
|
|
269
|
+
return result
|
|
270
|
+
except BaseException as e:
|
|
271
|
+
self.event(SpanExceptionEvent(span_id=span_id, err_str=str(e)))
|
|
272
|
+
self._span_exception(
|
|
273
|
+
id_=span_id, bound_args=bound_args, instance=instance, err=e
|
|
274
|
+
)
|
|
275
|
+
raise
|
|
276
|
+
finally:
|
|
277
|
+
try:
|
|
278
|
+
context.run(_active_span_id.reset, token)
|
|
279
|
+
except ValueError as e:
|
|
280
|
+
_logger.debug(f"Failed to reset _active_span_id: {e}")
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
result = func(*args, **kwargs)
|
|
284
|
+
if isinstance(result, asyncio.Future):
|
|
285
|
+
new_future = asyncio.ensure_future(result)
|
|
286
|
+
new_future.add_done_callback(
|
|
287
|
+
partial(
|
|
288
|
+
handle_future_result,
|
|
289
|
+
span_id=id_,
|
|
290
|
+
bound_args=bound_args,
|
|
291
|
+
instance=instance,
|
|
292
|
+
context=context,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return new_future
|
|
296
|
+
else:
|
|
297
|
+
self._span_end(
|
|
298
|
+
id_=id_, bound_args=bound_args, instance=instance, result=result
|
|
299
|
+
)
|
|
300
|
+
return result
|
|
301
|
+
except BaseException as e:
|
|
302
|
+
self.event(SpanExceptionEvent(span_id=id_, err_str=str(e)))
|
|
303
|
+
self._span_exception(
|
|
304
|
+
id_=id_, bound_args=bound_args, instance=instance, err=e
|
|
305
|
+
)
|
|
306
|
+
raise
|
|
307
|
+
finally:
|
|
308
|
+
if not isinstance(result, asyncio.Future):
|
|
309
|
+
_active_span_id.reset(token)
|
|
310
|
+
|
|
311
|
+
@wrapt.decorator
|
|
312
|
+
async def async_wrapper(
|
|
313
|
+
func: Callable, instance: Any, args: list, kwargs: dict
|
|
314
|
+
) -> Any:
|
|
315
|
+
bound_args = inspect.signature(func).bind(*args, **kwargs)
|
|
316
|
+
if instance is not None:
|
|
317
|
+
actual_class = type(instance).__name__
|
|
318
|
+
method_name = func.__name__
|
|
319
|
+
id_ = f"{actual_class}.{method_name}-{uuid.uuid4()}"
|
|
320
|
+
else:
|
|
321
|
+
id_ = f"{func.__qualname__}-{uuid.uuid4()}"
|
|
322
|
+
metadata = _active_context_metadata.get()
|
|
323
|
+
|
|
324
|
+
token = _active_span_id.set(id_)
|
|
325
|
+
parent_id = None if token.old_value is Token.MISSING else token.old_value
|
|
326
|
+
self._span_start(
|
|
327
|
+
id_=id_,
|
|
328
|
+
bound_args=bound_args,
|
|
329
|
+
instance=instance,
|
|
330
|
+
parent_id=parent_id,
|
|
331
|
+
metadata=metadata,
|
|
332
|
+
)
|
|
333
|
+
try:
|
|
334
|
+
result = await func(*args, **kwargs)
|
|
335
|
+
except BaseException as e:
|
|
336
|
+
self.event(SpanExceptionEvent(span_id=id_, err_str=str(e)))
|
|
337
|
+
self._span_exception(
|
|
338
|
+
id_=id_, bound_args=bound_args, instance=instance, err=e
|
|
339
|
+
)
|
|
340
|
+
raise
|
|
341
|
+
else:
|
|
342
|
+
self._span_end(
|
|
343
|
+
id_=id_, bound_args=bound_args, instance=instance, result=result
|
|
344
|
+
)
|
|
345
|
+
return result
|
|
346
|
+
finally:
|
|
347
|
+
_active_span_id.reset(token)
|
|
348
|
+
|
|
349
|
+
if inspect.iscoroutinefunction(func):
|
|
350
|
+
return async_wrapper(func) # type: ignore
|
|
351
|
+
else:
|
|
352
|
+
return wrapper(func) # type: ignore
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def log_name(self) -> str:
|
|
356
|
+
if self.parent:
|
|
357
|
+
return f"{self.parent.name}.{self.name}"
|
|
358
|
+
else:
|
|
359
|
+
return self.name
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class _DispatcherManager:
|
|
363
|
+
def __init__(self, root: Dispatcher) -> None:
|
|
364
|
+
self.dispatchers: dict[str, Dispatcher] = {root.name: root}
|
|
365
|
+
|
|
366
|
+
def add_dispatcher(self, d: Dispatcher) -> None:
|
|
367
|
+
self.dispatchers.setdefault(d.name, d)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
Dispatcher.model_rebuild()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from novastack_telemetry._dispatcher_core import Dispatcher, _DispatcherManager
|
|
2
|
+
|
|
3
|
+
root_dispatcher: Dispatcher = Dispatcher(
|
|
4
|
+
name="root",
|
|
5
|
+
handlers=[],
|
|
6
|
+
propagate=False,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
root_manager: _DispatcherManager = _DispatcherManager(root_dispatcher)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_dispatcher(name: str = "root") -> Dispatcher:
|
|
13
|
+
"""
|
|
14
|
+
Get or create a Dispatcher by name with hierarchical parent resolution.
|
|
15
|
+
|
|
16
|
+
Returns existing dispatcher or creates a new one. Parent is determined by
|
|
17
|
+
dot-notation hierarchy (e.g., "a.b.c" → parent "a.b"), falling back to "root".
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
name: The name of the dispatcher. Defaults to "root".
|
|
21
|
+
"""
|
|
22
|
+
# Return existing dispatcher if found
|
|
23
|
+
if existing := root_manager.dispatchers.get(name):
|
|
24
|
+
return existing
|
|
25
|
+
|
|
26
|
+
# Determine parent: try hierarchical parent first, fallback to root
|
|
27
|
+
candidate_parent_name = ".".join(name.split(".")[:-1])
|
|
28
|
+
parent_name = (
|
|
29
|
+
candidate_parent_name
|
|
30
|
+
if candidate_parent_name in root_manager.dispatchers
|
|
31
|
+
else "root"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Create and register new dispatcher
|
|
35
|
+
new_dispatcher = Dispatcher(
|
|
36
|
+
name=name,
|
|
37
|
+
root_name=root_dispatcher.name,
|
|
38
|
+
parent_name=parent_name,
|
|
39
|
+
dispatcher_manager=root_manager,
|
|
40
|
+
)
|
|
41
|
+
root_manager.add_dispatcher(new_dispatcher)
|
|
42
|
+
|
|
43
|
+
return new_dispatcher
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from novastack_telemetry.span import _active_span_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseEvent(BaseModel):
|
|
11
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
12
|
+
|
|
13
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now())
|
|
14
|
+
id_: str = Field(default_factory=lambda: str(uuid4()))
|
|
15
|
+
span_id: str | None = Field(default_factory=_active_span_id.get)
|
|
16
|
+
metadata: dict[str, Any] = Field(default={})
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def class_name(cls) -> str:
|
|
20
|
+
return "BaseEvent"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpanExceptionEvent(BaseEvent):
|
|
24
|
+
"""SpanExceptionEvent."""
|
|
25
|
+
|
|
26
|
+
err_str: str
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def class_name(cls) -> str:
|
|
30
|
+
return "SpanExceptionEvent"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from novastack_telemetry._dispatcher_core import _DISPATCHER_SPAN_DECORATED_ATTR
|
|
6
|
+
from novastack_telemetry.dispatcher import get_dispatcher
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DispatcherSpanMixin(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Automatically applies `dispatcher.span` to methods that override decorated methods
|
|
12
|
+
from base classes. This ensures that when you override a method that was decorated
|
|
13
|
+
with `@dispatcher.span` in a parent class, the override will also be automatically
|
|
14
|
+
decorated.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _is_abstract_method(method: Any) -> bool:
|
|
19
|
+
"""Check if a method is abstract."""
|
|
20
|
+
return getattr(method, "__isabstractmethod__", False)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _is_decorated_method(method: Any) -> bool:
|
|
24
|
+
"""Check if a method is decorated with dispatcher.span."""
|
|
25
|
+
return hasattr(method, _DISPATCHER_SPAN_DECORATED_ATTR)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def _collect_methods_to_decorate(cls, target_cls: type) -> set[str]:
|
|
29
|
+
"""
|
|
30
|
+
Collect method names from base classes that should be decorated
|
|
31
|
+
when overridden in subclasses.
|
|
32
|
+
|
|
33
|
+
Only collects methods that were explicitly decorated with @dispatcher.span.
|
|
34
|
+
"""
|
|
35
|
+
decorated_methods: set[str] = set()
|
|
36
|
+
|
|
37
|
+
# Iterate through base classes (excluding the target class itself)
|
|
38
|
+
for base_cls in inspect.getmro(target_cls)[1:]:
|
|
39
|
+
for attr, method in base_cls.__dict__.items():
|
|
40
|
+
if not callable(method):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Only collect methods that are already decorated
|
|
44
|
+
if cls._is_decorated_method(method):
|
|
45
|
+
decorated_methods.add(attr)
|
|
46
|
+
|
|
47
|
+
return decorated_methods
|
|
48
|
+
|
|
49
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
50
|
+
super().__init_subclass__(**kwargs)
|
|
51
|
+
|
|
52
|
+
# Collect methods that need decoration from base classes
|
|
53
|
+
methods_to_decorate = cls._collect_methods_to_decorate(cls)
|
|
54
|
+
|
|
55
|
+
# Get dispatcher for this module
|
|
56
|
+
dispatcher = get_dispatcher(cls.__module__)
|
|
57
|
+
|
|
58
|
+
# Decorate overridden methods in the current class
|
|
59
|
+
for attr, method in cls.__dict__.items():
|
|
60
|
+
# Skip non-callable or abstract methods
|
|
61
|
+
if not callable(method) or cls._is_abstract_method(method):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Decorate if this method overrides a decorated method
|
|
65
|
+
if attr in methods_to_decorate:
|
|
66
|
+
setattr(cls, attr, dispatcher.span(method))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from novastack_telemetry.events.base import BaseEvent
|
|
7
|
+
from novastack_telemetry.span import Span
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseObservability(BaseModel):
|
|
11
|
+
"""
|
|
12
|
+
Unified observability that can handle events and spans.
|
|
13
|
+
|
|
14
|
+
This is the foundation for moving toward to observability.
|
|
15
|
+
BaseObservability can implement event handling and span handling.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def class_name(cls) -> str:
|
|
22
|
+
return "BaseObservability"
|
|
23
|
+
|
|
24
|
+
def on_event(self, event: BaseEvent, **kwargs: Any) -> Any:
|
|
25
|
+
"""Handle an event."""
|
|
26
|
+
|
|
27
|
+
def on_span_start(
|
|
28
|
+
self,
|
|
29
|
+
id_: str,
|
|
30
|
+
bound_args: inspect.BoundArguments,
|
|
31
|
+
instance: Any | None = None,
|
|
32
|
+
parent_id: str | None = None,
|
|
33
|
+
metadata: dict[str, Any] | None = None,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Handle span start."""
|
|
37
|
+
|
|
38
|
+
def on_span_end(
|
|
39
|
+
self,
|
|
40
|
+
id_: str,
|
|
41
|
+
bound_args: inspect.BoundArguments,
|
|
42
|
+
instance: Any | None = None,
|
|
43
|
+
result: Any | None = None,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Handle span end."""
|
|
47
|
+
|
|
48
|
+
def on_span_exception(
|
|
49
|
+
self,
|
|
50
|
+
id_: str,
|
|
51
|
+
bound_args: inspect.BoundArguments,
|
|
52
|
+
instance: Any | None = None,
|
|
53
|
+
err: BaseException | None = None,
|
|
54
|
+
**kwargs: Any,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Handle span exception."""
|
|
57
|
+
|
|
58
|
+
def shutdown(self) -> None:
|
|
59
|
+
"""Optional cleanup hook called during dispatcher shutdown."""
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import threading
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, PrivateAttr
|
|
8
|
+
from treelib.tree import Tree
|
|
9
|
+
|
|
10
|
+
from novastack_telemetry.events import BaseEvent
|
|
11
|
+
from novastack_telemetry.observability.base import BaseObservability
|
|
12
|
+
from novastack_telemetry.span import Span
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NovastackDebugObservability(BaseObservability):
|
|
16
|
+
"""Novastack observability to track debug information (in-memory)."""
|
|
17
|
+
|
|
18
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
19
|
+
open_spans: dict[str, Span] = Field(
|
|
20
|
+
default_factory=dict, description="Dictionary of open spans."
|
|
21
|
+
)
|
|
22
|
+
completed_spans: list[Span] = Field(
|
|
23
|
+
default_factory=list, description="List of completed spans."
|
|
24
|
+
)
|
|
25
|
+
dropped_spans: list[Span] = Field(
|
|
26
|
+
default_factory=list, description="List of dropped spans."
|
|
27
|
+
)
|
|
28
|
+
events: list[BaseEvent] = Field(default_factory=list, description="List of events.")
|
|
29
|
+
_lock: Optional[threading.Lock] = PrivateAttr(default=None)
|
|
30
|
+
|
|
31
|
+
print_span_on_end: bool = Field(
|
|
32
|
+
default=True,
|
|
33
|
+
description="Automatically print trace tree when a root span completes.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def lock(self) -> threading.Lock:
|
|
38
|
+
if self._lock is None:
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
return self._lock
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def class_name(cls) -> str:
|
|
44
|
+
return "NovastackDebugObservability"
|
|
45
|
+
|
|
46
|
+
def on_event(self, event: BaseEvent, **kwargs: Any) -> None:
|
|
47
|
+
"""Handle an event."""
|
|
48
|
+
with self.lock:
|
|
49
|
+
self.events.append(event)
|
|
50
|
+
|
|
51
|
+
def on_span_start(
|
|
52
|
+
self,
|
|
53
|
+
id_: str,
|
|
54
|
+
bound_args: inspect.BoundArguments,
|
|
55
|
+
instance: Any | None = None,
|
|
56
|
+
parent_id: str | None = None,
|
|
57
|
+
metadata: dict[str, Any] | None = None,
|
|
58
|
+
**kwargs: Any,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Handle span start."""
|
|
61
|
+
span = Span(id_=id_, parent_id=parent_id, metadata=metadata or {})
|
|
62
|
+
with self.lock:
|
|
63
|
+
self.open_spans[id_] = span
|
|
64
|
+
|
|
65
|
+
def on_span_end(
|
|
66
|
+
self,
|
|
67
|
+
id_: str,
|
|
68
|
+
bound_args: inspect.BoundArguments,
|
|
69
|
+
instance: Any | None = None,
|
|
70
|
+
result: Any | None = None,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Handle span end."""
|
|
74
|
+
with self.lock:
|
|
75
|
+
span = self.open_spans.pop(id_, None)
|
|
76
|
+
if span:
|
|
77
|
+
span.end_time = datetime.now()
|
|
78
|
+
span.duration = (span.end_time - span.start_time).total_seconds()
|
|
79
|
+
self.completed_spans.append(span)
|
|
80
|
+
|
|
81
|
+
# Auto-print trace tree if enabled and this is a root span
|
|
82
|
+
if self.print_span_on_end and span.parent_id is None:
|
|
83
|
+
self.print_trace_trees(include_events=True)
|
|
84
|
+
|
|
85
|
+
def on_span_exception(
|
|
86
|
+
self,
|
|
87
|
+
id_: str,
|
|
88
|
+
bound_args: inspect.BoundArguments,
|
|
89
|
+
instance: Any | None = None,
|
|
90
|
+
err: BaseException | None = None,
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Handle span exception."""
|
|
94
|
+
with self.lock:
|
|
95
|
+
span = self.open_spans.pop(id_, None)
|
|
96
|
+
if span:
|
|
97
|
+
span.metadata["error"] = str(err)
|
|
98
|
+
self.dropped_spans.append(span)
|
|
99
|
+
|
|
100
|
+
def _get_parents(self) -> list[Span]:
|
|
101
|
+
"""Extract root spans from completed and dropped span collections."""
|
|
102
|
+
all_spans = self.completed_spans + self.dropped_spans
|
|
103
|
+
return [s for s in all_spans if s.parent_id is None]
|
|
104
|
+
|
|
105
|
+
def _build_spans_by_parent_index(
|
|
106
|
+
self, spans: list[Span]
|
|
107
|
+
) -> dict[str | None, list[Span]]:
|
|
108
|
+
"""Build optimized parent-to-children span index for O(1) hierarchy traversal."""
|
|
109
|
+
spans_by_parent: dict[str | None, list[Span]] = defaultdict(list)
|
|
110
|
+
for span in spans:
|
|
111
|
+
spans_by_parent[span.parent_id].append(span)
|
|
112
|
+
return spans_by_parent
|
|
113
|
+
|
|
114
|
+
def _build_tree_by_parent(
|
|
115
|
+
self, parent: Span, spans_by_parent: dict[str | None, list[Span]]
|
|
116
|
+
) -> list[Span]:
|
|
117
|
+
"""Recursively construct flattened span hierarchy using depth-first traversal."""
|
|
118
|
+
result = [parent]
|
|
119
|
+
children = spans_by_parent.get(parent.id_, [])
|
|
120
|
+
|
|
121
|
+
for child in children:
|
|
122
|
+
result.extend(self._build_tree_by_parent(child, spans_by_parent))
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def _get_trace_trees(self, include_events: bool = True) -> list[Tree]:
|
|
127
|
+
"""Generate hierarchical tree structures representing execution traces with span durations."""
|
|
128
|
+
all_spans = self.completed_spans + self.dropped_spans
|
|
129
|
+
for s in all_spans:
|
|
130
|
+
if s.parent_id is None:
|
|
131
|
+
continue
|
|
132
|
+
if not any(ns.id_ == s.parent_id for ns in all_spans):
|
|
133
|
+
s.parent_id += "-missing"
|
|
134
|
+
all_spans.append(Span(id_=s.parent_id, parent_id=None))
|
|
135
|
+
|
|
136
|
+
# Build index once for O(n) tree building
|
|
137
|
+
spans_by_parent = self._build_spans_by_parent_index(all_spans)
|
|
138
|
+
|
|
139
|
+
parents = self._get_parents()
|
|
140
|
+
span_groups = []
|
|
141
|
+
for p in parents:
|
|
142
|
+
this_span_group = self._build_tree_by_parent(
|
|
143
|
+
parent=p, spans_by_parent=spans_by_parent
|
|
144
|
+
)
|
|
145
|
+
sorted_span_group = sorted(this_span_group, key=lambda x: x.start_time)
|
|
146
|
+
span_groups.append(sorted_span_group)
|
|
147
|
+
|
|
148
|
+
trees = []
|
|
149
|
+
tree = Tree()
|
|
150
|
+
for grp in span_groups:
|
|
151
|
+
for span in grp:
|
|
152
|
+
if span.parent_id is None:
|
|
153
|
+
if tree.all_nodes():
|
|
154
|
+
trees.append(tree)
|
|
155
|
+
tree = Tree()
|
|
156
|
+
|
|
157
|
+
duration_str = f"{span.duration:.6f}s" if span.duration else ""
|
|
158
|
+
tree.create_node(
|
|
159
|
+
tag=f"{span.id_} - {duration_str}",
|
|
160
|
+
identifier=span.id_,
|
|
161
|
+
parent=span.parent_id,
|
|
162
|
+
data=span.start_time,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Add events that belong to this span if requested
|
|
166
|
+
if include_events:
|
|
167
|
+
span_events = [e for e in self.events if e.span_id == span.id_]
|
|
168
|
+
for event in sorted(span_events, key=lambda e: e.timestamp):
|
|
169
|
+
event_id = f"event-{event.id_}"
|
|
170
|
+
tree.create_node(
|
|
171
|
+
tag=event.class_name(),
|
|
172
|
+
identifier=event_id,
|
|
173
|
+
parent=span.id_,
|
|
174
|
+
data=event.timestamp,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
trees.append(tree)
|
|
178
|
+
return trees
|
|
179
|
+
|
|
180
|
+
def _get_event_trees(self) -> list["Tree"]:
|
|
181
|
+
"""Generate event-centric tree structures with spans as roots and events as children."""
|
|
182
|
+
try:
|
|
183
|
+
from treelib.tree import Tree
|
|
184
|
+
except ImportError as e:
|
|
185
|
+
raise ImportError(
|
|
186
|
+
"`treelib` package is missing. Please install it by using "
|
|
187
|
+
"`pip install treelib`."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Group events by span_id
|
|
191
|
+
events_by_span: dict[str, list[BaseEvent]] = defaultdict(list)
|
|
192
|
+
for event in self.events:
|
|
193
|
+
if event.span_id:
|
|
194
|
+
events_by_span[event.span_id].append(event)
|
|
195
|
+
|
|
196
|
+
trees = []
|
|
197
|
+
for span_id, span_events in events_by_span.items():
|
|
198
|
+
tree = Tree()
|
|
199
|
+
# Create root node for the span
|
|
200
|
+
tree.create_node(
|
|
201
|
+
tag=f"{span_id} (SPAN)",
|
|
202
|
+
identifier=span_id,
|
|
203
|
+
parent=None,
|
|
204
|
+
data=min(e.timestamp for e in span_events) if span_events else None,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Add events as children
|
|
208
|
+
for event in sorted(span_events, key=lambda e: e.timestamp):
|
|
209
|
+
event_id = f"event-{event.id_}"
|
|
210
|
+
tree.create_node(
|
|
211
|
+
tag=f"{event.class_name()}: {event.id_}",
|
|
212
|
+
identifier=event_id,
|
|
213
|
+
parent=span_id,
|
|
214
|
+
data=event.timestamp,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
trees.append(tree)
|
|
218
|
+
|
|
219
|
+
return trees
|
|
220
|
+
|
|
221
|
+
def print_trace_trees(self, include_events: bool = True) -> None:
|
|
222
|
+
"""Display formatted execution traces with chronologically sorted span hierarchies."""
|
|
223
|
+
trees = self._get_trace_trees(include_events=include_events)
|
|
224
|
+
for tree in trees:
|
|
225
|
+
print(tree.show(stdout=False, sorting=True, key=lambda node: node.data))
|
|
226
|
+
print("")
|
|
227
|
+
|
|
228
|
+
def print_event_trees(self) -> None:
|
|
229
|
+
"""Display event sequences grouped by parent span for focused event analysis."""
|
|
230
|
+
trees = self._get_event_trees()
|
|
231
|
+
for tree in trees:
|
|
232
|
+
print(tree.show(stdout=False, sorting=True, key=lambda node: node.data))
|
|
233
|
+
print("")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from novastack_telemetry.span.base import BaseSpan, Span
|
|
5
|
+
|
|
6
|
+
# ContextVar for managing active spans
|
|
7
|
+
_active_span_id: ContextVar[Optional[str]] = ContextVar("_active_span_id", default=None)
|
|
8
|
+
_active_span_id.set(None)
|
|
9
|
+
|
|
10
|
+
__all__ = ["BaseSpan", "Span", "_active_span_id"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseSpan(BaseModel):
|
|
9
|
+
"""Abstract base class defining the interface for span models."""
|
|
10
|
+
|
|
11
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
12
|
+
|
|
13
|
+
id_: str = Field(
|
|
14
|
+
default_factory=lambda: str(uuid4()), description="The Id of span."
|
|
15
|
+
)
|
|
16
|
+
parent_id: str | None = Field(default=None, description="The Id of parent span.")
|
|
17
|
+
metadata: dict[str, Any] = Field(default={})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Span(BaseSpan):
|
|
21
|
+
"""Simple span class."""
|
|
22
|
+
|
|
23
|
+
start_time: datetime = Field(default_factory=lambda: datetime.now())
|
|
24
|
+
end_time: datetime | None = Field(default=None)
|
|
25
|
+
duration: float = Field(default=0.0, description="Duration of span in seconds.")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "novastack-telemetry"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Provides the telemetry hooks for observability in Novastack."
|
|
9
|
+
authors = [{ name = "Leonardo Furnielis", email = "leonardofurnielis@outlook.com" }]
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11,<3.14"
|
|
13
|
+
keywords = ["AI", "LLM", "QA", "RAG", "data", "observability", "retrieval", "semantic-search"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"pydantic>=2.12.5,<3.0.0",
|
|
21
|
+
"treelib>=1.8.0,<2.0.0",
|
|
22
|
+
"wrapt>=2.2.1,<3.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=9.0.3,<10.0.0",
|
|
28
|
+
"pytest-asyncio>=1.3.0,<2.0.0",
|
|
29
|
+
"pytest-mock>=3.15.1,<4.0.0",
|
|
30
|
+
"ruff>=0.15.8,<1.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Repository = "https://github.com/novastack-project/novastack/tree/main/novastack-telemetry"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["novastack_telemetry/"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
include = ["novastack_telemetry/"]
|