Coverage for pyTooling/Tracing/__init__.py: 85%
218 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _| __ __ _ ___(_)_ __ __ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | || '__/ _` |/ __| | '_ \ / _` | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| || | | (_| | (__| | | | | (_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_||_| \__,_|\___|_|_| |_|\__, | #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32Tools for software execution tracing.
34.. seealso::
36 :mod:`pyTooling.Stopwatch`
37 |rarr| A single measurement instead of nested timespans.
38 :mod:`pyTooling.Tree`
39 |rarr| The tree data structure spans and their sub-spans form.
40"""
41from __future__ import annotations
43from datetime import datetime
44from time import perf_counter_ns
45from threading import local
46from types import TracebackType
47from typing import Optional as Nullable, Iterator, Self, Iterable, Any
49from pyTooling.Decorators import export, readonly
50from pyTooling.MetaClasses import ExtendedType
51from pyTooling.Exceptions import ToolingException
52from pyTooling.Common import getFullyQualifiedName
55__all__ = ["_threadLocalData"]
57_threadLocalData = local()
58"""A reference to the thread local data needed by the pyTooling.Tracing classes."""
61@export
62class TracingException(ToolingException):
63 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`."""
66@export
67class Event(metaclass=ExtendedType, slots=True):
68 """
69 Represents a named event within a timespan (:class:`Span`) used in a software execution trace.
71 It may contain arbitrary attributes (key-value pairs).
72 """
73 _name: str #: Name of the event.
74 _parent: Nullable[Span] #: Reference to the parent span.
75 _time: Nullable[datetime] #: Timestamp of the event.
76 _dict: dict[str, Any] #: Dictionary of associated attributes.
78 def __init__(self, name: str, time: Nullable[datetime] = None, parent: Nullable[Span] = None) -> None:
79 """
80 Initializes a named event.
82 :param name: The name of the event.
83 :param time: Optional, time when the event happened.
84 :param parent: Optional, reference to the parent span.
85 :raises ValueError: If parameter 'name' is empty.
86 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
87 """
88 if isinstance(name, str): 88 ↛ 94line 88 didn't jump to line 94 because the condition on line 88 was always true
89 if name == "": 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 raise ValueError(f"Parameter 'name' is empty.")
92 self._name = name
93 else:
94 ex = TypeError("Parameter 'name' is not of type 'str'.")
95 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
96 raise ex
98 if time is None: 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true
99 self._time = None
100 elif isinstance(time, datetime):
101 self._time = time
102 else:
103 ex = TypeError("Parameter 'time' is not of type 'datetime'.")
104 ex.add_note(f"Got type '{getFullyQualifiedName(time)}'.")
105 raise ex
107 if parent is None:
108 self._parent = None
109 elif isinstance(parent, Span): 109 ↛ 113line 109 didn't jump to line 113 because the condition on line 109 was always true
110 self._parent = parent
111 parent._events.append(self)
112 else:
113 ex = TypeError("Parameter 'parent' is not of type 'Span'.")
114 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
115 raise ex
117 self._dict = {}
119 @readonly
120 def Name(self) -> str:
121 """
122 Read-only property to access the event's name.
124 :returns: Name of the event.
125 """
126 return self._name
128 @readonly
129 def Time(self) -> datetime:
130 """
131 Read-only property to access the event's timestamp.
133 :returns: Timestamp of the event.
134 """
135 return self._time
137 @readonly
138 def Parent(self) -> Nullable[Span]:
139 """
140 Read-only property to access the event's parent span.
142 :returns: Parent span.
143 """
144 return self._parent
146 def __getitem__(self, key: str) -> Any:
147 """
148 Read an event's attached attributes (key-value-pairs) by key.
150 :param key: The key to look for.
151 :returns: The value associated to the given key.
152 """
153 return self._dict[key]
155 def __setitem__(self, key: str, value: Any) -> None:
156 """
157 Create or update an event's attached attributes (key-value-pairs) by key.
159 If a key doesn't exist yet, a new key-value-pair is created.
161 :param key: The key to create or update.
162 :param value: The value to associate to the given key.
163 """
164 self._dict[key] = value
166 def __delitem__(self, key: str) -> None:
167 """
168 Remove an entry from event's attached attributes (key-value-pairs) by key.
170 :param key: The key to remove.
171 :raises KeyError: If key doesn't exist in the event's attributes.
172 """
173 del self._dict[key]
175 def __contains__(self, key: str) -> bool:
176 """
177 Checks if the key is an attached attribute (key-value-pairs) on this event.
179 :param key: The key to check.
180 :returns: ``True``, if the key is an attached attribute.
181 """
182 return key in self._dict
184 def __iter__(self) -> Iterator[tuple[str, Any]]:
185 """
186 Returns an iterator to iterate all associated attributes of this event as :pycode:`(key, value)` tuples.
188 :returns: Iterator to iterate all attributes.
189 """
190 return iter(self._dict.items())
192 def __len__(self) -> int:
193 """
194 Returns the number of attached attributes (key-value-pairs) on this event.
196 :returns: Number of attached attributes.
197 """
198 return len(self._dict)
200 def __str__(self) -> str:
201 """
202 Return a string representation of the event.
204 :returns: The event's name.
205 """
206 return self._name
209@export
210class Span(metaclass=ExtendedType, slots=True):
211 """
212 Represents a timespan (span) within another timespan or trace.
214 It may contain sub-spans, events and arbitrary attributes (key-value pairs).
215 """
216 _name: str #: Name of the timespan
217 _parent: Nullable[Span] #: Reference to the parent span (or trace).
219 _beginTime: Nullable[datetime] #: Timestamp when the timespan begins.
220 _endTime: Nullable[datetime] #: Timestamp when the timespan ends.
221 _startTime: Nullable[int] #: Performance counter in ns when the timespan was started.
222 _stopTime: Nullable[int] #: Performance counter in ns when the timespan was stopped.
223 _totalTime: Nullable[int] #: Duration of this timespan in ns.
225 _spans: list[Span] #: Sub-timespans
226 _events: list[Event] #: Events happened within this timespan
227 _dict: dict[str, Any] #: Dictionary of associated attributes.
229 def __init__(self, name: str, parent: Nullable[Span] = None) -> None:
230 """
231 Initializes a timespan as part of a software execution trace.
233 :param name: Name of the timespan.
234 :param parent: Optional, reference to a parent span or trace.
235 :raises ValueError: If parameter 'name' is empty.
236 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
237 """
238 if isinstance(name, str): 238 ↛ 244line 238 didn't jump to line 244 because the condition on line 238 was always true
239 if name == "": 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 raise ValueError(f"Parameter 'name' is empty.")
242 self._name = name
243 else:
244 ex = TypeError("Parameter 'name' is not of type 'str'.")
245 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
246 raise ex
248 if parent is None:
249 self._parent = None
250 elif isinstance(parent, Span): 250 ↛ 254line 250 didn't jump to line 254 because the condition on line 250 was always true
251 self._parent = parent
252 parent._spans.append(self)
253 else:
254 ex = TypeError("Parameter 'parent' is not of type 'Span'.")
255 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
256 raise ex
258 self._beginTime = None
259 self._startTime = None
260 self._endTime = None
261 self._stopTime = None
262 self._totalTime = None
264 self._spans = []
265 self._events = []
266 self._dict = {}
268 @readonly
269 def Name(self) -> str:
270 """
271 Read-only property to access the timespan's name.
273 :returns: Name of the timespan.
274 """
275 return self._name
277 @readonly
278 def Parent(self) -> Nullable[Span]:
279 """
280 Read-only property to access the span's parent span or trace.
282 :returns: Parent span.
283 """
284 return self._parent
286 def _AddSpan(self, span: Span) -> Self:
287 """
288 Append a sub-span to this timespan and set this timespan as its parent.
290 :param span: The sub-span to append.
291 :returns: The appended sub-span.
292 """
293 self._spans.append(span)
294 span._parent = self
296 return span
298 @readonly
299 def HasSubSpans(self) -> bool:
300 """
301 Check if this timespan contains nested sub-spans.
303 :returns: ``True``, if the span has nested spans.
304 """
305 return len(self._spans) > 0
307 @readonly
308 def SubSpanCount(self) -> int:
309 """
310 Return the number of sub-spans within this span.
312 :returns: Number of nested spans.
313 """
314 return len(self._spans)
316 # iterate subspans with optional predicate
317 def IterateSubSpans(self) -> Iterator[Span]:
318 """
319 Returns an iterator to iterate all nested sub-spans.
321 :returns: Iterator to iterate all sub-spans.
322 """
323 return iter(self._spans)
325 @readonly
326 def HasEvents(self) -> bool:
327 """
328 Check if this timespan contains events.
330 :returns: ``True``, if the span has events.
331 """
332 return len(self._events) > 0
334 @readonly
335 def EventCount(self) -> int:
336 """
337 Return the number of events within this span.
339 :returns: Number of events.
340 """
341 return len(self._events)
343 # iterate events with optional predicate
344 def IterateEvents(self) -> Iterator[Event]:
345 """
346 Returns an iterator to iterate all embedded events.
348 :returns: Iterator to iterate all events.
349 """
350 return iter(self._events)
352 @readonly
353 def StartTime(self) -> Nullable[datetime]:
354 """
355 Read-only property accessing the absolute time when the span was started.
357 :returns: The time when the span was entered, otherwise None.
358 """
359 return self._beginTime
361 @readonly
362 def StopTime(self) -> Nullable[datetime]:
363 """
364 Read-only property accessing the absolute time when the span was stopped.
366 :returns: The time when the span was exited, otherwise None.
367 """
368 return self._endTime
370 @readonly
371 def Duration(self) -> float:
372 """
373 Read-only property accessing the duration from start operation to stop operation.
375 If the span is not yet stopped, the duration from start to now is returned.
377 :returns: Duration since span was started in seconds.
378 :raises TracingException: When span was never started.
379 """
380 if self._startTime is None:
381 raise TracingException(f"{self.__class__.__name__} was never started.")
383 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
385 @classmethod
386 def CurrentSpan(cls) -> Span:
387 """
388 Class-method to return the currently active timespan (span) or ``None``.
390 :returns: Currently active span or ``None``.
391 """
392 global _threadLocalData
394 try:
395 currentSpan = _threadLocalData.currentSpan
396 except AttributeError:
397 currentSpan = None
399 return currentSpan
401 def __enter__(self) -> Self:
402 """
403 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
405 A span will be started.
407 :returns: The span itself.
408 :raises TracingException: If no trace is active, so the span has nothing to attach to. |br|
409 Use a with-statement on :class:`Trace` to set up software execution tracing.
410 """
411 global _threadLocalData
413 try:
414 currentSpan = _threadLocalData.currentSpan
415 except AttributeError:
416 ex = TracingException("Can't setup span. No active trace.")
417 ex.add_note("Use with-statement using 'Trace()' to setup software execution tracing.")
418 raise ex
420 _threadLocalData.currentSpan = currentSpan._AddSpan(self)
422 self._beginTime = datetime.now()
423 self._startTime = perf_counter_ns()
425 return self
427 def __exit__(
428 self,
429 exc_type: Nullable[type[BaseException]] = None,
430 exc_val: Nullable[BaseException] = None,
431 exc_tb: Nullable[TracebackType] = None
432 ) -> Nullable[bool]:
433 """
434 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
436 An active span will be stopped.
438 Exit the context and ......
440 :param exc_type: Exception type
441 :param exc_val: Exception instance
442 :param exc_tb: Exception's traceback.
443 :returns: ``None``
444 """
445 global _threadLocalData
447 self._stopTime = perf_counter_ns()
448 self._endTime = datetime.now()
449 self._totalTime = self._stopTime - self._startTime
451 currentSpan = _threadLocalData.currentSpan
452 _threadLocalData.currentSpan = currentSpan._parent
454 def __getitem__(self, key: str) -> Any:
455 """
456 Read an event's attached attributes (key-value-pairs) by key.
458 :param key: The key to look for.
459 :returns: The value associated to the given key.
460 """
461 return self._dict[key]
463 def __setitem__(self, key: str, value: Any) -> None:
464 """
465 Create or update an event's attached attributes (key-value-pairs) by key.
467 If a key doesn't exist yet, a new key-value-pair is created.
469 :param key: The key to create or update.
470 :param value: The value to associate to the given key.
471 """
472 self._dict[key] = value
474 def __delitem__(self, key: str) -> None:
475 """
476 Remove an entry from event's attached attributes (key-value-pairs) by key.
478 :param key: The key to remove.
479 :raises KeyError: If key doesn't exist in the event's attributes.
480 """
481 del self._dict[key]
483 def __contains__(self, key: str) -> bool:
484 """
485 Checks if the key is an attached attribute (key-value-pairs) on this event.
487 :param key: The key to check.
488 :returns: ``True``, if the key is an attached attribute.
489 """
490 return key in self._dict
492 def __iter__(self) -> Iterator[tuple[str, Any]]:
493 """
494 Returns an iterator to iterate all associated attributes of this timespan as :pycode:`(key, value)` tuples.
496 :returns: Iterator to iterate all attributes.
497 """
498 return iter(self._dict.items())
500 def __len__(self) -> int:
501 """
502 Returns the number of attached attributes (key-value-pairs) on this event.
504 :returns: Number of attached attributes.
505 """
506 return len(self._dict)
508 def Format(self, indent: int = 1, columnSize: int = 25) -> Iterable[str]:
509 """
510 Render this timespan and its sub-spans as indented lines.
512 :param indent: Optional, indentation level of this timespan.
513 :param columnSize: Optional, column the durations are aligned at.
514 :returns: One line per timespan, deepest last.
515 """
516 result = []
517 result.append(f"{' ' * indent}🕑{self._name:<{columnSize - 2 * indent}} {self._totalTime/1e6:8.3f} ms")
518 for span in self._spans:
519 result.extend(span.Format(indent + 1, columnSize))
521 return result
523 def __repr__(self) -> str:
524 """
525 Return a detailed string representation of this timespan.
527 :returns: The timespan's name, followed by its parents up to the trace.
528 """
529 return f"{self._name} -> {self._parent!r}"
531 def __str__(self) -> str:
532 """
533 Return a string representation of the timespan.
535 :returns: The span's name.
536 """
537 return self._name
540@export
541class Trace(Span):
542 """
543 Represents a software execution trace made up of timespans (:class:`Span`).
545 The trace is the top-most element in a tree of timespans. All timespans share the same *TraceID*, thus even in a
546 distributed software execution, timespans can be aggregated with delay in a centralized database and the flow of
547 execution can be reassembled by grouping all timespans with same *TraceID*. Execution order can be derived from
548 timestamps and parallel execution is represented by overlapping timespans sharing the same parent *SpanID*. Thus, the
549 tree structure can be reassembled by inspecting the parent *SpanID* relations within the same *TraceID*.
551 A trace may contain sub-spans, events and arbitrary attributes (key-value pairs).
552 """
554 def __init__(self, name: str) -> None:
555 """
556 Initializes a software execution trace.
558 :param name: Name of the trace.
559 """
560 super().__init__(name)
562 def __enter__(self) -> Self:
563 """
564 Start the trace and register it as the current trace and current span of this thread.
566 :returns: The trace itself, so it can be named in an ``as`` clause.
567 """
568 global _threadLocalData
570 # TODO: check if a trace is already setup
571 # try:
572 # currentTrace = _threadLocalData.currentTrace
573 # except AttributeError:
574 # pass
576 _threadLocalData.currentTrace = self
577 _threadLocalData.currentSpan = self
579 self._beginTime = datetime.now()
580 self._startTime = perf_counter_ns()
582 return self
584 def __exit__(
585 self,
586 exc_type: Nullable[type[BaseException]] = None,
587 exc_val: Nullable[BaseException] = None,
588 exc_tb: Nullable[TracebackType] = None
589 ) -> Nullable[bool]:
590 """
591 Exit the context and ......
593 :param exc_type: Exception type
594 :param exc_val: Exception instance
595 :param exc_tb: Exception's traceback.
596 :returns: ``None``
597 """
598 global _threadLocalData
600 self._stopTime = perf_counter_ns()
601 self._endTime = datetime.now()
602 self._totalTime = self._stopTime - self._startTime
604 del _threadLocalData.currentTrace
605 del _threadLocalData.currentSpan
607 return None
609 @classmethod
610 def CurrentTrace(cls) -> Trace:
611 """
612 Class-method to return the currently active trace or ``None``.
614 :returns: Currently active trace or ``None``.
615 """
616 try:
617 currentTrace = _threadLocalData.currentTrace
618 except AttributeError:
619 currentTrace = None
621 return currentTrace
623 def Format(self, indent: int = 0, columnSize: int = 25) -> Iterable[str]:
624 """
625 Render this trace and its spans as indented lines.
627 :param indent: Optional, indentation level of the trace.
628 :param columnSize: Optional, column the durations are aligned at.
629 :returns: A headline, followed by one line per timespan.
630 """
631 result = []
632 result.append(f"{' ' * indent}Software Execution Trace: {self._totalTime/1e6:8.3f} ms")
633 result.append(f"{' ' * indent}📉{self._name:<{columnSize - 2}} {self._totalTime/1e6:8.3f} ms")
634 for span in self._spans:
635 result.extend(span.Format(indent + 1, columnSize - 2))
637 return result