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.
@@ -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
+ # Novastack telemetry
2
+
3
+ This project provides the telemetry hooks for observability in Novastack.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install novastack-telemetry
9
+ ```
@@ -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,6 @@
1
+ from novastack_telemetry.events.base import BaseEvent, SpanExceptionEvent
2
+
3
+ __all__ = [
4
+ "BaseEvent",
5
+ "SpanExceptionEvent",
6
+ ]
@@ -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,9 @@
1
+ from novastack_telemetry.observability.base import BaseObservability
2
+ from novastack_telemetry.observability.novastack_debug import (
3
+ NovastackDebugObservability,
4
+ )
5
+
6
+ __all__ = [
7
+ "BaseObservability",
8
+ "NovastackDebugObservability",
9
+ ]
@@ -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/"]