Coverage for pyTooling/Tracing/__init__.py: 95%
491 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +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.
41.. hint::
43 See :ref:`high-level help <TRACING>` for explanations and usage examples.
44"""
45from __future__ import annotations
47from base64 import b64encode
48from datetime import datetime, timedelta
49from enum import Enum
50from json import dumps as json_dumps
51from math import isfinite
52from os import urandom
53from pathlib import Path
54from time import perf_counter_ns
55from threading import local
56from types import TracebackType
57from typing import Optional as Nullable, Iterator, Self, Iterable, TypedDict, Union
59from pyTooling.Decorators import export, readonly
60from pyTooling.MetaClasses import ExtendedType
61from pyTooling.Exceptions import ToolingException
62from pyTooling.Common import __version__, getFullyQualifiedName, getTypeName
63from pyTooling.DataModel import KeyValuePairsMixin
66__all__ = ["_threadLocalData", "OTLP_SCOPE_NAME"]
68OTLP_SCOPE_NAME = "pyTooling.Tracing"
69"""The instrumentation scope every exported span is reported under."""
71_threadLocalData = local()
72"""A reference to the thread local data needed by the pyTooling.Tracing classes."""
74AttributeValue = Union[
75 bool, int, float, str, bytes,
76 list["AttributeValue"], tuple["AttributeValue", ...], dict[str, "AttributeValue"]
77]
78"""
79A value that can be attached to a trace, a span or an event as an attribute.
81These are the types OTLP's ``AnyValue`` can carry, and nothing else - a value of any other type is rejected rather
82than stringified, because a silent ``str(value)`` puts a Python ``repr`` into a document a backend then indexes.
83"""
85DurationValue = Union[timedelta, int, float]
86"""
87A recorded duration, given as a :class:`~datetime.timedelta` or as a number of seconds.
89An :class:`int` is whole seconds and a :class:`float` is fractional seconds, matching the unit
90:attr:`Span.Duration` reports, so a source stating ``"duration": 98.0`` needs no conversion at the call site.
91"""
93_MAXIMUM_IDENTIFIER_ATTEMPTS = 4
94"""Number of attempts to draw a non-zero random identifier before giving up."""
97def _asTimedelta(duration: DurationValue) -> timedelta:
98 """
99 Convert a recorded duration to a :class:`~datetime.timedelta`.
101 A :class:`bool` is rejected although it is an :class:`int` in Python, because ``True`` would otherwise mean one
102 second. A non-finite :class:`float` is rejected here, because :class:`~datetime.timedelta` answers ``nan`` with its
103 own :exc:`ValueError` and infinity with an :exc:`OverflowError`, neither of which names the parameter.
105 :param duration: Duration as a :class:`~datetime.timedelta`, or as a number of seconds.
106 :returns: The duration as a :class:`~datetime.timedelta`.
107 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
108 :class:`float`.
109 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
110 :raises ValueError: If parameter 'duration' is negative.
111 """
112 if isinstance(duration, timedelta):
113 if duration < timedelta(0):
114 ex = ValueError("Parameter 'duration' is negative.")
115 ex.add_note(f"Got duration '{duration}'.")
116 raise ex
118 return duration
119 elif isinstance(duration, bool) or not isinstance(duration, (int, float)):
120 ex = TypeError("Parameter 'duration' is not of type 'timedelta', 'int' or 'float'.")
121 ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.")
122 raise ex
123 elif not isfinite(duration):
124 ex = ValueError("Parameter 'duration' is not a finite number of seconds.")
125 ex.add_note(f"Got duration '{duration}'.")
126 raise ex
127 elif duration < 0:
128 ex = ValueError("Parameter 'duration' is negative.")
129 ex.add_note(f"Got duration '{duration}'.")
130 raise ex
132 return timedelta(seconds=duration)
135def _nanoseconds(beginTime: datetime, endTime: datetime) -> int:
136 """
137 Compute the length of a timespan in nanoseconds.
139 The difference is reduced with integer arithmetic, so the result is exact to the microsecond both timestamps hold -
140 a conversion through :class:`float` seconds would not be.
142 :param beginTime: Time when the timespan began.
143 :param endTime: Time when the timespan ended.
144 :returns: Length of the timespan in nanoseconds.
145 """
146 difference = endTime - beginTime
147 seconds = difference.days * 86_400 + difference.seconds
149 return seconds * 1_000_000_000 + difference.microseconds * 1_000
152def _unixNanoseconds(timestamp: datetime) -> int:
153 """
154 Compute a timestamp's distance from the Unix epoch in nanoseconds.
156 Whole seconds and microseconds are converted separately with integer arithmetic, so the result is exact to the
157 microsecond the timestamp holds.
159 A naive timestamp is local time, as for :meth:`~datetime.datetime.timestamp`.
161 :param timestamp: The timestamp to convert.
162 :returns: Nanoseconds since the Unix epoch.
163 """
164 return int(timestamp.replace(microsecond=0).timestamp()) * 1_000_000_000 + timestamp.microsecond * 1_000
167@export
168class TracingError(ToolingException):
169 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`."""
172@export
173class SpanState(Enum):
174 """An enumeration describing which of a timespan's times are filled in."""
176 Empty = 0 #: Neither begin nor end time is set, so the timespan can be timed by a ``with``-statement.
177 Running = 1 #: The begin time is set, but the end time isn't, so the timespan is still running.
178 Complete = 2 #: Begin and end time are both set.
181@export
182class OTLPArrayValue(TypedDict):
183 """OTLP's ``ArrayValue``: a list of values, as it is nested inside an :class:`OTLPAnyValue`."""
185 values: list[OTLPAnyValue] #: The elements of the array.
188@export
189class OTLPKeyValueList(TypedDict):
190 """OTLP's ``KeyValueList``: a mapping of values, as it is nested inside an :class:`OTLPAnyValue`."""
192 values: list[OTLPAttribute] #: The entries of the mapping.
195@export
196class OTLPAnyValue(TypedDict, total=False):
197 """
198 OTLP's ``AnyValue``: a value of any supported type, carried in a mapping of exactly one key.
200 The key names the type of the value. A 64-bit integer travels as a decimal **string**, because a JSON number can't
201 carry 64 bits exactly. That is proto3's JSON mapping rather than a quirk of OTLP.
202 """
204 boolValue: bool #: A boolean value.
205 intValue: str #: A 64-bit integer, encoded as a decimal string.
206 doubleValue: float #: A floating-point value.
207 stringValue: str #: A string value.
208 bytesValue: str #: A byte string, encoded as base64 - this field is ``bytes`` in proto3.
209 arrayValue: OTLPArrayValue #: A list of values.
210 kvlistValue: OTLPKeyValueList #: A mapping of values.
213@export
214class OTLPAttribute(TypedDict):
215 """OTLP's ``KeyValue``: a single attribute of a resource, a span or an event."""
217 key: str #: Name of the attribute.
218 value: OTLPAnyValue #: Value of the attribute.
221@export
222class OTLPEvent(TypedDict, total=False):
223 """OTLP's ``Span.Event``: a named point in time within a span."""
225 name: str #: Name of the event.
226 timeUnixNano: str #: Time of the event in nanoseconds since the Unix epoch, as a decimal string.
227 attributes: list[OTLPAttribute] #: Attributes attached to the event.
230@export
231class OTLPStatus(TypedDict):
232 """OTLP's ``Status``: whether a span completed successfully."""
234 code: int #: Status code - always ``2`` (``STATUS_CODE_ERROR``) here, as only a failed span carries a status.
235 message: str #: Description of the error.
238@export
239class OTLPSpan(TypedDict, total=False):
240 """
241 OTLP's ``Span``: a single timespan of a trace.
243 OTLP doesn't nest spans, so the enclosing span is referenced by :attr:`parentSpanId` instead of containing this one.
244 """
246 traceId: str #: Identifier shared by every span of the trace, as 32 hex digits.
247 spanId: str #: Identifier of this span, as 16 hex digits.
248 parentSpanId: str #: Identifier of the enclosing span, absent for the trace's own span.
249 name: str #: Name of the span.
250 kind: int #: Kind of the span - always ``1`` (``SPAN_KIND_INTERNAL``) here.
251 startTimeUnixNano: str #: Start in nanoseconds since the Unix epoch, as a decimal string.
252 endTimeUnixNano: str #: End in nanoseconds since the Unix epoch, as a decimal string.
253 attributes: list[OTLPAttribute] #: Attributes attached to the span.
254 events: list[OTLPEvent] #: Events that happened within the span.
255 status: OTLPStatus #: Status of the span, present for a failed span only.
258@export
259class OTLPScope(TypedDict):
260 """OTLP's ``InstrumentationScope``: the library the spans were produced by."""
262 name: str #: Name of the instrumentation scope.
263 version: str #: Version of the instrumentation scope.
266@export
267class OTLPScopeSpans(TypedDict):
268 """OTLP's ``ScopeSpans``: the spans produced by one instrumentation scope."""
270 scope: OTLPScope #: The instrumentation scope the spans were produced by.
271 spans: list[OTLPSpan] #: The spans, flattened.
274@export
275class OTLPResource(TypedDict):
276 """OTLP's ``Resource``: the entity the spans were produced by."""
278 attributes: list[OTLPAttribute] #: Attributes describing the resource, e.g. ``service.name``.
281@export
282class OTLPResourceSpans(TypedDict):
283 """OTLP's ``ResourceSpans``: the spans produced by one resource."""
285 resource: OTLPResource #: The resource the spans were produced by.
286 scopeSpans: list[OTLPScopeSpans] #: The spans, grouped by instrumentation scope.
289@export
290class OTLPDocument(TypedDict):
291 """OTLP's ``TracesData``: the root of an OTLP/JSON document."""
293 resourceSpans: list[OTLPResourceSpans] #: The spans, grouped by resource.
296def _toAttributeValue(value: AttributeValue) -> OTLPAnyValue:
297 """
298 Wrap a Python value in OTLP's ``AnyValue`` representation.
300 Supported are :class:`bool`, :class:`int`, :class:`float`, :class:`str`, :class:`bytes`, and a :class:`list`,
301 :class:`tuple` or :class:`dict` of these - see :data:`AttributeValue`.
303 :param value: The value to wrap.
304 :returns: The value, wrapped in the one-key mapping OTLP expects for its type.
305 :raises TracingError: If the value is of a type OTLP's ``AnyValue`` can't carry.
306 """
307 # a bool is an int in Python, so it has to be recognized first
308 if isinstance(value, bool):
309 return {"boolValue": value}
310 elif isinstance(value, int):
311 return {"intValue": str(value)}
312 elif isinstance(value, float):
313 return {"doubleValue": value}
314 elif isinstance(value, str):
315 return {"stringValue": value}
316 elif isinstance(value, (bytes, bytearray)):
317 return {"bytesValue": b64encode(value).decode("ascii")}
318 elif isinstance(value, (list, tuple)):
319 return {"arrayValue": {"values": [_toAttributeValue(element) for element in value]}}
320 elif isinstance(value, dict):
321 return {"kvlistValue": {"values": _toAttributes(value.items())}}
323 ex = TracingError(f"Attribute value of type '{getFullyQualifiedName(value)}' can't be represented in OTLP.")
324 ex.add_note("Supported are: bool, int, float, str, bytes, and a list, tuple or dict of these.")
325 raise ex
328def _toAttributes(attributes: Iterable[tuple[str, AttributeValue]]) -> list[OTLPAttribute]:
329 """
330 Convert key-value pairs to OTLP's list of attributes.
332 :param attributes: The key-value pairs to convert.
333 :returns: One ``{"key": ..., "value": ...}`` mapping per pair.
334 :raises TracingError: If a value is of a type OTLP's ``AnyValue`` can't carry.
335 """
336 return [{"key": key, "value": _toAttributeValue(value)} for key, value in attributes]
339def _newIdentifier(bits: int) -> str:
340 """
341 Generate a random trace or span identifier.
343 OTLP/JSON encodes both as **hex** rather than base64, which is where it deviates from proto3's JSON mapping. An
344 all-zero identifier is invalid, so it is drawn again in that case.
346 :param bits: Width of the identifier: 128 for a trace, 64 for a span.
347 :returns: The identifier as a lower-case hex string.
348 :raises TracingError: If no non-zero identifier was drawn within :data:`_MAXIMUM_IDENTIFIER_ATTEMPTS` attempts.
349 """
350 for _ in range(_MAXIMUM_IDENTIFIER_ATTEMPTS):
351 if any(identifier := urandom(bits // 8)):
352 return identifier.hex()
353 else:
354 ex = TracingError(f"Couldn't draw a non-zero {bits}-bit random identifier.")
355 ex.add_note(f"Tried {_MAXIMUM_IDENTIFIER_ATTEMPTS} times.")
356 raise ex
359@export
360class TraceElement(KeyValuePairsMixin, metaclass=ExtendedType, slots=True):
361 """
362 Base-class of a trace's elements: a named thing within a timespan, carrying arbitrary attributes
363 (key-value-pairs).
365 It holds what a :class:`Span` and an :class:`Event` have in common - their name, the timespan enclosing them and
366 their attributes - and validates the two parameters every element takes. **It doesn't attach the element to its
367 parent**, because where it goes differs: a sub-span joins :attr:`Span._spans` and an event
368 :attr:`Span._events`, and neither may happen before the derived class has validated the rest of its parameters.
370 The attributes are read, written and removed like a dictionary's items
371 (:class:`~pyTooling.DataModel.KeyValuePairsMixin`); :meth:`IterateAttributes` yields them as pairs.
372 """
373 _name: str #: Name of the element.
374 _parent: Nullable[Span] #: Reference to the enclosing timespan (or trace).
376 def __init__(self, name: str, parent: Nullable[Span] = None) -> None:
377 """
378 Initializes a trace's element, without attaching it to its parent.
380 :param name: Name of the element.
381 :param parent: Optional, reference to the enclosing timespan.
382 :raises TypeError: If parameter 'name' is not of type :class:`str`.
383 :raises ValueError: If parameter 'name' is empty.
384 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
385 """
386 super().__init__()
388 if isinstance(name, str):
389 if name == "": 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 raise ValueError("Parameter 'name' is empty.")
392 self._name = name
393 else:
394 ex = TypeError("Parameter 'name' is not of type 'str'.")
395 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
396 raise ex
398 if parent is not None and not isinstance(parent, Span): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 ex = TypeError("Parameter 'parent' is not of type 'Span'.")
400 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
401 raise ex
403 self._parent = parent
405 @readonly
406 def Name(self) -> str:
407 """
408 Read-only property to access the element's name (:attr:`_name`).
410 :returns: Name of the element.
411 """
412 return self._name
414 @readonly
415 def Parent(self) -> Nullable[Span]:
416 """
417 Read-only property to access the timespan enclosing this element (:attr:`_parent`).
419 :returns: The enclosing timespan, or ``None`` for a trace and for an element not attached to one.
420 """
421 return self._parent
423 def IterateAttributes(self) -> Iterator[tuple[str, AttributeValue]]:
424 """
425 Returns an iterator to iterate all attached attributes as :pycode:`(key, value)` tuples.
427 :returns: Iterator to iterate all attributes.
428 """
429 return iter(self._dict.items())
432@export
433class Event(TraceElement):
434 """
435 Represents a named event within a timespan (:class:`Span`) used in a software execution trace.
437 It may contain arbitrary attributes (key-value pairs).
438 """
439 _time: datetime #: Timestamp of the event.
441 def __init__(self, name: str, time: Nullable[datetime] = None, *, parent: Nullable[Span] = None) -> None:
442 """
443 Initializes a named event.
445 :param name: The name of the event.
446 :param time: Optional, time when the event happened. Default: the current system time.
447 :param parent: Optional, reference to the parent span.
448 :raises TypeError: If parameter 'name' is not of type :class:`str`.
449 :raises ValueError: If parameter 'name' is empty.
450 :raises TypeError: If parameter 'time' is not of type :class:`~datetime.datetime`.
451 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
452 """
453 super().__init__(name, parent)
455 if time is None:
456 self._time = datetime.now()
457 elif isinstance(time, datetime):
458 self._time = time
459 else:
460 ex = TypeError("Parameter 'time' is not of type 'datetime'.")
461 ex.add_note(f"Got type '{getFullyQualifiedName(time)}'.")
462 raise ex
464 if parent is not None:
465 parent._events.append(self)
467 @readonly
468 def Time(self) -> datetime:
469 """
470 Read-only property to access the event's timestamp.
472 :returns: Timestamp of the event.
473 """
474 return self._time
476 def _ToOTLPJSON(self) -> OTLPEvent:
477 """
478 Convert this event to its **OTLP/JSON** representation.
480 :returns: The event as an OTLP mapping.
481 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
482 """
483 converted: OTLPEvent = {
484 "name": self._name,
485 "timeUnixNano": str(_unixNanoseconds(self._time)),
486 }
488 if len(attributes := _toAttributes(self._dict.items())) != 0:
489 converted["attributes"] = attributes
491 return converted
493 def __str__(self) -> str:
494 """
495 Return a string representation of the event.
497 :returns: The event's name.
498 """
499 return self._name
502@export
503class Span(TraceElement):
504 """
505 Represents a timespan (span) within another timespan or trace.
507 It may contain sub-spans, events and arbitrary attributes (key-value pairs).
508 """
509 _trace: Nullable[Trace] #: Reference to the trace this timespan belongs to.
510 _spanID: str #: Identifier of this timespan, as 16 hex digits.
512 _beginTime: Nullable[datetime] #: Timestamp when the timespan begins.
513 _endTime: Nullable[datetime] #: Timestamp when the timespan ends.
514 _startTime: Nullable[int] #: Performance counter in ns when the timespan was started.
515 _stopTime: Nullable[int] #: Performance counter in ns when the timespan was stopped.
516 _totalTime: Nullable[int] #: Duration of this timespan in ns.
518 _spans: list[Span] #: Sub-timespans
519 _events: list[Event] #: Events happened within this timespan
520 _exception: Nullable[BaseException] #: The exception that left the ``with``-statement timing this timespan.
522 def __init__(
523 self,
524 name: str,
525 beginTime: Nullable[datetime] = None,
526 endTime: Nullable[datetime] = None,
527 duration: Nullable[DurationValue] = None,
528 *,
529 parent: Nullable[Span] = None
530 ) -> None:
531 """
532 Initializes a timespan as part of a software execution trace.
534 A timespan is timed when it is entered and left by a ``with``-statement. A timespan that was measured elsewhere
535 is constructed with its recorded times instead.
537 The end of a recorded timespan is given either as ``endTime`` or as ``duration``, whichever the source reports;
538 a ``duration`` is converted to ``endTime``, so both forms are stored alike.
540 :param name: Name of the timespan.
541 :param beginTime: Optional, recorded time when the timespan began. Default: the time the timespan is entered.
542 :param endTime: Optional, recorded time when the timespan ended. Requires ``beginTime``. Default: the time
543 the timespan is left, or ``None`` for a recorded timespan, which is still running.
544 :param duration: Optional, recorded duration of the timespan, as an alternative to ``endTime``. Requires
545 ``beginTime``. A :class:`~datetime.timedelta`, or a number of seconds as :class:`int`
546 (whole) or :class:`float` (fractional).
547 :param parent: Optional, reference to a parent span or trace.
548 :raises TypeError: If parameter 'name' is not of type :class:`str`.
549 :raises ValueError: If parameter 'name' is empty.
550 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
551 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
552 :class:`float`.
553 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
554 :raises ValueError: If parameter 'duration' is given without parameter 'beginTime'.
555 :raises ValueError: If parameters 'endTime' and 'duration' are both given.
556 :raises ValueError: If parameter 'duration' is negative.
557 :raises ValueError: If parameter 'endTime' is given without parameter 'beginTime'.
558 :raises TypeError: If parameter 'beginTime' is not of type :class:`~datetime.datetime`.
559 :raises TypeError: If parameter 'endTime' is not of type :class:`~datetime.datetime`.
560 :raises ValueError: If parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.
561 :raises ValueError: If parameter 'endTime' is before parameter 'beginTime'.
562 :raises ValueError: If the timespan begins before or ends after its parent.
563 """
564 super().__init__(name, parent)
566 if duration is not None:
567 if beginTime is None:
568 ex = ValueError("Parameter 'duration' is given without parameter 'beginTime'.")
569 ex.add_note(f"Got duration '{duration}'.")
570 raise ex
571 elif endTime is not None:
572 ex = ValueError("Parameters 'endTime' and 'duration' are both given.")
573 ex.add_note(f"Got endTime '{endTime}' and duration '{duration}'.")
574 ex.add_note("Give the end of a recorded timespan either as 'endTime' or as 'duration'.")
575 raise ex
577 duration = _asTimedelta(duration)
579 if beginTime is None:
580 if endTime is not None:
581 ex = ValueError("Parameter 'endTime' is given without parameter 'beginTime'.")
582 ex.add_note(f"Got endTime '{endTime}'.")
583 raise ex
584 elif not isinstance(beginTime, datetime):
585 ex = TypeError("Parameter 'beginTime' is not of type 'datetime'.")
586 ex.add_note(f"Got type '{getFullyQualifiedName(beginTime)}'.")
587 raise ex
588 elif endTime is not None:
589 if not isinstance(endTime, datetime):
590 ex = TypeError("Parameter 'endTime' is not of type 'datetime'.")
591 ex.add_note(f"Got type '{getFullyQualifiedName(endTime)}'.")
592 raise ex
593 elif (beginTime.utcoffset() is None) != (endTime.utcoffset() is None):
594 ex = ValueError("Parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.")
595 ex.add_note(f"Got '{beginTime}' and '{endTime}'.")
596 raise ex
597 elif endTime < beginTime:
598 ex = ValueError("Parameter 'endTime' is before parameter 'beginTime'.")
599 ex.add_note(f"Got '{beginTime}' to '{endTime}'.")
600 raise ex
602 if duration is not None:
603 endTime = beginTime + duration
605 if parent is None:
606 self._trace = None
607 else:
608 self._CheckParentRange(parent, beginTime, endTime)
610 self._trace = parent._trace
611 parent._spans.append(self)
613 self._spanID = _newIdentifier(64)
615 self._beginTime = beginTime
616 self._startTime = None
617 self._endTime = endTime
618 self._stopTime = None
619 if beginTime is None or endTime is None:
620 self._totalTime = None
621 else:
622 self._totalTime = _nanoseconds(beginTime, endTime)
624 self._spans = []
625 self._events = []
626 self._exception = None
628 @readonly
629 def SpanID(self) -> str:
630 """
631 Read-only property to access the timespan's identifier.
633 It is drawn when the timespan is constructed, so it identifies *this* timespan for as long as it exists.
635 :returns: Identifier of the timespan, as 16 hex digits.
636 """
637 return self._spanID
639 @readonly
640 def Exception(self) -> Nullable[BaseException]:
641 """
642 Read-only property to access the exception that left the ``with``-statement timing this timespan
643 (:attr:`_exception`).
645 A timespan with an exception has failed. The exception isn't caught: it propagates out of the ``with``-statement
646 as before, and is recorded only.
648 :returns: The exception, or ``None`` if the timespan ended normally or wasn't timed by a ``with``-statement.
649 """
650 return self._exception
652 @readonly
653 def Trace(self) -> Nullable[Trace]:
654 """
655 Read-only property to access the trace this timespan belongs to.
657 :returns: The enclosing trace, or ``None`` while the timespan is not part of one.
658 """
659 return self._trace
661 def _AddSpan(self, span: Span) -> Self:
662 """
663 Append a sub-span to this timespan and set this timespan as its parent.
665 The sub-span joins this timespan's trace, because a span entered with a ``with``-statement is constructed
666 before it knows where it belongs.
668 :param span: The sub-span to append.
669 :returns: The appended sub-span.
670 """
671 self._spans.append(span)
672 span._parent = self
673 span._trace = self._trace
675 return span
677 @readonly
678 def HasSubSpans(self) -> bool:
679 """
680 Check if this timespan contains nested sub-spans.
682 :returns: ``True``, if the span has nested spans.
683 """
684 return len(self._spans) > 0
686 @readonly
687 def SubSpanCount(self) -> int:
688 """
689 Return the number of sub-spans within this span.
691 :returns: Number of nested spans.
692 """
693 return len(self._spans)
695 # iterate subspans with optional predicate
696 def IterateSubSpans(self) -> Iterator[Span]:
697 """
698 Returns an iterator to iterate all nested sub-spans.
700 :returns: Iterator to iterate all sub-spans.
701 """
702 return iter(self._spans)
704 @readonly
705 def HasEvents(self) -> bool:
706 """
707 Check if this timespan contains events.
709 :returns: ``True``, if the span has events.
710 """
711 return len(self._events) > 0
713 @readonly
714 def EventCount(self) -> int:
715 """
716 Return the number of events within this span.
718 :returns: Number of events.
719 """
720 return len(self._events)
722 # iterate events with optional predicate
723 def IterateEvents(self) -> Iterator[Event]:
724 """
725 Returns an iterator to iterate all embedded events.
727 :returns: Iterator to iterate all events.
728 """
729 return iter(self._events)
731 @readonly
732 def StartTime(self) -> Nullable[datetime]:
733 """
734 Read-only property accessing the absolute time when the span was started.
736 :returns: The time when the span was entered, or its recorded begin time, otherwise None.
737 """
738 return self._beginTime
740 @property
741 def StopTime(self) -> Nullable[datetime]:
742 """
743 Property accessing the absolute time when the span was stopped.
745 The end time of a recorded timespan can be assigned once, for a source that reports the end of a timespan later
746 than its begin. A timespan timed by a ``with``-statement is stopped by leaving that block, so assigning to it
747 raises.
749 :returns: The time when the span was exited, or its recorded end time, otherwise None.
750 :raises TracingError: When the timespan has no begin time yet.
751 :raises TracingError: When the timespan already has an end time.
752 :raises TracingError: When the timespan is timed by a ``with``-statement.
753 :raises TypeError: When the assigned value is not of type :class:`~datetime.datetime`.
754 :raises ValueError: When the assigned value and the begin time mix a time zone aware and a naive timestamp.
755 :raises ValueError: When the assigned value is before the begin time.
756 """
757 return self._endTime
759 @StopTime.setter
760 def StopTime(self, value: datetime) -> None:
761 if self._beginTime is None: 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true
762 ex = TracingError(f"{getTypeName(self)} '{self._name}' has no begin time and can't be stopped.")
763 ex.add_note("Construct it with a 'beginTime' to record a timespan whose end is reported later.")
764 raise ex
765 elif self._startTime is not None:
766 ex = TracingError(f"{getTypeName(self)} '{self._name}' is timed by a with-statement.")
767 ex.add_note("It is stopped by leaving that block.")
768 raise ex
769 elif self._endTime is not None:
770 ex = TracingError(f"{getTypeName(self)} '{self._name}' already has an end time.")
771 ex.add_note(f"Got '{self._endTime}'.")
772 raise ex
773 elif not isinstance(value, datetime): 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true
774 ex = TypeError("Parameter 'value' is not of type 'datetime'.")
775 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
776 raise ex
777 elif (self._beginTime.utcoffset() is None) != (value.utcoffset() is None): 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true
778 ex = ValueError("Parameter 'value' and the begin time mix a time zone aware and a naive timestamp.")
779 ex.add_note(f"Got '{self._beginTime}' and '{value}'.")
780 raise ex
781 elif value < self._beginTime:
782 ex = ValueError("Parameter 'value' is before the begin time.")
783 ex.add_note(f"Got '{self._beginTime}' to '{value}'.")
784 raise ex
786 self._endTime = value
787 self._totalTime = _nanoseconds(self._beginTime, value)
789 def Stop(self) -> Self:
790 """
791 Stop a running timespan now.
793 A timespan that ended at a time already known is stopped by assigning that time to :attr:`StopTime` instead.
795 :returns: The timespan itself, so the call can be chained.
796 :raises TracingError: When the timespan has no begin time yet. |br|
797 Construct it with a 'beginTime' to record a timespan that is stopped later.
798 :raises TracingError: When the timespan already has an end time.
799 :raises TracingError: When the timespan is timed by a ``with``-statement.
800 """
801 if self._beginTime is None:
802 ex = TracingError(f"{getTypeName(self)} '{self._name}' has no begin time and can't be stopped.")
803 ex.add_note("Construct it with a 'beginTime' to record a timespan that is stopped later.")
804 raise ex
806 self.StopTime = datetime.now(self._beginTime.tzinfo)
808 return self
810 def _CheckParentRange(self, parent: Span, beginTime: Nullable[datetime], endTime: Nullable[datetime]) -> None:
811 """
812 Check that a recorded timespan lies within the range of its direct parent.
814 Grandparents need no check: containment is transitive, so every ancestor's range holds once each direct
815 parent-child pair is checked.
817 :param parent: The parent span or trace this timespan is attached to.
818 :param beginTime: The timespan's begin time, or ``None``.
819 :param endTime: The timespan's end time, or ``None``.
820 :raises ValueError: If the timespan begins before its parent.
821 :raises ValueError: If the timespan ends after its parent.
822 :raises ValueError: If a timespan and its parent mix a time zone aware and a naive timestamp.
823 """
824 if beginTime is None or parent._beginTime is None:
825 return
827 if (beginTime.utcoffset() is None) != (parent._beginTime.utcoffset() is None): 827 ↛ 828line 827 didn't jump to line 828 because the condition on line 827 was never true
828 ex = ValueError(
829 f"Timespan '{self._name}' and its parent '{parent._name}' mix a time zone aware and a naive timestamp."
830 )
831 ex.add_note(f"Got '{beginTime}' and '{parent._beginTime}'.")
832 raise ex
833 elif beginTime < parent._beginTime:
834 ex = ValueError(f"Timespan '{self._name}' begins before its parent '{parent._name}'.")
835 ex.add_note(f"Got '{beginTime}', while the parent begins at '{parent._beginTime}'.")
836 raise ex
838 if endTime is not None and parent._endTime is not None and endTime > parent._endTime:
839 ex = ValueError(f"Timespan '{self._name}' ends after its parent '{parent._name}'.")
840 ex.add_note(f"Got '{endTime}', while the parent ends at '{parent._endTime}'.")
841 raise ex
843 @readonly
844 def Duration(self) -> timedelta:
845 """
846 Read-only property accessing the duration from start operation to stop operation.
848 If the span is not yet stopped, the duration from start to now is returned. For a timespan with recorded times,
849 the duration is the difference of both times, or the time since its recorded begin while it has no end. A
850 :class:`~datetime.timedelta` resolves microseconds; :attr:`DurationInSeconds` keeps the nanoseconds a performance
851 counter measured.
853 :returns: Duration since span was started.
854 :raises TracingError: When span was never started.
855 """
856 return timedelta(seconds=self.DurationInSeconds)
858 @readonly
859 def DurationInSeconds(self) -> float:
860 """
861 Read-only property accessing the duration from start operation to stop operation in seconds.
863 It is :attr:`Duration` as a number: from a performance counter's nanoseconds, or from the difference of the
864 recorded times.
866 :returns: Duration since span was started in seconds.
867 :raises TracingError: When span was never started.
868 """
869 if self._totalTime is not None:
870 return self._totalTime / 1e9
871 elif self._startTime is not None: 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true
872 return (perf_counter_ns() - self._startTime) / 1e9
873 elif self._beginTime is not None:
874 return (datetime.now(self._beginTime.tzinfo) - self._beginTime).total_seconds()
876 raise TracingError(f"{getTypeName(self)} was never started.")
878 @readonly
879 def State(self) -> SpanState:
880 """
881 Read-only property accessing which of the timespan's times are filled in.
883 The state does not say where the times came from: a timespan timed by a ``with``-statement is
884 :attr:`~SpanState.Running` inside the block and :attr:`~SpanState.Complete` after it, exactly as a recorded one
885 is. Only an :attr:`~SpanState.Empty` timespan can be entered.
887 :returns: Which of the timespan's times are filled in.
888 """
889 if self._beginTime is None:
890 return SpanState.Empty
891 elif self._endTime is None:
892 return SpanState.Running
893 else:
894 return SpanState.Complete
896 @classmethod
897 def CurrentSpan(cls) -> Span:
898 """
899 Class-method to return the currently active timespan (span) or ``None``.
901 :returns: Currently active span or ``None``.
902 """
903 global _threadLocalData
905 try:
906 currentSpan = _threadLocalData.currentSpan
907 except AttributeError:
908 currentSpan = None
910 return currentSpan
912 def __enter__(self) -> Self:
913 """
914 Enter the context and start the span.
916 The span is added to the current span of this thread and becomes the current span.
918 :returns: The span itself.
919 :raises TracingError: If the span is not :attr:`~SpanState.Empty`. |br|
920 A timespan that was already timed can't be entered a second time.
921 :raises TracingError: If no trace is active, so the span has nothing to attach to. |br|
922 Use a with-statement on :class:`Trace` to set up software execution tracing.
923 """
924 global _threadLocalData
926 if self.State is not SpanState.Empty:
927 ex = TracingError(f"Timespan '{self._name}' is not empty and can't be entered.")
928 ex.add_note(f"Its state is '{self.State.name}'.")
929 ex.add_note("A timespan that was already timed can't be entered a second time.")
930 raise ex
932 try:
933 currentSpan = _threadLocalData.currentSpan
934 except AttributeError:
935 ex = TracingError("Can't setup span. No active trace.")
936 ex.add_note("Use with-statement using 'Trace()' to setup software execution tracing.")
937 raise ex
939 _threadLocalData.currentSpan = currentSpan._AddSpan(self)
941 self._beginTime = datetime.now()
942 self._startTime = perf_counter_ns()
944 return self
946 def __exit__(
947 self,
948 exc_type: Nullable[type[BaseException]] = None,
949 exc_val: Nullable[BaseException] = None,
950 exc_tb: Nullable[TracebackType] = None
951 ) -> Nullable[bool]:
952 """
953 Exit the context and stop the span.
955 The span's parent becomes the current span of this thread again. An exception leaving the ``with``-statement is
956 recorded in :attr:`Exception`, and propagates.
958 :param exc_type: Optional, exception type.
959 :param exc_val: Optional, exception instance.
960 :param exc_tb: Optional, exception's traceback.
961 :returns: ``None``
962 """
963 global _threadLocalData
965 self._stopTime = perf_counter_ns()
966 self._endTime = datetime.now()
967 self._totalTime = self._stopTime - self._startTime
968 self._exception = exc_val
970 currentSpan = _threadLocalData.currentSpan
971 _threadLocalData.currentSpan = currentSpan._parent
973 def _ToOTLPJSON(self) -> list[OTLPSpan]:
974 """
975 Convert this timespan and its sub-spans to their **OTLP/JSON** representation.
977 OTLP has no nesting: the hierarchy is carried by ``parentSpanId``, so the tree is flattened into one list -
978 this timespan first, then the lists its sub-spans return - and reassembled by whoever reads the document.
980 Both identifiers come from the data model: ``spanId`` is this timespan's own, ``traceId`` belongs to the
981 trace it is part of, and ``parentSpanId`` is read off the parent relation. A timespan joins a trace through
982 a ``with``-statement, or through the ``parent`` parameter of its constructor.
984 :returns: This timespan and every timespan below it, flattened.
985 :raises TracingError: If this timespan is not part of a trace.
986 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
987 """
988 if self._trace is None:
989 ex = TracingError(f"Timespan '{self._name}' is not part of a trace.")
990 ex.add_note("A span is added to a trace by a 'with'-statement, or by the 'parent' parameter.")
991 raise ex
993 converted: OTLPSpan = {
994 "traceId": self._trace._traceID,
995 "spanId": self._spanID,
996 "name": self._name,
997 "kind": 1, # SPAN_KIND_INTERNAL
998 }
1000 if self._parent is not None:
1001 converted["parentSpanId"] = self._parent._spanID
1003 if self.StartTime is not None: 1003 ↛ 1013line 1003 didn't jump to line 1013 because the condition on line 1003 was always true
1004 startTimeUnixNano = _unixNanoseconds(self.StartTime)
1005 converted["startTimeUnixNano"] = str(startTimeUnixNano)
1007 # The wall clock has microsecond resolution while the duration comes from a nanosecond performance
1008 # counter, so the end is computed from the duration rather than read from a second wall-clock sample.
1009 # A finished timespan's total is already in nanoseconds; 'DurationInSeconds' of a running one is in seconds.
1010 totalTime = int(self.DurationInSeconds * 1_000_000_000) if self._totalTime is None else self._totalTime
1011 converted["endTimeUnixNano"] = str(startTimeUnixNano + totalTime)
1013 if len(attributes := _toAttributes(self._dict.items())) != 0:
1014 converted["attributes"] = attributes
1016 if len(events := [event._ToOTLPJSON() for event in self._events]) != 0:
1017 converted["events"] = events
1019 if self._exception is not None:
1020 converted["status"] = {
1021 "code": 2, # STATUS_CODE_ERROR
1022 "message": f"{getFullyQualifiedName(self._exception)}: {self._exception}"
1023 }
1025 return [converted, *(span for subSpan in self._spans for span in subSpan._ToOTLPJSON())]
1027 def _FormatDuration(self) -> str:
1028 """
1029 Format the duration in milliseconds, right-aligned in 8 characters, as :meth:`Format` lines show it.
1031 :returns: The duration with unit, or ``--`` with unit for a timespan that never started.
1032 """
1033 if self.State is SpanState.Empty:
1034 return f"{'--':>8} ms"
1036 return f"{self.DurationInSeconds * 1e3:8.3f} ms"
1038 def Format(self, indent: int = 1, columnSize: int = 25) -> Iterable[str]:
1039 """
1040 Render this timespan and its sub-spans as indented lines.
1042 A timespan that never started (:attr:`~SpanState.Empty`) has no duration; it shows ``--`` instead.
1044 :param indent: Optional, indentation level of this timespan.
1045 :param columnSize: Optional, column the durations are aligned at.
1046 :returns: One line per timespan, deepest last.
1047 """
1048 result = []
1049 result.append(f"{' ' * indent}🕑{self._name:<{columnSize - 2 * indent}} {self._FormatDuration()}")
1050 for span in self._spans:
1051 result.extend(span.Format(indent + 1, columnSize))
1053 return result
1055 def __repr__(self) -> str:
1056 """
1057 Return a detailed string representation of this timespan.
1059 :returns: The timespan's name, followed by its parents up to the trace.
1060 """
1061 return f"{self._name} -> {self._parent!r}"
1063 def __str__(self) -> str:
1064 """
1065 Return a string representation of the timespan.
1067 :returns: The span's name.
1068 """
1069 return self._name
1072@export
1073class Trace(Span):
1074 """
1075 Represents a software execution trace made up of timespans (:class:`Span`).
1077 The trace is the top-most element in a tree of timespans. All timespans share the same *TraceID*, thus even in a
1078 distributed software execution, timespans can be aggregated with delay in a centralized database and the flow of
1079 execution can be reassembled by grouping all timespans with same *TraceID*. Execution order can be derived from
1080 timestamps and parallel execution is represented by overlapping timespans sharing the same parent *SpanID*. Thus, the
1081 tree structure can be reassembled by inspecting the parent *SpanID* relations within the same *TraceID*.
1083 A trace may contain sub-spans, events and arbitrary attributes (key-value pairs).
1084 """
1085 _traceID: str #: Identifier shared by every timespan of this trace, as 32 hex digits.
1087 def __init__(
1088 self,
1089 name: str,
1090 beginTime: Nullable[datetime] = None,
1091 endTime: Nullable[datetime] = None,
1092 duration: Nullable[DurationValue] = None
1093 ) -> None:
1094 """
1095 Initializes a software execution trace.
1097 A trace is timed when it is entered and left by a ``with``-statement. A trace that was measured elsewhere is
1098 constructed with its recorded times instead, and its timespans are attached with their ``parent`` parameter.
1100 :param name: Name of the trace.
1101 :param beginTime: Optional, recorded time when the trace began. Default: the time the trace is entered.
1102 :param endTime: Optional, recorded time when the trace ended. Requires ``beginTime``. Default: the time the
1103 trace is left, or ``None`` for a recorded trace, which is still running.
1104 :param duration: Optional, recorded duration of the trace, as an alternative to ``endTime``. Requires
1105 ``beginTime``. A :class:`~datetime.timedelta`, or a number of seconds as :class:`int`
1106 (whole) or :class:`float` (fractional).
1107 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1108 :raises ValueError: If parameter 'name' is empty.
1109 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
1110 :class:`float`.
1111 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
1112 :raises ValueError: If parameter 'duration' is given without parameter 'beginTime'.
1113 :raises ValueError: If parameters 'endTime' and 'duration' are both given.
1114 :raises ValueError: If parameter 'duration' is negative.
1115 :raises ValueError: If parameter 'endTime' is given without parameter 'beginTime'.
1116 :raises TypeError: If parameter 'beginTime' is not of type :class:`~datetime.datetime`.
1117 :raises TypeError: If parameter 'endTime' is not of type :class:`~datetime.datetime`.
1118 :raises ValueError: If parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.
1119 :raises ValueError: If parameter 'endTime' is before parameter 'beginTime'.
1120 """
1121 super().__init__(name, beginTime, endTime, duration)
1123 self._traceID = _newIdentifier(128)
1124 self._trace = self
1126 @readonly
1127 def TraceID(self) -> str:
1128 """
1129 Read-only property to access the trace's identifier.
1131 It is drawn when the trace is constructed, so exporting the same trace twice reports the same ``traceId``.
1133 :returns: Identifier of the trace, as 32 hex digits.
1134 """
1135 return self._traceID
1137 def __enter__(self) -> Self:
1138 """
1139 Enter the context and start the trace.
1141 The trace becomes the current trace and the current span of this thread.
1143 :returns: The trace itself, so it can be named in an ``as`` clause.
1144 :raises TracingError: If the trace is not :attr:`~SpanState.Empty`. |br|
1145 A trace that was already timed can't be entered a second time.
1146 """
1147 global _threadLocalData
1149 if self.State is not SpanState.Empty:
1150 ex = TracingError(f"Trace '{self._name}' is not empty and can't be entered.")
1151 ex.add_note(f"Its state is '{self.State.name}'.")
1152 ex.add_note("A trace that was already timed can't be entered a second time.")
1153 raise ex
1155 # TODO: check if a trace is already setup
1156 # try:
1157 # currentTrace = _threadLocalData.currentTrace
1158 # except AttributeError:
1159 # pass
1161 _threadLocalData.currentTrace = self
1162 _threadLocalData.currentSpan = self
1164 self._beginTime = datetime.now()
1165 self._startTime = perf_counter_ns()
1167 return self
1169 def __exit__(
1170 self,
1171 exc_type: Nullable[type[BaseException]] = None,
1172 exc_val: Nullable[BaseException] = None,
1173 exc_tb: Nullable[TracebackType] = None
1174 ) -> Nullable[bool]:
1175 """
1176 Exit the context and stop the trace.
1178 The current thread has no active trace or span afterwards. An exception leaving the ``with``-statement is
1179 recorded in :attr:`~Span.Exception`, and propagates.
1181 :param exc_type: Optional, exception type.
1182 :param exc_val: Optional, exception instance.
1183 :param exc_tb: Optional, exception's traceback.
1184 :returns: ``None``
1185 """
1186 global _threadLocalData
1188 self._stopTime = perf_counter_ns()
1189 self._endTime = datetime.now()
1190 self._totalTime = self._stopTime - self._startTime
1191 self._exception = exc_val
1193 del _threadLocalData.currentTrace
1194 del _threadLocalData.currentSpan
1196 return None
1198 @classmethod
1199 def CurrentTrace(cls) -> Trace:
1200 """
1201 Class-method to return the currently active trace or ``None``.
1203 :returns: Currently active trace or ``None``.
1204 """
1205 try:
1206 currentTrace = _threadLocalData.currentTrace
1207 except AttributeError:
1208 currentTrace = None
1210 return currentTrace
1212 def ToJSON(
1213 self,
1214 serviceName: Nullable[str] = None,
1215 scopeName: str = OTLP_SCOPE_NAME,
1216 scopeVersion: str = __version__
1217 ) -> OTLPDocument:
1218 """
1219 Convert this trace to an **OTLP/JSON** document.
1221 One format reaches both destinations: an OpenTelemetry collector accepts OTLP natively, and Jaeger has
1222 accepted it since v1.35, so nothing has to translate between them.
1224 Every timespan of the trace carries this trace's ``traceId``, and the tree is flattened into a list whose
1225 ``parentSpanId`` references carry the structure - which is how OTLP represents a trace.
1227 :param serviceName: Optional, the value of the ``service.name`` resource attribute, which is the name a
1228 backend shows the trace under. Default: the trace's name.
1229 :param scopeName: Optional, the instrumentation scope the spans are reported under - the library that
1230 produced them. Default: :data:`OTLP_SCOPE_NAME`.
1231 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1232 :returns: The trace as an OTLP/JSON document, ready for :func:`json.dump`.
1233 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
1234 """
1235 return {
1236 "resourceSpans": [{
1237 "resource": {
1238 "attributes": _toAttributes(
1239 {"service.name": self._name if serviceName is None else serviceName}.items()
1240 )
1241 },
1242 "scopeSpans": [{
1243 "scope": {"name": scopeName, "version": scopeVersion},
1244 "spans": self._ToOTLPJSON(),
1245 }],
1246 }]
1247 }
1249 def ToJSONString(
1250 self,
1251 serviceName: Nullable[str] = None,
1252 indent: Nullable[int] = None,
1253 scopeName: str = OTLP_SCOPE_NAME,
1254 scopeVersion: str = __version__
1255 ) -> str:
1256 """
1257 Convert this trace to an **OTLP/JSON** document and encode it as a string.
1259 :param serviceName: Optional, the value of the ``service.name`` resource attribute. Default: the trace's name.
1260 :param indent: Optional, indentation for a human-readable document. Default: ``None``, the compact form
1261 a collector expects.
1262 :param scopeName: Optional, the instrumentation scope the spans are reported under.
1263 Default: :data:`OTLP_SCOPE_NAME`.
1264 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1265 :returns: The OTLP/JSON document, encoded.
1266 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
1267 """
1268 return json_dumps(self.ToJSON(serviceName, scopeName, scopeVersion), indent=indent)
1270 def WriteJSONFile(
1271 self,
1272 jsonFile: Path,
1273 serviceName: Nullable[str] = None,
1274 indent: Nullable[int] = None,
1275 scopeName: str = OTLP_SCOPE_NAME,
1276 scopeVersion: str = __version__
1277 ) -> None:
1278 """
1279 Write this trace to a file as an **OTLP/JSON** document.
1281 Missing parent directories are created, because the directory a pipeline collects its artifacts from - usually
1282 ``report/`` - rarely exists yet when the trace is written.
1284 :param jsonFile: Path of the file to write.
1285 :param serviceName: Optional, the value of the ``service.name`` resource attribute. Default: the trace's name.
1286 :param indent: Optional, indentation for a human-readable file. Default: ``None``, the compact form a
1287 collector expects.
1288 :param scopeName: Optional, the instrumentation scope the spans are reported under.
1289 Default: :data:`OTLP_SCOPE_NAME`.
1290 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1291 :raises TypeError: If parameter 'jsonFile' is not of type :class:`~pathlib.Path`.
1292 :raises TracingError: If the parent directories couldn't be created.
1293 :raises TracingError: If the file couldn't be written.
1294 """
1295 if not isinstance(jsonFile, Path):
1296 ex = TypeError("Parameter 'jsonFile' is not of type 'Path'.")
1297 ex.add_note(f"Got type '{getFullyQualifiedName(jsonFile)}'.")
1298 raise ex
1300 try:
1301 jsonFile.parent.mkdir(parents=True, exist_ok=True)
1302 except OSError as ex:
1303 raise TracingError(f"Directory '{jsonFile.parent}' couldn't be created.") from ex
1305 try:
1306 with jsonFile.open("w", encoding="utf-8") as file:
1307 file.write(self.ToJSONString(serviceName, indent, scopeName, scopeVersion))
1308 except OSError as ex:
1309 raise TracingError(f"OTLP/JSON file '{jsonFile}' couldn't be written.") from ex
1311 def Format(self, indent: int = 0, columnSize: int = 25) -> Iterable[str]:
1312 """
1313 Render this trace and its spans as indented lines.
1315 A trace or span that never started (:attr:`~SpanState.Empty`) has no duration; it shows ``--`` instead.
1317 :param indent: Optional, indentation level of the trace.
1318 :param columnSize: Optional, column the durations are aligned at.
1319 :returns: A headline, followed by one line per timespan.
1320 """
1321 result = []
1322 result.append(f"{' ' * indent}Software Execution Trace: {self._FormatDuration()}")
1323 result.append(f"{' ' * indent}📉{self._name:<{columnSize - 2}} {self._FormatDuration()}")
1324 for span in self._spans:
1325 result.extend(span.Format(indent + 1, columnSize - 2))
1327 return result