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

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. 

33 

34.. seealso:: 

35 

36 :mod:`pyTooling.Stopwatch` 

37 |rarr| A single measurement instead of nested timespans. 

38 :mod:`pyTooling.Tree` 

39 |rarr| The tree data structure spans and their sub-spans form. 

40 

41.. hint:: 

42 

43 See :ref:`high-level help <TRACING>` for explanations and usage examples. 

44""" 

45from __future__ import annotations 

46 

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 

58 

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 

64 

65 

66__all__ = ["_threadLocalData", "OTLP_SCOPE_NAME"] 

67 

68OTLP_SCOPE_NAME = "pyTooling.Tracing" 

69"""The instrumentation scope every exported span is reported under.""" 

70 

71_threadLocalData = local() 

72"""A reference to the thread local data needed by the pyTooling.Tracing classes.""" 

73 

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. 

80 

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""" 

84 

85DurationValue = Union[timedelta, int, float] 

86""" 

87A recorded duration, given as a :class:`~datetime.timedelta` or as a number of seconds. 

88 

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""" 

92 

93_MAXIMUM_IDENTIFIER_ATTEMPTS = 4 

94"""Number of attempts to draw a non-zero random identifier before giving up.""" 

95 

96 

97def _asTimedelta(duration: DurationValue) -> timedelta: 

98 """ 

99 Convert a recorded duration to a :class:`~datetime.timedelta`. 

100 

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. 

104 

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 

117 

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 

131 

132 return timedelta(seconds=duration) 

133 

134 

135def _nanoseconds(beginTime: datetime, endTime: datetime) -> int: 

136 """ 

137 Compute the length of a timespan in nanoseconds. 

138 

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. 

141 

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 

148 

149 return seconds * 1_000_000_000 + difference.microseconds * 1_000 

150 

151 

152def _unixNanoseconds(timestamp: datetime) -> int: 

153 """ 

154 Compute a timestamp's distance from the Unix epoch in nanoseconds. 

155 

156 Whole seconds and microseconds are converted separately with integer arithmetic, so the result is exact to the 

157 microsecond the timestamp holds. 

158 

159 A naive timestamp is local time, as for :meth:`~datetime.datetime.timestamp`. 

160 

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 

165 

166 

167@export 

168class TracingError(ToolingException): 

169 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`.""" 

170 

171 

172@export 

173class SpanState(Enum): 

174 """An enumeration describing which of a timespan's times are filled in.""" 

175 

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. 

179 

180 

181@export 

182class OTLPArrayValue(TypedDict): 

183 """OTLP's ``ArrayValue``: a list of values, as it is nested inside an :class:`OTLPAnyValue`.""" 

184 

185 values: list[OTLPAnyValue] #: The elements of the array. 

186 

187 

188@export 

189class OTLPKeyValueList(TypedDict): 

190 """OTLP's ``KeyValueList``: a mapping of values, as it is nested inside an :class:`OTLPAnyValue`.""" 

191 

192 values: list[OTLPAttribute] #: The entries of the mapping. 

193 

194 

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. 

199 

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 """ 

203 

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. 

211 

212 

213@export 

214class OTLPAttribute(TypedDict): 

215 """OTLP's ``KeyValue``: a single attribute of a resource, a span or an event.""" 

216 

217 key: str #: Name of the attribute. 

218 value: OTLPAnyValue #: Value of the attribute. 

219 

220 

221@export 

222class OTLPEvent(TypedDict, total=False): 

223 """OTLP's ``Span.Event``: a named point in time within a span.""" 

224 

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. 

228 

229 

230@export 

231class OTLPStatus(TypedDict): 

232 """OTLP's ``Status``: whether a span completed successfully.""" 

233 

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. 

236 

237 

238@export 

239class OTLPSpan(TypedDict, total=False): 

240 """ 

241 OTLP's ``Span``: a single timespan of a trace. 

242 

243 OTLP doesn't nest spans, so the enclosing span is referenced by :attr:`parentSpanId` instead of containing this one. 

244 """ 

245 

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. 

256 

257 

258@export 

259class OTLPScope(TypedDict): 

260 """OTLP's ``InstrumentationScope``: the library the spans were produced by.""" 

261 

262 name: str #: Name of the instrumentation scope. 

263 version: str #: Version of the instrumentation scope. 

264 

265 

266@export 

267class OTLPScopeSpans(TypedDict): 

268 """OTLP's ``ScopeSpans``: the spans produced by one instrumentation scope.""" 

269 

270 scope: OTLPScope #: The instrumentation scope the spans were produced by. 

271 spans: list[OTLPSpan] #: The spans, flattened. 

272 

273 

274@export 

275class OTLPResource(TypedDict): 

276 """OTLP's ``Resource``: the entity the spans were produced by.""" 

277 

278 attributes: list[OTLPAttribute] #: Attributes describing the resource, e.g. ``service.name``. 

279 

280 

281@export 

282class OTLPResourceSpans(TypedDict): 

283 """OTLP's ``ResourceSpans``: the spans produced by one resource.""" 

284 

285 resource: OTLPResource #: The resource the spans were produced by. 

286 scopeSpans: list[OTLPScopeSpans] #: The spans, grouped by instrumentation scope. 

287 

288 

289@export 

290class OTLPDocument(TypedDict): 

291 """OTLP's ``TracesData``: the root of an OTLP/JSON document.""" 

292 

293 resourceSpans: list[OTLPResourceSpans] #: The spans, grouped by resource. 

294 

295 

296def _toAttributeValue(value: AttributeValue) -> OTLPAnyValue: 

297 """ 

298 Wrap a Python value in OTLP's ``AnyValue`` representation. 

299 

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`. 

302 

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())}} 

322 

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 

326 

327 

328def _toAttributes(attributes: Iterable[tuple[str, AttributeValue]]) -> list[OTLPAttribute]: 

329 """ 

330 Convert key-value pairs to OTLP's list of attributes. 

331 

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] 

337 

338 

339def _newIdentifier(bits: int) -> str: 

340 """ 

341 Generate a random trace or span identifier. 

342 

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. 

345 

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 

357 

358 

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). 

364 

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. 

369 

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). 

375 

376 def __init__(self, name: str, parent: Nullable[Span] = None) -> None: 

377 """ 

378 Initializes a trace's element, without attaching it to its parent. 

379 

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__() 

387 

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.") 

391 

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 

397 

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 

402 

403 self._parent = parent 

404 

405 @readonly 

406 def Name(self) -> str: 

407 """ 

408 Read-only property to access the element's name (:attr:`_name`). 

409 

410 :returns: Name of the element. 

411 """ 

412 return self._name 

413 

414 @readonly 

415 def Parent(self) -> Nullable[Span]: 

416 """ 

417 Read-only property to access the timespan enclosing this element (:attr:`_parent`). 

418 

419 :returns: The enclosing timespan, or ``None`` for a trace and for an element not attached to one. 

420 """ 

421 return self._parent 

422 

423 def IterateAttributes(self) -> Iterator[tuple[str, AttributeValue]]: 

424 """ 

425 Returns an iterator to iterate all attached attributes as :pycode:`(key, value)` tuples. 

426 

427 :returns: Iterator to iterate all attributes. 

428 """ 

429 return iter(self._dict.items()) 

430 

431 

432@export 

433class Event(TraceElement): 

434 """ 

435 Represents a named event within a timespan (:class:`Span`) used in a software execution trace. 

436 

437 It may contain arbitrary attributes (key-value pairs). 

438 """ 

439 _time: datetime #: Timestamp of the event. 

440 

441 def __init__(self, name: str, time: Nullable[datetime] = None, *, parent: Nullable[Span] = None) -> None: 

442 """ 

443 Initializes a named event. 

444 

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) 

454 

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 

463 

464 if parent is not None: 

465 parent._events.append(self) 

466 

467 @readonly 

468 def Time(self) -> datetime: 

469 """ 

470 Read-only property to access the event's timestamp. 

471 

472 :returns: Timestamp of the event. 

473 """ 

474 return self._time 

475 

476 def _ToOTLPJSON(self) -> OTLPEvent: 

477 """ 

478 Convert this event to its **OTLP/JSON** representation. 

479 

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 } 

487 

488 if len(attributes := _toAttributes(self._dict.items())) != 0: 

489 converted["attributes"] = attributes 

490 

491 return converted 

492 

493 def __str__(self) -> str: 

494 """ 

495 Return a string representation of the event. 

496 

497 :returns: The event's name. 

498 """ 

499 return self._name 

500 

501 

502@export 

503class Span(TraceElement): 

504 """ 

505 Represents a timespan (span) within another timespan or trace. 

506 

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. 

511 

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. 

517 

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. 

521 

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. 

533 

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. 

536 

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. 

539 

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) 

565 

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 

576 

577 duration = _asTimedelta(duration) 

578 

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 

601 

602 if duration is not None: 

603 endTime = beginTime + duration 

604 

605 if parent is None: 

606 self._trace = None 

607 else: 

608 self._CheckParentRange(parent, beginTime, endTime) 

609 

610 self._trace = parent._trace 

611 parent._spans.append(self) 

612 

613 self._spanID = _newIdentifier(64) 

614 

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) 

623 

624 self._spans = [] 

625 self._events = [] 

626 self._exception = None 

627 

628 @readonly 

629 def SpanID(self) -> str: 

630 """ 

631 Read-only property to access the timespan's identifier. 

632 

633 It is drawn when the timespan is constructed, so it identifies *this* timespan for as long as it exists. 

634 

635 :returns: Identifier of the timespan, as 16 hex digits. 

636 """ 

637 return self._spanID 

638 

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`). 

644 

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. 

647 

648 :returns: The exception, or ``None`` if the timespan ended normally or wasn't timed by a ``with``-statement. 

649 """ 

650 return self._exception 

651 

652 @readonly 

653 def Trace(self) -> Nullable[Trace]: 

654 """ 

655 Read-only property to access the trace this timespan belongs to. 

656 

657 :returns: The enclosing trace, or ``None`` while the timespan is not part of one. 

658 """ 

659 return self._trace 

660 

661 def _AddSpan(self, span: Span) -> Self: 

662 """ 

663 Append a sub-span to this timespan and set this timespan as its parent. 

664 

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. 

667 

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 

674 

675 return span 

676 

677 @readonly 

678 def HasSubSpans(self) -> bool: 

679 """ 

680 Check if this timespan contains nested sub-spans. 

681 

682 :returns: ``True``, if the span has nested spans. 

683 """ 

684 return len(self._spans) > 0 

685 

686 @readonly 

687 def SubSpanCount(self) -> int: 

688 """ 

689 Return the number of sub-spans within this span. 

690 

691 :returns: Number of nested spans. 

692 """ 

693 return len(self._spans) 

694 

695 # iterate subspans with optional predicate 

696 def IterateSubSpans(self) -> Iterator[Span]: 

697 """ 

698 Returns an iterator to iterate all nested sub-spans. 

699 

700 :returns: Iterator to iterate all sub-spans. 

701 """ 

702 return iter(self._spans) 

703 

704 @readonly 

705 def HasEvents(self) -> bool: 

706 """ 

707 Check if this timespan contains events. 

708 

709 :returns: ``True``, if the span has events. 

710 """ 

711 return len(self._events) > 0 

712 

713 @readonly 

714 def EventCount(self) -> int: 

715 """ 

716 Return the number of events within this span. 

717 

718 :returns: Number of events. 

719 """ 

720 return len(self._events) 

721 

722 # iterate events with optional predicate 

723 def IterateEvents(self) -> Iterator[Event]: 

724 """ 

725 Returns an iterator to iterate all embedded events. 

726 

727 :returns: Iterator to iterate all events. 

728 """ 

729 return iter(self._events) 

730 

731 @readonly 

732 def StartTime(self) -> Nullable[datetime]: 

733 """ 

734 Read-only property accessing the absolute time when the span was started. 

735 

736 :returns: The time when the span was entered, or its recorded begin time, otherwise None. 

737 """ 

738 return self._beginTime 

739 

740 @property 

741 def StopTime(self) -> Nullable[datetime]: 

742 """ 

743 Property accessing the absolute time when the span was stopped. 

744 

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. 

748 

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 

758 

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 

785 

786 self._endTime = value 

787 self._totalTime = _nanoseconds(self._beginTime, value) 

788 

789 def Stop(self) -> Self: 

790 """ 

791 Stop a running timespan now. 

792 

793 A timespan that ended at a time already known is stopped by assigning that time to :attr:`StopTime` instead. 

794 

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 

805 

806 self.StopTime = datetime.now(self._beginTime.tzinfo) 

807 

808 return self 

809 

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. 

813 

814 Grandparents need no check: containment is transitive, so every ancestor's range holds once each direct 

815 parent-child pair is checked. 

816 

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 

826 

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 

837 

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 

842 

843 @readonly 

844 def Duration(self) -> timedelta: 

845 """ 

846 Read-only property accessing the duration from start operation to stop operation. 

847 

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. 

852 

853 :returns: Duration since span was started. 

854 :raises TracingError: When span was never started. 

855 """ 

856 return timedelta(seconds=self.DurationInSeconds) 

857 

858 @readonly 

859 def DurationInSeconds(self) -> float: 

860 """ 

861 Read-only property accessing the duration from start operation to stop operation in seconds. 

862 

863 It is :attr:`Duration` as a number: from a performance counter's nanoseconds, or from the difference of the 

864 recorded times. 

865 

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() 

875 

876 raise TracingError(f"{getTypeName(self)} was never started.") 

877 

878 @readonly 

879 def State(self) -> SpanState: 

880 """ 

881 Read-only property accessing which of the timespan's times are filled in. 

882 

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. 

886 

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 

895 

896 @classmethod 

897 def CurrentSpan(cls) -> Span: 

898 """ 

899 Class-method to return the currently active timespan (span) or ``None``. 

900 

901 :returns: Currently active span or ``None``. 

902 """ 

903 global _threadLocalData 

904 

905 try: 

906 currentSpan = _threadLocalData.currentSpan 

907 except AttributeError: 

908 currentSpan = None 

909 

910 return currentSpan 

911 

912 def __enter__(self) -> Self: 

913 """ 

914 Enter the context and start the span. 

915 

916 The span is added to the current span of this thread and becomes the current span. 

917 

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 

925 

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 

931 

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 

938 

939 _threadLocalData.currentSpan = currentSpan._AddSpan(self) 

940 

941 self._beginTime = datetime.now() 

942 self._startTime = perf_counter_ns() 

943 

944 return self 

945 

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. 

954 

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. 

957 

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 

964 

965 self._stopTime = perf_counter_ns() 

966 self._endTime = datetime.now() 

967 self._totalTime = self._stopTime - self._startTime 

968 self._exception = exc_val 

969 

970 currentSpan = _threadLocalData.currentSpan 

971 _threadLocalData.currentSpan = currentSpan._parent 

972 

973 def _ToOTLPJSON(self) -> list[OTLPSpan]: 

974 """ 

975 Convert this timespan and its sub-spans to their **OTLP/JSON** representation. 

976 

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. 

979 

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. 

983 

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 

992 

993 converted: OTLPSpan = { 

994 "traceId": self._trace._traceID, 

995 "spanId": self._spanID, 

996 "name": self._name, 

997 "kind": 1, # SPAN_KIND_INTERNAL 

998 } 

999 

1000 if self._parent is not None: 

1001 converted["parentSpanId"] = self._parent._spanID 

1002 

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) 

1006 

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) 

1012 

1013 if len(attributes := _toAttributes(self._dict.items())) != 0: 

1014 converted["attributes"] = attributes 

1015 

1016 if len(events := [event._ToOTLPJSON() for event in self._events]) != 0: 

1017 converted["events"] = events 

1018 

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 } 

1024 

1025 return [converted, *(span for subSpan in self._spans for span in subSpan._ToOTLPJSON())] 

1026 

1027 def _FormatDuration(self) -> str: 

1028 """ 

1029 Format the duration in milliseconds, right-aligned in 8 characters, as :meth:`Format` lines show it. 

1030 

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" 

1035 

1036 return f"{self.DurationInSeconds * 1e3:8.3f} ms" 

1037 

1038 def Format(self, indent: int = 1, columnSize: int = 25) -> Iterable[str]: 

1039 """ 

1040 Render this timespan and its sub-spans as indented lines. 

1041 

1042 A timespan that never started (:attr:`~SpanState.Empty`) has no duration; it shows ``--`` instead. 

1043 

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)) 

1052 

1053 return result 

1054 

1055 def __repr__(self) -> str: 

1056 """ 

1057 Return a detailed string representation of this timespan. 

1058 

1059 :returns: The timespan's name, followed by its parents up to the trace. 

1060 """ 

1061 return f"{self._name} -> {self._parent!r}" 

1062 

1063 def __str__(self) -> str: 

1064 """ 

1065 Return a string representation of the timespan. 

1066 

1067 :returns: The span's name. 

1068 """ 

1069 return self._name 

1070 

1071 

1072@export 

1073class Trace(Span): 

1074 """ 

1075 Represents a software execution trace made up of timespans (:class:`Span`). 

1076 

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*. 

1082 

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. 

1086 

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. 

1096 

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. 

1099 

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) 

1122 

1123 self._traceID = _newIdentifier(128) 

1124 self._trace = self 

1125 

1126 @readonly 

1127 def TraceID(self) -> str: 

1128 """ 

1129 Read-only property to access the trace's identifier. 

1130 

1131 It is drawn when the trace is constructed, so exporting the same trace twice reports the same ``traceId``. 

1132 

1133 :returns: Identifier of the trace, as 32 hex digits. 

1134 """ 

1135 return self._traceID 

1136 

1137 def __enter__(self) -> Self: 

1138 """ 

1139 Enter the context and start the trace. 

1140 

1141 The trace becomes the current trace and the current span of this thread. 

1142 

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 

1148 

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 

1154 

1155 # TODO: check if a trace is already setup 

1156 # try: 

1157 # currentTrace = _threadLocalData.currentTrace 

1158 # except AttributeError: 

1159 # pass 

1160 

1161 _threadLocalData.currentTrace = self 

1162 _threadLocalData.currentSpan = self 

1163 

1164 self._beginTime = datetime.now() 

1165 self._startTime = perf_counter_ns() 

1166 

1167 return self 

1168 

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. 

1177 

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. 

1180 

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 

1187 

1188 self._stopTime = perf_counter_ns() 

1189 self._endTime = datetime.now() 

1190 self._totalTime = self._stopTime - self._startTime 

1191 self._exception = exc_val 

1192 

1193 del _threadLocalData.currentTrace 

1194 del _threadLocalData.currentSpan 

1195 

1196 return None 

1197 

1198 @classmethod 

1199 def CurrentTrace(cls) -> Trace: 

1200 """ 

1201 Class-method to return the currently active trace or ``None``. 

1202 

1203 :returns: Currently active trace or ``None``. 

1204 """ 

1205 try: 

1206 currentTrace = _threadLocalData.currentTrace 

1207 except AttributeError: 

1208 currentTrace = None 

1209 

1210 return currentTrace 

1211 

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. 

1220 

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. 

1223 

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. 

1226 

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 } 

1248 

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. 

1258 

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) 

1269 

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. 

1280 

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. 

1283 

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 

1299 

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 

1304 

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 

1310 

1311 def Format(self, indent: int = 0, columnSize: int = 25) -> Iterable[str]: 

1312 """ 

1313 Render this trace and its spans as indented lines. 

1314 

1315 A trace or span that never started (:attr:`~SpanState.Empty`) has no duration; it shows ``--`` instead. 

1316 

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)) 

1326 

1327 return result