Coverage for pyTooling/Tracing/__init__.py: 95%
480 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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 pathlib import Path
53from secrets import randbits
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
65__all__ = ["_threadLocalData", "OTLP_SCOPE_NAME"]
67OTLP_SCOPE_NAME = "pyTooling.Tracing"
68"""The instrumentation scope every exported span is reported under."""
70_threadLocalData = local()
71"""A reference to the thread local data needed by the pyTooling.Tracing classes."""
73AttributeValue = Union[
74 bool, int, float, str, bytes,
75 list["AttributeValue"], tuple["AttributeValue", ...], dict[str, "AttributeValue"]
76]
77"""
78A value that can be attached to a trace, a span or an event as an attribute.
80These are the types OTLP's ``AnyValue`` can carry, and nothing else - a value of any other type is rejected rather
81than stringified, because a silent ``str(value)`` puts a Python ``repr`` into a document a backend then indexes.
82"""
84DurationValue = Union[timedelta, int, float]
85"""
86A recorded duration, given as a :class:`~datetime.timedelta` or as a number of seconds.
88An :class:`int` is whole seconds and a :class:`float` is fractional seconds, matching the unit
89:attr:`Span.Duration` reports, so a source stating ``"duration": 98.0`` needs no conversion at the call site.
90"""
92_MAXIMUM_IDENTIFIER_ATTEMPTS = 4
93"""Number of attempts to draw a non-zero random identifier before giving up."""
96def _asTimedelta(duration: DurationValue) -> timedelta:
97 """
98 Convert a recorded duration to a :class:`~datetime.timedelta`.
100 A :class:`bool` is rejected although it is an :class:`int` in Python, because ``True`` would otherwise mean one
101 second. A non-finite :class:`float` is rejected here, because :class:`~datetime.timedelta` answers ``nan`` with its
102 own :exc:`ValueError` and infinity with an :exc:`OverflowError`, neither of which names the parameter.
104 :param duration: Duration as a :class:`~datetime.timedelta`, or as a number of seconds.
105 :returns: The duration as a :class:`~datetime.timedelta`.
106 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
107 :class:`float`.
108 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
109 :raises ValueError: If parameter 'duration' is negative.
110 """
111 if isinstance(duration, timedelta):
112 if duration < timedelta(0):
113 ex = ValueError("Parameter 'duration' is negative.")
114 ex.add_note(f"Got duration '{duration}'.")
115 raise ex
117 return duration
118 elif isinstance(duration, bool) or not isinstance(duration, (int, float)):
119 ex = TypeError("Parameter 'duration' is not of type 'timedelta', 'int' or 'float'.")
120 ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.")
121 raise ex
122 elif not isfinite(duration):
123 ex = ValueError("Parameter 'duration' is not a finite number of seconds.")
124 ex.add_note(f"Got duration '{duration}'.")
125 raise ex
126 elif duration < 0:
127 ex = ValueError("Parameter 'duration' is negative.")
128 ex.add_note(f"Got duration '{duration}'.")
129 raise ex
131 return timedelta(seconds=duration)
134def _nanoseconds(beginTime: datetime, endTime: datetime) -> int:
135 """
136 Compute the length of a timespan in nanoseconds.
138 The difference is reduced with integer arithmetic, so the result is exact to the microsecond both timestamps hold -
139 a conversion through :class:`float` seconds would not be.
141 :param beginTime: Time when the timespan began.
142 :param endTime: Time when the timespan ended.
143 :returns: Length of the timespan in nanoseconds.
144 """
145 difference = endTime - beginTime
146 seconds = difference.days * 86_400 + difference.seconds
148 return seconds * 1_000_000_000 + difference.microseconds * 1_000
151@export
152class TracingError(ToolingException):
153 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`."""
156@export
157class SpanState(Enum):
158 """An enumeration describing which of a timespan's times are filled in."""
160 Empty = 0 #: Neither begin nor end time is set, so the timespan can be timed by a ``with``-statement.
161 Running = 1 #: The begin time is set, but the end time isn't, so the timespan is still running.
162 Complete = 2 #: Begin and end time are both set.
165@export
166class OTLPArrayValue(TypedDict):
167 """OTLP's ``ArrayValue``: a list of values, as it is nested inside an :class:`OTLPAnyValue`."""
169 values: list[OTLPAnyValue] #: The elements of the array.
172@export
173class OTLPKeyValueList(TypedDict):
174 """OTLP's ``KeyValueList``: a mapping of values, as it is nested inside an :class:`OTLPAnyValue`."""
176 values: list[OTLPAttribute] #: The entries of the mapping.
179@export
180class OTLPAnyValue(TypedDict, total=False):
181 """
182 OTLP's ``AnyValue``: a value of any supported type, carried in a mapping of exactly one key.
184 The key names the type of the value. A 64-bit integer travels as a decimal **string**, because a JSON number can't
185 carry 64 bits exactly. That is proto3's JSON mapping rather than a quirk of OTLP.
186 """
188 boolValue: bool #: A boolean value.
189 intValue: str #: A 64-bit integer, encoded as a decimal string.
190 doubleValue: float #: A floating-point value.
191 stringValue: str #: A string value.
192 bytesValue: str #: A byte string, encoded as base64 - this field is ``bytes`` in proto3.
193 arrayValue: OTLPArrayValue #: A list of values.
194 kvlistValue: OTLPKeyValueList #: A mapping of values.
197@export
198class OTLPAttribute(TypedDict):
199 """OTLP's ``KeyValue``: a single attribute of a resource, a span or an event."""
201 key: str #: Name of the attribute.
202 value: OTLPAnyValue #: Value of the attribute.
205@export
206class OTLPEvent(TypedDict, total=False):
207 """OTLP's ``Span.Event``: a named point in time within a span."""
209 name: str #: Name of the event.
210 timeUnixNano: str #: Time of the event in nanoseconds since the Unix epoch, as a decimal string.
211 attributes: list[OTLPAttribute] #: Attributes attached to the event.
214@export
215class OTLPSpan(TypedDict, total=False):
216 """
217 OTLP's ``Span``: a single timespan of a trace.
219 OTLP doesn't nest spans, so the enclosing span is referenced by :attr:`parentSpanId` instead of containing this one.
220 """
222 traceId: str #: Identifier shared by every span of the trace, as 32 hex digits.
223 spanId: str #: Identifier of this span, as 16 hex digits.
224 parentSpanId: str #: Identifier of the enclosing span, absent for the trace's own span.
225 name: str #: Name of the span.
226 kind: int #: Kind of the span - always ``1`` (``SPAN_KIND_INTERNAL``) here.
227 startTimeUnixNano: str #: Start in nanoseconds since the Unix epoch, as a decimal string.
228 endTimeUnixNano: str #: End in nanoseconds since the Unix epoch, as a decimal string.
229 attributes: list[OTLPAttribute] #: Attributes attached to the span.
230 events: list[OTLPEvent] #: Events that happened within the span.
233@export
234class OTLPScope(TypedDict):
235 """OTLP's ``InstrumentationScope``: the library the spans were produced by."""
237 name: str #: Name of the instrumentation scope.
238 version: str #: Version of the instrumentation scope.
241@export
242class OTLPScopeSpans(TypedDict):
243 """OTLP's ``ScopeSpans``: the spans produced by one instrumentation scope."""
245 scope: OTLPScope #: The instrumentation scope the spans were produced by.
246 spans: list[OTLPSpan] #: The spans, flattened.
249@export
250class OTLPResource(TypedDict):
251 """OTLP's ``Resource``: the entity the spans were produced by."""
253 attributes: list[OTLPAttribute] #: Attributes describing the resource, e.g. ``service.name``.
256@export
257class OTLPResourceSpans(TypedDict):
258 """OTLP's ``ResourceSpans``: the spans produced by one resource."""
260 resource: OTLPResource #: The resource the spans were produced by.
261 scopeSpans: list[OTLPScopeSpans] #: The spans, grouped by instrumentation scope.
264@export
265class OTLPDocument(TypedDict):
266 """OTLP's ``TracesData``: the root of an OTLP/JSON document."""
268 resourceSpans: list[OTLPResourceSpans] #: The spans, grouped by resource.
271def _toAttributeValue(value: AttributeValue) -> OTLPAnyValue:
272 """
273 Wrap a Python value in OTLP's ``AnyValue`` representation.
275 Supported are :class:`bool`, :class:`int`, :class:`float`, :class:`str`, :class:`bytes`, and a :class:`list`,
276 :class:`tuple` or :class:`dict` of these - see :data:`AttributeValue`.
278 :param value: The value to wrap.
279 :returns: The value, wrapped in the one-key mapping OTLP expects for its type.
280 :raises TracingError: If the value is of a type OTLP's ``AnyValue`` can't carry.
281 """
282 # a bool is an int in Python, so it has to be recognized first
283 if isinstance(value, bool):
284 return {"boolValue": value}
285 elif isinstance(value, int):
286 return {"intValue": str(value)}
287 elif isinstance(value, float):
288 return {"doubleValue": value}
289 elif isinstance(value, str):
290 return {"stringValue": value}
291 elif isinstance(value, (bytes, bytearray)):
292 return {"bytesValue": b64encode(value).decode("ascii")}
293 elif isinstance(value, (list, tuple)):
294 return {"arrayValue": {"values": [_toAttributeValue(element) for element in value]}}
295 elif isinstance(value, dict):
296 return {"kvlistValue": {"values": _toAttributes(value.items())}}
298 ex = TracingError(f"Attribute value of type '{getFullyQualifiedName(value)}' can't be represented in OTLP.")
299 ex.add_note("Supported are: bool, int, float, str, bytes, and a list, tuple or dict of these.")
300 raise ex
303def _toAttributes(attributes: Iterable[tuple[str, AttributeValue]]) -> list[OTLPAttribute]:
304 """
305 Convert key-value pairs to OTLP's list of attributes.
307 :param attributes: The key-value pairs to convert.
308 :returns: One ``{"key": ..., "value": ...}`` mapping per pair.
309 :raises TracingError: If a value is of a type OTLP's ``AnyValue`` can't carry.
310 """
311 return [{"key": key, "value": _toAttributeValue(value)} for key, value in attributes]
314def _newIdentifier(bits: int) -> str:
315 """
316 Generate a random trace or span identifier.
318 OTLP/JSON encodes both as **hex** rather than base64, which is where it deviates from proto3's JSON mapping. An
319 all-zero identifier is invalid, so it is drawn again in that case.
321 :param bits: Width of the identifier: 128 for a trace, 64 for a span.
322 :returns: The identifier as a lower-case hex string.
323 :raises TracingError: If no non-zero identifier was drawn within :data:`_MAXIMUM_IDENTIFIER_ATTEMPTS` attempts.
324 """
325 for _ in range(_MAXIMUM_IDENTIFIER_ATTEMPTS):
326 if (identifier := randbits(bits)) != 0:
327 return f"{identifier:0{bits // 4}x}"
328 else:
329 ex = TracingError(f"Couldn't draw a non-zero {bits}-bit random identifier.")
330 ex.add_note(f"Tried {_MAXIMUM_IDENTIFIER_ATTEMPTS} times.")
331 raise ex
334@export
335class TraceElement(metaclass=ExtendedType, slots=True):
336 """
337 Base-class of a trace's elements: a named thing within a timespan, carrying arbitrary attributes
338 (key-value-pairs).
340 It holds what a :class:`Span` and an :class:`Event` have in common - their name, the timespan enclosing them and
341 their attributes - and validates the two parameters every element takes. **It doesn't attach the element to its
342 parent**, because where it goes differs: a sub-span joins :attr:`Span._spans` and an event
343 :attr:`Span._events`, and neither may happen before the derived class has validated the rest of its parameters.
345 The attributes are read, written and removed like a dictionary's items.
346 """
347 _name: str #: Name of the element.
348 _parent: Nullable[Span] #: Reference to the enclosing timespan (or trace).
349 _dict: dict[str, AttributeValue] #: Dictionary of associated attributes.
351 def __init__(self, name: str, parent: Nullable[Span] = None) -> None:
352 """
353 Initializes a trace's element, without attaching it to its parent.
355 :param name: Name of the element.
356 :param parent: Optional, reference to the enclosing timespan.
357 :raises TypeError: If parameter 'name' is not of type :class:`str`.
358 :raises ValueError: If parameter 'name' is empty.
359 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
360 """
361 if isinstance(name, str):
362 if name == "": 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 raise ValueError("Parameter 'name' is empty.")
365 self._name = name
366 else:
367 ex = TypeError("Parameter 'name' is not of type 'str'.")
368 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
369 raise ex
371 if parent is not None and not isinstance(parent, Span): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 ex = TypeError("Parameter 'parent' is not of type 'Span'.")
373 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
374 raise ex
376 self._parent = parent
377 self._dict = {}
379 @readonly
380 def Name(self) -> str:
381 """
382 Read-only property to access the element's name (:attr:`_name`).
384 :returns: Name of the element.
385 """
386 return self._name
388 @readonly
389 def Parent(self) -> Nullable[Span]:
390 """
391 Read-only property to access the timespan enclosing this element (:attr:`_parent`).
393 :returns: The enclosing timespan, or ``None`` for a trace and for an element not attached to one.
394 """
395 return self._parent
397 def get(self, key: str, default: Nullable[AttributeValue] = None) -> Nullable[AttributeValue]:
398 """
399 Read an attached attribute (key-value-pair) by key, or a default value, if the key doesn't exist.
401 This method is spelled the way :meth:`dict.get` is, because that is the behaviour it offers.
403 :param key: The key to look for.
404 :param default: Optional, the value returned if the key isn't an attached attribute. Default: ``None``.
405 :returns: The value associated to the given key, otherwise the default value.
406 """
407 return self._dict.get(key, default)
409 def __getitem__(self, key: str) -> AttributeValue:
410 """
411 Read an attached attribute (key-value-pair) by key.
413 :param key: The key to look for.
414 :returns: The value associated to the given key.
415 """
416 return self._dict[key]
418 def __setitem__(self, key: str, value: AttributeValue) -> None:
419 """
420 Create or update an attached attribute (key-value-pair) by key.
422 If a key doesn't exist yet, a new key-value-pair is created.
424 :param key: The key to create or update.
425 :param value: The value to associate to the given key.
426 """
427 self._dict[key] = value
429 def __delitem__(self, key: str) -> None:
430 """
431 Remove an attached attribute (key-value-pair) by key.
433 :param key: The key to remove.
434 :raises KeyError: If key doesn't exist in the attributes.
435 """
436 del self._dict[key]
438 def __contains__(self, key: str) -> bool:
439 """
440 Checks if the key is an attached attribute (key-value-pairs).
442 :param key: The key to check.
443 :returns: ``True``, if the key is an attached attribute.
444 """
445 return key in self._dict
447 def __iter__(self) -> Iterator[tuple[str, AttributeValue]]:
448 """
449 Returns an iterator to iterate all attached attributes as :pycode:`(key, value)` tuples.
451 :returns: Iterator to iterate all attributes.
452 """
453 return iter(self._dict.items())
455 def __len__(self) -> int:
456 """
457 Returns the number of attached attributes (key-value-pairs).
459 :returns: Number of attached attributes.
460 """
461 return len(self._dict)
464@export
465class Event(TraceElement):
466 """
467 Represents a named event within a timespan (:class:`Span`) used in a software execution trace.
469 It may contain arbitrary attributes (key-value pairs).
470 """
471 _time: datetime #: Timestamp of the event.
473 def __init__(self, name: str, time: Nullable[datetime] = None, *, parent: Nullable[Span] = None) -> None:
474 """
475 Initializes a named event.
477 :param name: The name of the event.
478 :param time: Optional, time when the event happened. Default: the current system time.
479 :param parent: Optional, reference to the parent span.
480 :raises TypeError: If parameter 'name' is not of type :class:`str`.
481 :raises ValueError: If parameter 'name' is empty.
482 :raises TypeError: If parameter 'time' is not of type :class:`~datetime.datetime`.
483 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
484 """
485 super().__init__(name, parent)
487 if time is None:
488 self._time = datetime.now()
489 elif isinstance(time, datetime):
490 self._time = time
491 else:
492 ex = TypeError("Parameter 'time' is not of type 'datetime'.")
493 ex.add_note(f"Got type '{getFullyQualifiedName(time)}'.")
494 raise ex
496 if parent is not None:
497 parent._events.append(self)
499 @readonly
500 def Time(self) -> datetime:
501 """
502 Read-only property to access the event's timestamp.
504 :returns: Timestamp of the event.
505 """
506 return self._time
508 def _ToOTLPJSON(self) -> OTLPEvent:
509 """
510 Convert this event to its **OTLP/JSON** representation.
512 :returns: The event as an OTLP mapping.
513 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
514 """
515 converted: OTLPEvent = {
516 "name": self._name,
517 "timeUnixNano": str(int(self._time.timestamp() * 1_000_000_000)),
518 }
520 if len(attributes := _toAttributes(self._dict.items())) != 0:
521 converted["attributes"] = attributes
523 return converted
525 def __str__(self) -> str:
526 """
527 Return a string representation of the event.
529 :returns: The event's name.
530 """
531 return self._name
534@export
535class Span(TraceElement):
536 """
537 Represents a timespan (span) within another timespan or trace.
539 It may contain sub-spans, events and arbitrary attributes (key-value pairs).
540 """
541 _trace: Nullable[Trace] #: Reference to the trace this timespan belongs to.
542 _spanID: str #: Identifier of this timespan, as 16 hex digits.
544 _beginTime: Nullable[datetime] #: Timestamp when the timespan begins.
545 _endTime: Nullable[datetime] #: Timestamp when the timespan ends.
546 _startTime: Nullable[int] #: Performance counter in ns when the timespan was started.
547 _stopTime: Nullable[int] #: Performance counter in ns when the timespan was stopped.
548 _totalTime: Nullable[int] #: Duration of this timespan in ns.
550 _spans: list[Span] #: Sub-timespans
551 _events: list[Event] #: Events happened within this timespan
553 def __init__(
554 self,
555 name: str,
556 beginTime: Nullable[datetime] = None,
557 endTime: Nullable[datetime] = None,
558 duration: Nullable[DurationValue] = None,
559 *,
560 parent: Nullable[Span] = None
561 ) -> None:
562 """
563 Initializes a timespan as part of a software execution trace.
565 A timespan is timed when it is entered and left by a ``with``-statement. A timespan that was measured elsewhere
566 is constructed with its recorded times instead.
568 The end of a recorded timespan is given either as ``endTime`` or as ``duration``, whichever the source reports;
569 a ``duration`` is converted to ``endTime``, so both forms are stored alike.
571 :param name: Name of the timespan.
572 :param beginTime: Optional, recorded time when the timespan began. Default: the time the timespan is entered.
573 :param endTime: Optional, recorded time when the timespan ended. Requires ``beginTime``. Default: the time
574 the timespan is left, or ``None`` for a recorded timespan, which is still running.
575 :param duration: Optional, recorded duration of the timespan, as an alternative to ``endTime``. Requires
576 ``beginTime``. A :class:`~datetime.timedelta`, or a number of seconds as :class:`int`
577 (whole) or :class:`float` (fractional).
578 :param parent: Optional, reference to a parent span or trace.
579 :raises TypeError: If parameter 'name' is not of type :class:`str`.
580 :raises ValueError: If parameter 'name' is empty.
581 :raises TypeError: If parameter 'parent' is not of type :class:`Span`.
582 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
583 :class:`float`.
584 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
585 :raises ValueError: If parameter 'duration' is given without parameter 'beginTime'.
586 :raises ValueError: If parameters 'endTime' and 'duration' are both given.
587 :raises ValueError: If parameter 'duration' is negative.
588 :raises ValueError: If parameter 'endTime' is given without parameter 'beginTime'.
589 :raises TypeError: If parameter 'beginTime' is not of type :class:`~datetime.datetime`.
590 :raises TypeError: If parameter 'endTime' is not of type :class:`~datetime.datetime`.
591 :raises ValueError: If parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.
592 :raises ValueError: If parameter 'endTime' is before parameter 'beginTime'.
593 :raises ValueError: If the timespan begins before or ends after its parent.
594 """
595 super().__init__(name, parent)
597 if duration is not None:
598 if beginTime is None:
599 ex = ValueError("Parameter 'duration' is given without parameter 'beginTime'.")
600 ex.add_note(f"Got duration '{duration}'.")
601 raise ex
602 elif endTime is not None:
603 ex = ValueError("Parameters 'endTime' and 'duration' are both given.")
604 ex.add_note(f"Got endTime '{endTime}' and duration '{duration}'.")
605 ex.add_note("Give the end of a recorded timespan either as 'endTime' or as 'duration'.")
606 raise ex
608 duration = _asTimedelta(duration)
610 if beginTime is None:
611 if endTime is not None:
612 ex = ValueError("Parameter 'endTime' is given without parameter 'beginTime'.")
613 ex.add_note(f"Got endTime '{endTime}'.")
614 raise ex
615 elif not isinstance(beginTime, datetime):
616 ex = TypeError("Parameter 'beginTime' is not of type 'datetime'.")
617 ex.add_note(f"Got type '{getFullyQualifiedName(beginTime)}'.")
618 raise ex
619 elif endTime is not None:
620 if not isinstance(endTime, datetime):
621 ex = TypeError("Parameter 'endTime' is not of type 'datetime'.")
622 ex.add_note(f"Got type '{getFullyQualifiedName(endTime)}'.")
623 raise ex
624 elif (beginTime.utcoffset() is None) != (endTime.utcoffset() is None):
625 ex = ValueError("Parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.")
626 ex.add_note(f"Got '{beginTime}' and '{endTime}'.")
627 raise ex
628 elif endTime < beginTime:
629 ex = ValueError("Parameter 'endTime' is before parameter 'beginTime'.")
630 ex.add_note(f"Got '{beginTime}' to '{endTime}'.")
631 raise ex
633 if duration is not None:
634 endTime = beginTime + duration
636 if parent is None:
637 self._trace = None
638 else:
639 self._CheckParentRange(parent, beginTime, endTime)
641 self._trace = parent._trace
642 parent._spans.append(self)
644 self._spanID = _newIdentifier(64)
646 self._beginTime = beginTime
647 self._startTime = None
648 self._endTime = endTime
649 self._stopTime = None
650 if beginTime is None or endTime is None:
651 self._totalTime = None
652 else:
653 self._totalTime = _nanoseconds(beginTime, endTime)
655 self._spans = []
656 self._events = []
658 @readonly
659 def SpanID(self) -> str:
660 """
661 Read-only property to access the timespan's identifier.
663 It is drawn when the timespan is constructed, so it identifies *this* timespan for as long as it exists.
665 :returns: Identifier of the timespan, as 16 hex digits.
666 """
667 return self._spanID
669 @readonly
670 def Trace(self) -> Nullable[Trace]:
671 """
672 Read-only property to access the trace this timespan belongs to.
674 :returns: The enclosing trace, or ``None`` while the timespan is not part of one.
675 """
676 return self._trace
678 def _AddSpan(self, span: Span) -> Self:
679 """
680 Append a sub-span to this timespan and set this timespan as its parent.
682 The sub-span joins this timespan's trace, because a span entered with a ``with``-statement is constructed
683 before it knows where it belongs.
685 :param span: The sub-span to append.
686 :returns: The appended sub-span.
687 """
688 self._spans.append(span)
689 span._parent = self
690 span._trace = self._trace
692 return span
694 @readonly
695 def HasSubSpans(self) -> bool:
696 """
697 Check if this timespan contains nested sub-spans.
699 :returns: ``True``, if the span has nested spans.
700 """
701 return len(self._spans) > 0
703 @readonly
704 def SubSpanCount(self) -> int:
705 """
706 Return the number of sub-spans within this span.
708 :returns: Number of nested spans.
709 """
710 return len(self._spans)
712 # iterate subspans with optional predicate
713 def IterateSubSpans(self) -> Iterator[Span]:
714 """
715 Returns an iterator to iterate all nested sub-spans.
717 :returns: Iterator to iterate all sub-spans.
718 """
719 return iter(self._spans)
721 @readonly
722 def HasEvents(self) -> bool:
723 """
724 Check if this timespan contains events.
726 :returns: ``True``, if the span has events.
727 """
728 return len(self._events) > 0
730 @readonly
731 def EventCount(self) -> int:
732 """
733 Return the number of events within this span.
735 :returns: Number of events.
736 """
737 return len(self._events)
739 # iterate events with optional predicate
740 def IterateEvents(self) -> Iterator[Event]:
741 """
742 Returns an iterator to iterate all embedded events.
744 :returns: Iterator to iterate all events.
745 """
746 return iter(self._events)
748 @readonly
749 def StartTime(self) -> Nullable[datetime]:
750 """
751 Read-only property accessing the absolute time when the span was started.
753 :returns: The time when the span was entered, or its recorded begin time, otherwise None.
754 """
755 return self._beginTime
757 @property
758 def StopTime(self) -> Nullable[datetime]:
759 """
760 Property accessing the absolute time when the span was stopped.
762 The end time of a recorded timespan can be assigned once, for a source that reports the end of a timespan later
763 than its begin. A timespan timed by a ``with``-statement is stopped by leaving that block, so assigning to it
764 raises.
766 :returns: The time when the span was exited, or its recorded end time, otherwise None.
767 :raises TracingError: When the timespan has no begin time yet.
768 :raises TracingError: When the timespan already has an end time.
769 :raises TracingError: When the timespan is timed by a ``with``-statement.
770 :raises TypeError: When the assigned value is not of type :class:`~datetime.datetime`.
771 :raises ValueError: When the assigned value and the begin time mix a time zone aware and a naive timestamp.
772 :raises ValueError: When the assigned value is before the begin time.
773 """
774 return self._endTime
776 @StopTime.setter
777 def StopTime(self, value: datetime) -> None:
778 if self._beginTime is None: 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true
779 ex = TracingError(f"{self.__class__.__name__} '{self._name}' has no begin time and can't be stopped.")
780 ex.add_note("Construct it with a 'beginTime' to record a timespan whose end is reported later.")
781 raise ex
782 elif self._startTime is not None:
783 ex = TracingError(f"{self.__class__.__name__} '{self._name}' is timed by a with-statement.")
784 ex.add_note("It is stopped by leaving that block.")
785 raise ex
786 elif self._endTime is not None:
787 ex = TracingError(f"{self.__class__.__name__} '{self._name}' already has an end time.")
788 ex.add_note(f"Got '{self._endTime}'.")
789 raise ex
790 elif not isinstance(value, datetime): 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true
791 ex = TypeError("Parameter 'value' is not of type 'datetime'.")
792 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
793 raise ex
794 elif (self._beginTime.utcoffset() is None) != (value.utcoffset() is None): 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 ex = ValueError("Parameter 'value' and the begin time mix a time zone aware and a naive timestamp.")
796 ex.add_note(f"Got '{self._beginTime}' and '{value}'.")
797 raise ex
798 elif value < self._beginTime:
799 ex = ValueError("Parameter 'value' is before the begin time.")
800 ex.add_note(f"Got '{self._beginTime}' to '{value}'.")
801 raise ex
803 self._endTime = value
804 self._totalTime = _nanoseconds(self._beginTime, value)
806 def Stop(self) -> Self:
807 """
808 Stop a running timespan now.
810 A timespan that ended at a time already known is stopped by assigning that time to :attr:`StopTime` instead.
812 :returns: The timespan itself, so the call can be chained.
813 :raises TracingError: When the timespan has no begin time yet. |br|
814 Construct it with a 'beginTime' to record a timespan that is stopped later.
815 :raises TracingError: When the timespan already has an end time.
816 :raises TracingError: When the timespan is timed by a ``with``-statement.
817 """
818 if self._beginTime is None:
819 ex = TracingError(f"{self.__class__.__name__} '{self._name}' has no begin time and can't be stopped.")
820 ex.add_note("Construct it with a 'beginTime' to record a timespan that is stopped later.")
821 raise ex
823 self.StopTime = datetime.now(self._beginTime.tzinfo)
825 return self
827 def _CheckParentRange(self, parent: Span, beginTime: Nullable[datetime], endTime: Nullable[datetime]) -> None:
828 """
829 Check that a recorded timespan lies within the range of its direct parent.
831 Grandparents need no check: containment is transitive, so every ancestor's range holds once each direct
832 parent-child pair is checked.
834 :param parent: The parent span or trace this timespan is attached to.
835 :param beginTime: The timespan's begin time, or ``None``.
836 :param endTime: The timespan's end time, or ``None``.
837 :raises ValueError: If the timespan begins before its parent.
838 :raises ValueError: If the timespan ends after its parent.
839 :raises ValueError: If a timespan and its parent mix a time zone aware and a naive timestamp.
840 """
841 if beginTime is None or parent._beginTime is None:
842 return
844 if (beginTime.utcoffset() is None) != (parent._beginTime.utcoffset() is None): 844 ↛ 845line 844 didn't jump to line 845 because the condition on line 844 was never true
845 ex = ValueError(
846 f"Timespan '{self._name}' and its parent '{parent._name}' mix a time zone aware and a naive timestamp."
847 )
848 ex.add_note(f"Got '{beginTime}' and '{parent._beginTime}'.")
849 raise ex
850 elif beginTime < parent._beginTime:
851 ex = ValueError(f"Timespan '{self._name}' begins before its parent '{parent._name}'.")
852 ex.add_note(f"Got '{beginTime}', while the parent begins at '{parent._beginTime}'.")
853 raise ex
855 if endTime is not None and parent._endTime is not None and endTime > parent._endTime:
856 ex = ValueError(f"Timespan '{self._name}' ends after its parent '{parent._name}'.")
857 ex.add_note(f"Got '{endTime}', while the parent ends at '{parent._endTime}'.")
858 raise ex
860 @readonly
861 def Duration(self) -> float:
862 """
863 Read-only property accessing the duration from start operation to stop operation.
865 If the span is not yet stopped, the duration from start to now is returned. For a timespan with recorded times,
866 the duration is the difference of both times, or the time since its recorded begin while it has no end.
868 :returns: Duration since span was started in seconds.
869 :raises TracingError: When span was never started.
870 """
871 if self._totalTime is not None:
872 return self._totalTime / 1e9
873 elif self._startTime is not None: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 return (perf_counter_ns() - self._startTime) / 1e9
875 elif self._beginTime is not None:
876 return (datetime.now(self._beginTime.tzinfo) - self._beginTime).total_seconds()
878 raise TracingError(f"{self.__class__.__name__} was never started.")
880 @readonly
881 def State(self) -> SpanState:
882 """
883 Read-only property accessing which of the timespan's times are filled in.
885 The state does not say where the times came from: a timespan timed by a ``with``-statement is
886 :attr:`~SpanState.Running` inside the block and :attr:`~SpanState.Complete` after it, exactly as a recorded one
887 is. Only an :attr:`~SpanState.Empty` timespan can be entered.
889 :returns: :attr:`~SpanState.Empty`, :attr:`~SpanState.Running` or :attr:`~SpanState.Complete`.
890 """
891 if self._beginTime is None:
892 return SpanState.Empty
893 elif self._endTime is None:
894 return SpanState.Running
895 else:
896 return SpanState.Complete
898 @classmethod
899 def CurrentSpan(cls) -> Span:
900 """
901 Class-method to return the currently active timespan (span) or ``None``.
903 :returns: Currently active span or ``None``.
904 """
905 global _threadLocalData
907 try:
908 currentSpan = _threadLocalData.currentSpan
909 except AttributeError:
910 currentSpan = None
912 return currentSpan
914 def __enter__(self) -> Self:
915 """
916 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
918 A span will be started.
920 :returns: The span itself.
921 :raises TracingError: If the span is not :attr:`~SpanState.Empty`. |br|
922 A timespan that was already timed can't be entered a second time.
923 :raises TracingError: If no trace is active, so the span has nothing to attach to. |br|
924 Use a with-statement on :class:`Trace` to set up software execution tracing.
925 """
926 global _threadLocalData
928 if self.State is not SpanState.Empty:
929 ex = TracingError(f"Timespan '{self._name}' is not empty and can't be entered.")
930 ex.add_note(f"Its state is '{self.State.name}'.")
931 ex.add_note("A timespan that was already timed can't be entered a second time.")
932 raise ex
934 try:
935 currentSpan = _threadLocalData.currentSpan
936 except AttributeError:
937 ex = TracingError("Can't setup span. No active trace.")
938 ex.add_note("Use with-statement using 'Trace()' to setup software execution tracing.")
939 raise ex
941 _threadLocalData.currentSpan = currentSpan._AddSpan(self)
943 self._beginTime = datetime.now()
944 self._startTime = perf_counter_ns()
946 return self
948 def __exit__(
949 self,
950 exc_type: Nullable[type[BaseException]] = None,
951 exc_val: Nullable[BaseException] = None,
952 exc_tb: Nullable[TracebackType] = None
953 ) -> Nullable[bool]:
954 """
955 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
957 An active span will be stopped.
959 Exit the context and ......
961 :param exc_type: Exception type
962 :param exc_val: Exception instance
963 :param exc_tb: Exception's traceback.
964 :returns: ``None``
965 """
966 global _threadLocalData
968 self._stopTime = perf_counter_ns()
969 self._endTime = datetime.now()
970 self._totalTime = self._stopTime - self._startTime
972 currentSpan = _threadLocalData.currentSpan
973 _threadLocalData.currentSpan = currentSpan._parent
975 def _ToOTLPJSON(self) -> list[OTLPSpan]:
976 """
977 Convert this timespan and its sub-spans to their **OTLP/JSON** representation.
979 OTLP has no nesting: the hierarchy is carried by ``parentSpanId``, so the tree is flattened into one list -
980 this timespan first, then the lists its sub-spans return - and reassembled by whoever reads the document.
982 Both identifiers come from the data model: ``spanId`` is this timespan's own, ``traceId`` belongs to the
983 trace it is part of, and ``parentSpanId`` is read off the parent relation. A timespan joins a trace through
984 a ``with``-statement, or through the ``parent`` parameter of its constructor.
986 :returns: This timespan and every timespan below it, flattened.
987 :raises TracingError: If this timespan is not part of a trace.
988 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
989 """
990 if self._trace is None:
991 ex = TracingError(f"Timespan '{self._name}' is not part of a trace.")
992 ex.add_note("A span is added to a trace by a 'with'-statement, or by the 'parent' parameter.")
993 raise ex
995 converted: OTLPSpan = {
996 "traceId": self._trace._traceID,
997 "spanId": self._spanID,
998 "name": self._name,
999 "kind": 1, # SPAN_KIND_INTERNAL
1000 }
1002 if self._parent is not None:
1003 converted["parentSpanId"] = self._parent._spanID
1005 if self.StartTime is not None:
1006 startTimeUnixNano = int(self.StartTime.timestamp() * 1_000_000_000)
1007 converted["startTimeUnixNano"] = str(startTimeUnixNano)
1009 # The wall clock has microsecond resolution while the duration comes from a nanosecond performance
1010 # counter, so the end is computed from the duration rather than read from a second wall-clock sample.
1011 # A finished timespan's total is already in nanoseconds; 'Duration' of a running one is in seconds.
1012 totalTime = int(self.Duration * 1_000_000_000) if self._totalTime is None else self._totalTime
1013 converted["endTimeUnixNano"] = str(startTimeUnixNano + totalTime)
1015 if len(attributes := _toAttributes(self._dict.items())) != 0:
1016 converted["attributes"] = attributes
1018 if len(events := [event._ToOTLPJSON() for event in self._events]) != 0:
1019 converted["events"] = events
1021 return [converted, *(span for subSpan in self._spans for span in subSpan._ToOTLPJSON())]
1023 def Format(self, indent: int = 1, columnSize: int = 25) -> Iterable[str]:
1024 """
1025 Render this timespan and its sub-spans as indented lines.
1027 :param indent: Optional, indentation level of this timespan.
1028 :param columnSize: Optional, column the durations are aligned at.
1029 :returns: One line per timespan, deepest last.
1030 """
1031 result = []
1032 result.append(f"{' ' * indent}🕑{self._name:<{columnSize - 2 * indent}} {self.Duration * 1e3:8.3f} ms")
1033 for span in self._spans:
1034 result.extend(span.Format(indent + 1, columnSize))
1036 return result
1038 def __repr__(self) -> str:
1039 """
1040 Return a detailed string representation of this timespan.
1042 :returns: The timespan's name, followed by its parents up to the trace.
1043 """
1044 return f"{self._name} -> {self._parent!r}"
1046 def __str__(self) -> str:
1047 """
1048 Return a string representation of the timespan.
1050 :returns: The span's name.
1051 """
1052 return self._name
1055@export
1056class Trace(Span):
1057 """
1058 Represents a software execution trace made up of timespans (:class:`Span`).
1060 The trace is the top-most element in a tree of timespans. All timespans share the same *TraceID*, thus even in a
1061 distributed software execution, timespans can be aggregated with delay in a centralized database and the flow of
1062 execution can be reassembled by grouping all timespans with same *TraceID*. Execution order can be derived from
1063 timestamps and parallel execution is represented by overlapping timespans sharing the same parent *SpanID*. Thus, the
1064 tree structure can be reassembled by inspecting the parent *SpanID* relations within the same *TraceID*.
1066 A trace may contain sub-spans, events and arbitrary attributes (key-value pairs).
1067 """
1068 _traceID: str #: Identifier shared by every timespan of this trace, as 32 hex digits.
1070 def __init__(
1071 self,
1072 name: str,
1073 beginTime: Nullable[datetime] = None,
1074 endTime: Nullable[datetime] = None,
1075 duration: Nullable[DurationValue] = None
1076 ) -> None:
1077 """
1078 Initializes a software execution trace.
1080 A trace is timed when it is entered and left by a ``with``-statement. A trace that was measured elsewhere is
1081 constructed with its recorded times instead, and its timespans are attached with their ``parent`` parameter.
1083 :param name: Name of the trace.
1084 :param beginTime: Optional, recorded time when the trace began. Default: the time the trace is entered.
1085 :param endTime: Optional, recorded time when the trace ended. Requires ``beginTime``. Default: the time the
1086 trace is left, or ``None`` for a recorded trace, which is still running.
1087 :param duration: Optional, recorded duration of the trace, as an alternative to ``endTime``. Requires
1088 ``beginTime``. A :class:`~datetime.timedelta`, or a number of seconds as :class:`int`
1089 (whole) or :class:`float` (fractional).
1090 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1091 :raises ValueError: If parameter 'name' is empty.
1092 :raises TypeError: If parameter 'duration' is not of type :class:`~datetime.timedelta`, :class:`int` or
1093 :class:`float`.
1094 :raises ValueError: If parameter 'duration' is not a finite number of seconds.
1095 :raises ValueError: If parameter 'duration' is given without parameter 'beginTime'.
1096 :raises ValueError: If parameters 'endTime' and 'duration' are both given.
1097 :raises ValueError: If parameter 'duration' is negative.
1098 :raises ValueError: If parameter 'endTime' is given without parameter 'beginTime'.
1099 :raises TypeError: If parameter 'beginTime' is not of type :class:`~datetime.datetime`.
1100 :raises TypeError: If parameter 'endTime' is not of type :class:`~datetime.datetime`.
1101 :raises ValueError: If parameters 'beginTime' and 'endTime' mix a time zone aware and a naive timestamp.
1102 :raises ValueError: If parameter 'endTime' is before parameter 'beginTime'.
1103 """
1104 super().__init__(name, beginTime, endTime, duration)
1106 self._traceID = _newIdentifier(128)
1107 self._trace = self
1109 @readonly
1110 def TraceID(self) -> str:
1111 """
1112 Read-only property to access the trace's identifier.
1114 It is drawn when the trace is constructed, so exporting the same trace twice reports the same ``traceId``.
1116 :returns: Identifier of the trace, as 32 hex digits.
1117 """
1118 return self._traceID
1120 def __enter__(self) -> Self:
1121 """
1122 Start the trace and register it as the current trace and current span of this thread.
1124 :returns: The trace itself, so it can be named in an ``as`` clause.
1125 :raises TracingError: If the trace is not :attr:`~SpanState.Empty`. |br|
1126 A trace that was already timed can't be entered a second time.
1127 """
1128 global _threadLocalData
1130 if self.State is not SpanState.Empty:
1131 ex = TracingError(f"Trace '{self._name}' is not empty and can't be entered.")
1132 ex.add_note(f"Its state is '{self.State.name}'.")
1133 ex.add_note("A trace that was already timed can't be entered a second time.")
1134 raise ex
1136 # TODO: check if a trace is already setup
1137 # try:
1138 # currentTrace = _threadLocalData.currentTrace
1139 # except AttributeError:
1140 # pass
1142 _threadLocalData.currentTrace = self
1143 _threadLocalData.currentSpan = self
1145 self._beginTime = datetime.now()
1146 self._startTime = perf_counter_ns()
1148 return self
1150 def __exit__(
1151 self,
1152 exc_type: Nullable[type[BaseException]] = None,
1153 exc_val: Nullable[BaseException] = None,
1154 exc_tb: Nullable[TracebackType] = None
1155 ) -> Nullable[bool]:
1156 """
1157 Exit the context and ......
1159 :param exc_type: Exception type
1160 :param exc_val: Exception instance
1161 :param exc_tb: Exception's traceback.
1162 :returns: ``None``
1163 """
1164 global _threadLocalData
1166 self._stopTime = perf_counter_ns()
1167 self._endTime = datetime.now()
1168 self._totalTime = self._stopTime - self._startTime
1170 del _threadLocalData.currentTrace
1171 del _threadLocalData.currentSpan
1173 return None
1175 @classmethod
1176 def CurrentTrace(cls) -> Trace:
1177 """
1178 Class-method to return the currently active trace or ``None``.
1180 :returns: Currently active trace or ``None``.
1181 """
1182 try:
1183 currentTrace = _threadLocalData.currentTrace
1184 except AttributeError:
1185 currentTrace = None
1187 return currentTrace
1189 def ToJSON(
1190 self,
1191 serviceName: Nullable[str] = None,
1192 scopeName: str = OTLP_SCOPE_NAME,
1193 scopeVersion: str = __version__
1194 ) -> OTLPDocument:
1195 """
1196 Convert this trace to an **OTLP/JSON** document.
1198 One format reaches both destinations: an OpenTelemetry collector accepts OTLP natively, and Jaeger has
1199 accepted it since v1.35, so nothing has to translate between them.
1201 Every timespan of the trace carries this trace's ``traceId``, and the tree is flattened into a list whose
1202 ``parentSpanId`` references carry the structure - which is how OTLP represents a trace.
1204 :param serviceName: Optional, the value of the ``service.name`` resource attribute, which is the name a
1205 backend shows the trace under. Default: the trace's name.
1206 :param scopeName: Optional, the instrumentation scope the spans are reported under - the library that
1207 produced them. Default: :data:`OTLP_SCOPE_NAME`.
1208 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1209 :returns: The trace as an OTLP/JSON document, ready for :func:`json.dump`.
1210 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
1211 """
1212 return {
1213 "resourceSpans": [{
1214 "resource": {
1215 "attributes": _toAttributes(
1216 {"service.name": self._name if serviceName is None else serviceName}.items()
1217 )
1218 },
1219 "scopeSpans": [{
1220 "scope": {"name": scopeName, "version": scopeVersion},
1221 "spans": self._ToOTLPJSON(),
1222 }],
1223 }]
1224 }
1226 def ToJSONString(
1227 self,
1228 serviceName: Nullable[str] = None,
1229 indent: Nullable[int] = None,
1230 scopeName: str = OTLP_SCOPE_NAME,
1231 scopeVersion: str = __version__
1232 ) -> str:
1233 """
1234 Convert this trace to an **OTLP/JSON** document and encode it as a string.
1236 :param serviceName: Optional, the value of the ``service.name`` resource attribute. Default: the trace's name.
1237 :param indent: Optional, indentation for a human-readable document. Default: ``None``, the compact form
1238 a collector expects.
1239 :param scopeName: Optional, the instrumentation scope the spans are reported under.
1240 Default: :data:`OTLP_SCOPE_NAME`.
1241 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1242 :returns: The OTLP/JSON document, encoded.
1243 :raises TracingError: If an attribute is of a type OTLP's ``AnyValue`` can't carry.
1244 """
1245 return json_dumps(self.ToJSON(serviceName, scopeName, scopeVersion), indent=indent)
1247 def WriteJSONFile(
1248 self,
1249 jsonFile: Path,
1250 serviceName: Nullable[str] = None,
1251 indent: Nullable[int] = None,
1252 scopeName: str = OTLP_SCOPE_NAME,
1253 scopeVersion: str = __version__
1254 ) -> None:
1255 """
1256 Write this trace to a file as an **OTLP/JSON** document.
1258 Missing parent directories are created, because the directory a pipeline collects its artifacts from - usually
1259 ``report/`` - rarely exists yet when the trace is written.
1261 :param jsonFile: Path of the file to write.
1262 :param serviceName: Optional, the value of the ``service.name`` resource attribute. Default: the trace's name.
1263 :param indent: Optional, indentation for a human-readable file. Default: ``None``, the compact form a
1264 collector expects.
1265 :param scopeName: Optional, the instrumentation scope the spans are reported under.
1266 Default: :data:`OTLP_SCOPE_NAME`.
1267 :param scopeVersion: Optional, the version of that instrumentation scope. Default: pyTooling's version.
1268 :raises TypeError: If parameter 'jsonFile' is not of type :class:`~pathlib.Path`.
1269 :raises TracingError: If the parent directories couldn't be created.
1270 :raises TracingError: If the file couldn't be written.
1271 """
1272 if not isinstance(jsonFile, Path):
1273 ex = TypeError("Parameter 'jsonFile' is not of type 'Path'.")
1274 ex.add_note(f"Got type '{getFullyQualifiedName(jsonFile)}'.")
1275 raise ex
1277 try:
1278 jsonFile.parent.mkdir(parents=True, exist_ok=True)
1279 except OSError as ex:
1280 raise TracingError(f"Directory '{jsonFile.parent}' couldn't be created.") from ex
1282 try:
1283 with jsonFile.open("w", encoding="utf-8") as file:
1284 file.write(self.ToJSONString(serviceName, indent, scopeName, scopeVersion))
1285 except OSError as ex:
1286 raise TracingError(f"OTLP/JSON file '{jsonFile}' couldn't be written.") from ex
1288 def Format(self, indent: int = 0, columnSize: int = 25) -> Iterable[str]:
1289 """
1290 Render this trace and its spans as indented lines.
1292 :param indent: Optional, indentation level of the trace.
1293 :param columnSize: Optional, column the durations are aligned at.
1294 :returns: A headline, followed by one line per timespan.
1295 """
1296 result = []
1297 result.append(f"{' ' * indent}Software Execution Trace: {self.Duration * 1e3:8.3f} ms")
1298 result.append(f"{' ' * indent}📉{self._name:<{columnSize - 2}} {self.Duration * 1e3:8.3f} ms")
1299 for span in self._spans:
1300 result.extend(span.Format(indent + 1, columnSize - 2))
1302 return result