docent-python 0.1.3a0__py3-none-any.whl → 0.1.4a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of docent-python might be problematic. Click here for more details.

docent/trace.py CHANGED
@@ -1,7 +1,5 @@
1
- import asyncio
2
1
  import atexit
3
2
  import contextvars
4
- import inspect
5
3
  import itertools
6
4
  import logging
7
5
  import os
@@ -12,8 +10,10 @@ import uuid
12
10
  from collections import defaultdict
13
11
  from contextlib import asynccontextmanager, contextmanager
14
12
  from contextvars import ContextVar, Token
13
+ from datetime import datetime, timezone
15
14
  from typing import Any, AsyncIterator, Callable, Dict, Iterator, List, Optional, Union
16
15
 
16
+ import requests
17
17
  from opentelemetry import trace
18
18
  from opentelemetry.context import Context
19
19
  from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCExporter
@@ -39,36 +39,14 @@ logger.disabled = True
39
39
 
40
40
  # Default configuration
41
41
  DEFAULT_ENDPOINT = "https://api.docent.transluce.org/rest/telemetry"
42
-
43
-
44
- def _is_async_context() -> bool:
45
- """Detect if we're in an async context."""
46
- try:
47
- # Check if we're in an async function
48
- frame = inspect.currentframe()
49
- while frame:
50
- if frame.f_code.co_flags & inspect.CO_COROUTINE:
51
- return True
52
- frame = frame.f_back
53
- return False
54
- except:
55
- return False
56
-
57
-
58
- def _is_running_in_event_loop() -> bool:
59
- """Check if we're running in an event loop."""
60
- try:
61
- asyncio.get_running_loop()
62
- return True
63
- except RuntimeError:
64
- return False
42
+ DEFAULT_COLLECTION_NAME = "default-collection-name"
65
43
 
66
44
 
67
45
  def _is_notebook() -> bool:
68
46
  """Check if we're running in a Jupyter notebook."""
69
47
  try:
70
48
  return "ipykernel" in sys.modules
71
- except:
49
+ except Exception:
72
50
  return False
73
51
 
74
52
 
@@ -77,7 +55,7 @@ class DocentTracer:
77
55
 
78
56
  def __init__(
79
57
  self,
80
- collection_name: str = "default-collection-name",
58
+ collection_name: str = DEFAULT_COLLECTION_NAME,
81
59
  collection_id: Optional[str] = None,
82
60
  agent_run_id: Optional[str] = None,
83
61
  endpoint: Union[str, List[str]] = DEFAULT_ENDPOINT,
@@ -86,7 +64,6 @@ class DocentTracer:
86
64
  enable_console_export: bool = False,
87
65
  enable_otlp_export: bool = True,
88
66
  disable_batch: bool = False,
89
- span_postprocess_callback: Optional[Callable[[ReadableSpan], None]] = None,
90
67
  ):
91
68
  """
92
69
  Initialize Docent tracing manager.
@@ -101,7 +78,6 @@ class DocentTracer:
101
78
  enable_console_export: Whether to export to console
102
79
  enable_otlp_export: Whether to export to OTLP endpoint
103
80
  disable_batch: Whether to disable batch processing (use SimpleSpanProcessor)
104
- span_postprocess_callback: Optional callback for post-processing spans
105
81
  """
106
82
  self.collection_name: str = collection_name
107
83
  self.collection_id: str = collection_id if collection_id else str(uuid.uuid4())
@@ -129,22 +105,27 @@ class DocentTracer:
129
105
  self.enable_console_export = enable_console_export
130
106
  self.enable_otlp_export = enable_otlp_export
131
107
  self.disable_batch = disable_batch
132
- self.span_postprocess_callback = span_postprocess_callback
133
108
 
134
109
  # Use separate tracer provider to avoid interfering with existing OTEL setup
135
110
  self._tracer_provider: Optional[TracerProvider] = None
136
- self._root_span: Optional[Span] = None
137
- self._root_context: Context = Context()
111
+ self._root_context: Optional[Context] = Context()
138
112
  self._tracer: Optional[trace.Tracer] = None
139
113
  self._initialized: bool = False
140
114
  self._cleanup_registered: bool = False
141
115
  self._disabled: bool = False
142
116
  self._spans_processors: List[Union[BatchSpanProcessor, SimpleSpanProcessor]] = []
143
117
 
144
- # Context variables for agent_run_id and transcript_id (thread/async safe)
118
+ # Base HTTP endpoint for direct API calls (scores, metadata, trace-done)
119
+ if len(self.endpoints) > 0:
120
+ self._api_endpoint_base: Optional[str] = self.endpoints[0]
121
+
122
+ # Context variables for agent_run_id and transcript_id
145
123
  self._collection_id_var: ContextVar[str] = contextvars.ContextVar("docent_collection_id")
146
124
  self._agent_run_id_var: ContextVar[str] = contextvars.ContextVar("docent_agent_run_id")
147
125
  self._transcript_id_var: ContextVar[str] = contextvars.ContextVar("docent_transcript_id")
126
+ self._transcript_group_id_var: ContextVar[str] = contextvars.ContextVar(
127
+ "docent_transcript_group_id"
128
+ )
148
129
  self._attributes_var: ContextVar[dict[str, Any]] = contextvars.ContextVar(
149
130
  "docent_attributes"
150
131
  )
@@ -154,18 +135,17 @@ class DocentTracer:
154
135
  )
155
136
  self._transcript_counter_lock = threading.Lock()
156
137
 
157
- def get_current_docent_span(self) -> Optional[Span]:
138
+ def get_current_agent_run_id(self) -> Optional[str]:
158
139
  """
159
- Get the current span from our isolated context.
160
- This never touches the global OpenTelemetry context.
161
- """
162
- if self._root_context is None:
163
- return None
140
+ Get the current agent run ID from context.
164
141
 
142
+ Returns:
143
+ The current agent run ID if available, None otherwise
144
+ """
165
145
  try:
166
- return trace.get_current_span(context=self._root_context)
167
- except Exception:
168
- return None
146
+ return self._agent_run_id_var.get()
147
+ except LookupError:
148
+ return self.default_agent_run_id
169
149
 
170
150
  def _register_cleanup(self):
171
151
  """Register cleanup handlers."""
@@ -187,7 +167,7 @@ class DocentTracer:
187
167
 
188
168
  def _next_span_order(self, transcript_id: str) -> int:
189
169
  """
190
- Get the next atomic span order for a given transcript_id.
170
+ Get the next span order for a given transcript_id.
191
171
  Thread-safe and guaranteed to be unique and monotonic.
192
172
  """
193
173
  with self._transcript_counter_lock:
@@ -252,17 +232,16 @@ class DocentTracer:
252
232
  resource=Resource.create({"service.name": self.collection_name})
253
233
  )
254
234
 
255
- # Add custom span processor for run_id and transcript_id
235
+ # Add custom span processor for agent_run_id and transcript_id
256
236
  class ContextSpanProcessor(SpanProcessor):
257
237
  def __init__(self, manager: "DocentTracer"):
258
238
  self.manager: "DocentTracer" = manager
259
239
 
260
240
  def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
261
- # Add collection_id, agent_run_id, transcript_id, and any other current attributes
262
- # Always add collection_id as it's always available
241
+ # Add collection_id, agent_run_id, transcript_id, transcript_group_id, and any other current attributes
263
242
  span.set_attribute("collection_id", self.manager.collection_id)
264
243
 
265
- # Handle agent_run_id
244
+ # Set agent_run_id from context
266
245
  try:
267
246
  agent_run_id: str = self.manager._agent_run_id_var.get()
268
247
  if agent_run_id:
@@ -274,7 +253,15 @@ class DocentTracer:
274
253
  span.set_attribute("agent_run_id_default", True)
275
254
  span.set_attribute("agent_run_id", self.manager.default_agent_run_id)
276
255
 
277
- # Handle transcript_id
256
+ # Set transcript_group_id from context
257
+ try:
258
+ transcript_group_id: str = self.manager._transcript_group_id_var.get()
259
+ if transcript_group_id:
260
+ span.set_attribute("transcript_group_id", transcript_group_id)
261
+ except LookupError:
262
+ pass
263
+
264
+ # Set transcript_id from context
278
265
  try:
279
266
  transcript_id: str = self.manager._transcript_id_var.get()
280
267
  if transcript_id:
@@ -286,7 +273,7 @@ class DocentTracer:
286
273
  # transcript_id not available, skip it
287
274
  pass
288
275
 
289
- # Handle attributes
276
+ # Set custom attributes from context
290
277
  try:
291
278
  attributes: dict[str, Any] = self.manager._attributes_var.get()
292
279
  for key, value in attributes.items():
@@ -340,18 +327,6 @@ class DocentTracer:
340
327
  # Get tracer from our isolated provider (don't set global provider)
341
328
  self._tracer = self._tracer_provider.get_tracer(__name__)
342
329
 
343
- # Start root span
344
- self._root_span = self._tracer.start_span(
345
- "application_session",
346
- attributes={
347
- "service.name": self.collection_name,
348
- "session.type": "application_root",
349
- },
350
- )
351
- self._root_context = trace.set_span_in_context(
352
- self._root_span, context=self._root_context
353
- )
354
-
355
330
  # Instrument threading for better context propagation
356
331
  try:
357
332
  ThreadingInstrumentor().instrument()
@@ -398,30 +373,14 @@ class DocentTracer:
398
373
  raise
399
374
 
400
375
  def cleanup(self):
401
- """Clean up Docent tracing resources."""
376
+ """Clean up Docent tracing resources and signal trace completion to backend."""
402
377
  try:
403
- # Create an explicit end-of-trace span before ending the root span
404
- if self._tracer and self._root_span:
405
- end_span = self._tracer.start_span(
406
- "trace_end",
407
- context=self._root_context,
408
- attributes={
409
- "event.type": "trace_end",
410
- },
411
- )
412
- end_span.end()
413
-
414
- if (
415
- self._root_span
416
- and hasattr(self._root_span, "is_recording")
417
- and self._root_span.is_recording()
418
- ):
419
- self._root_span.end()
420
- elif self._root_span:
421
- # Fallback if is_recording is not available
422
- self._root_span.end()
423
-
424
- self._root_span = None
378
+ # Notify backend that trace is done (no span creation)
379
+ try:
380
+ self._send_trace_done()
381
+ except Exception as e:
382
+ logger.warning(f"Failed to notify trace done: {e}")
383
+
425
384
  self._root_context = None # type: ignore
426
385
 
427
386
  # Shutdown our isolated tracer provider
@@ -485,48 +444,6 @@ class DocentTracer:
485
444
  self.initialize()
486
445
  return self._root_context
487
446
 
488
- @contextmanager
489
- def span(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> Iterator[Span]:
490
- """
491
- Context manager for creating spans with attributes.
492
- """
493
- if not self._initialized:
494
- self.initialize()
495
-
496
- if self._tracer is None:
497
- raise RuntimeError("Tracer not initialized")
498
-
499
- span_attributes: dict[str, Any] = attributes or {}
500
-
501
- with self._tracer.start_as_current_span(
502
- name, context=self._root_context, attributes=span_attributes
503
- ) as span:
504
- yield span
505
-
506
- @asynccontextmanager
507
- async def async_span(
508
- self, name: str, attributes: Optional[Dict[str, Any]] = None
509
- ) -> AsyncIterator[Span]:
510
- """
511
- Async context manager for creating spans with attributes.
512
-
513
- Args:
514
- name: Name of the span
515
- attributes: Dictionary of attributes to add to the span
516
- """
517
- if not self._initialized:
518
- self.initialize()
519
-
520
- if self._tracer is None:
521
- raise RuntimeError("Tracer not initialized")
522
-
523
- span_attributes: dict[str, Any] = attributes or {}
524
-
525
- with self._tracer.start_as_current_span(
526
- name, context=self._root_context, attributes=span_attributes
527
- ) as span:
528
- yield span
529
-
530
447
  @contextmanager
531
448
  def agent_run_context(
532
449
  self,
@@ -541,7 +458,7 @@ class DocentTracer:
541
458
  Args:
542
459
  agent_run_id: Optional agent run ID (auto-generated if not provided)
543
460
  transcript_id: Optional transcript ID (auto-generated if not provided)
544
- metadata: Optional nested dictionary of metadata to attach as events
461
+ metadata: Optional nested dictionary of metadata to send to backend
545
462
  **attributes: Additional attributes to add to the context
546
463
 
547
464
  Yields:
@@ -550,9 +467,6 @@ class DocentTracer:
550
467
  if not self._initialized:
551
468
  self.initialize()
552
469
 
553
- if self._tracer is None:
554
- raise RuntimeError("Tracer not initialized")
555
-
556
470
  if agent_run_id is None:
557
471
  agent_run_id = str(uuid.uuid4())
558
472
  if transcript_id is None:
@@ -564,20 +478,14 @@ class DocentTracer:
564
478
  attributes_token: Token[dict[str, Any]] = self._attributes_var.set(attributes)
565
479
 
566
480
  try:
567
- # Create a span with the agent run attributes
568
- span_attributes: dict[str, Any] = {
569
- "agent_run_id": agent_run_id,
570
- "transcript_id": transcript_id,
571
- **attributes,
572
- }
573
- with self._tracer.start_as_current_span(
574
- "agent_run_context", context=self._root_context, attributes=span_attributes
575
- ) as _span:
576
- # Attach metadata as events if provided
577
- if metadata:
578
- _add_metadata_event_to_span(_span, metadata)
579
-
580
- yield agent_run_id, transcript_id
481
+ # Send metadata directly to backend if provided
482
+ if metadata:
483
+ try:
484
+ self.send_agent_run_metadata(agent_run_id, metadata)
485
+ except Exception as e:
486
+ logger.warning(f"Failed sending agent run metadata: {e}")
487
+
488
+ yield agent_run_id, transcript_id
581
489
  finally:
582
490
  self._agent_run_id_var.reset(agent_run_id_token)
583
491
  self._transcript_id_var.reset(transcript_id_token)
@@ -598,7 +506,7 @@ class DocentTracer:
598
506
  Args:
599
507
  agent_run_id: Optional agent run ID (auto-generated if not provided)
600
508
  transcript_id: Optional transcript ID (auto-generated if not provided)
601
- metadata: Optional nested dictionary of metadata to attach as events
509
+ metadata: Optional nested dictionary of metadata to send to backend
602
510
  **attributes: Additional attributes to add to the context
603
511
 
604
512
  Yields:
@@ -607,9 +515,6 @@ class DocentTracer:
607
515
  if not self._initialized:
608
516
  self.initialize()
609
517
 
610
- if self._tracer is None:
611
- raise RuntimeError("Tracer not initialized")
612
-
613
518
  if agent_run_id is None:
614
519
  agent_run_id = str(uuid.uuid4())
615
520
  if transcript_id is None:
@@ -621,117 +526,415 @@ class DocentTracer:
621
526
  attributes_token: Token[dict[str, Any]] = self._attributes_var.set(attributes)
622
527
 
623
528
  try:
624
- # Create a span with the agent run attributes
625
- span_attributes: dict[str, Any] = {
626
- "agent_run_id": agent_run_id,
627
- "transcript_id": transcript_id,
628
- **attributes,
629
- }
630
- with self._tracer.start_as_current_span(
631
- "agent_run_context", context=self._root_context, attributes=span_attributes
632
- ) as _span:
633
- # Attach metadata as events if provided
634
- if metadata:
635
- _add_metadata_event_to_span(_span, metadata)
636
-
637
- yield agent_run_id, transcript_id
529
+ # Send metadata directly to backend if provided
530
+ if metadata:
531
+ try:
532
+ self.send_agent_run_metadata(agent_run_id, metadata)
533
+ except Exception as e:
534
+ logger.warning(f"Failed sending agent run metadata: {e}")
535
+
536
+ yield agent_run_id, transcript_id
638
537
  finally:
639
538
  self._agent_run_id_var.reset(agent_run_id_token)
640
539
  self._transcript_id_var.reset(transcript_id_token)
641
540
  self._attributes_var.reset(attributes_token)
642
541
 
643
- def start_transcript(
542
+ def _api_headers(self) -> Dict[str, str]:
543
+ """
544
+ Get the API headers for HTTP requests.
545
+
546
+ Returns:
547
+ Dictionary of headers including Authorization
548
+ """
549
+ return {
550
+ "Content-Type": "application/json",
551
+ "Authorization": f"Bearer {self.headers.get('Authorization', '').replace('Bearer ', '')}",
552
+ }
553
+
554
+ def _post_json(self, path: str, data: Dict[str, Any]) -> None:
555
+ if not self._api_endpoint_base:
556
+ raise RuntimeError("API endpoint base is not configured")
557
+ url = f"{self._api_endpoint_base}{path}"
558
+ try:
559
+ resp = requests.post(url, json=data, headers=self._api_headers(), timeout=10)
560
+ resp.raise_for_status()
561
+ except requests.exceptions.RequestException as e:
562
+ logger.error(f"Failed POST {url}: {e}")
563
+
564
+ def send_agent_run_score(
644
565
  self,
645
- agent_run_id: Optional[str] = None,
566
+ agent_run_id: str,
567
+ name: str,
568
+ score: float,
569
+ attributes: Optional[Dict[str, Any]] = None,
570
+ ) -> None:
571
+ """
572
+ Send a score to the backend for a specific agent run.
573
+
574
+ Args:
575
+ agent_run_id: The agent run ID
576
+ name: Name of the score metric
577
+ score: Numeric score value
578
+ attributes: Optional additional attributes
579
+ """
580
+ collection_id = self.collection_id
581
+ payload: Dict[str, Any] = {
582
+ "collection_id": collection_id,
583
+ "agent_run_id": agent_run_id,
584
+ "score_name": name,
585
+ "score_value": score,
586
+ "timestamp": datetime.now(timezone.utc).isoformat(),
587
+ }
588
+ if attributes:
589
+ payload.update(attributes)
590
+ self._post_json("/v1/scores", payload)
591
+
592
+ def send_agent_run_metadata(self, agent_run_id: str, metadata: Dict[str, Any]) -> None:
593
+ collection_id = self.collection_id
594
+ payload: Dict[str, Any] = {
595
+ "collection_id": collection_id,
596
+ "agent_run_id": agent_run_id,
597
+ "metadata": metadata,
598
+ "timestamp": datetime.now(timezone.utc).isoformat(),
599
+ }
600
+ self._post_json("/v1/agent-run-metadata", payload)
601
+
602
+ def send_transcript_metadata(
603
+ self,
604
+ transcript_id: str,
605
+ name: Optional[str] = None,
606
+ description: Optional[str] = None,
607
+ transcript_group_id: Optional[str] = None,
608
+ metadata: Optional[Dict[str, Any]] = None,
609
+ ) -> None:
610
+ """
611
+ Send transcript data to the backend.
612
+
613
+ Args:
614
+ transcript_id: The transcript ID
615
+ name: Optional transcript name
616
+ description: Optional transcript description
617
+ transcript_group_id: Optional transcript group ID
618
+ metadata: Optional metadata to send
619
+ """
620
+ collection_id = self.collection_id
621
+ payload: Dict[str, Any] = {
622
+ "collection_id": collection_id,
623
+ "transcript_id": transcript_id,
624
+ "timestamp": datetime.now(timezone.utc).isoformat(),
625
+ }
626
+
627
+ # Only add fields that are provided
628
+ if name is not None:
629
+ payload["name"] = name
630
+ if description is not None:
631
+ payload["description"] = description
632
+ if transcript_group_id is not None:
633
+ payload["transcript_group_id"] = transcript_group_id
634
+ if metadata is not None:
635
+ payload["metadata"] = metadata
636
+
637
+ self._post_json("/v1/transcript-metadata", payload)
638
+
639
+ def get_current_transcript_id(self) -> Optional[str]:
640
+ """
641
+ Get the current transcript ID from context.
642
+
643
+ Returns:
644
+ The current transcript ID if available, None otherwise
645
+ """
646
+ try:
647
+ return self._transcript_id_var.get()
648
+ except LookupError:
649
+ return None
650
+
651
+ def get_current_transcript_group_id(self) -> Optional[str]:
652
+ """
653
+ Get the current transcript group ID from context.
654
+
655
+ Returns:
656
+ The current transcript group ID if available, None otherwise
657
+ """
658
+ try:
659
+ return self._transcript_group_id_var.get()
660
+ except LookupError:
661
+ return None
662
+
663
+ @contextmanager
664
+ def transcript_context(
665
+ self,
666
+ name: Optional[str] = None,
646
667
  transcript_id: Optional[str] = None,
647
- **attributes: Any,
648
- ) -> tuple[Any, str, str]:
668
+ description: Optional[str] = None,
669
+ metadata: Optional[Dict[str, Any]] = None,
670
+ transcript_group_id: Optional[str] = None,
671
+ ) -> Iterator[str]:
649
672
  """
650
- Manually start a transcript span.
673
+ Context manager for setting up a transcript context.
651
674
 
652
675
  Args:
653
- agent_run_id: Optional agent run ID (auto-generated if not provided)
676
+ name: Optional transcript name
654
677
  transcript_id: Optional transcript ID (auto-generated if not provided)
655
- **attributes: Additional attributes to add to the span
678
+ description: Optional transcript description
679
+ metadata: Optional metadata to send to backend
680
+ transcript_group_id: Optional transcript group ID
656
681
 
657
- Returns:
658
- Tuple of (span, agent_run_id, transcript_id)
682
+ Yields:
683
+ The transcript ID
659
684
  """
660
685
  if not self._initialized:
661
- self.initialize()
686
+ raise RuntimeError(
687
+ "Tracer is not initialized. Call initialize_tracing() before using transcript context."
688
+ )
662
689
 
663
- if self._tracer is None:
664
- raise RuntimeError("Tracer not initialized")
690
+ if transcript_id is None:
691
+ transcript_id = str(uuid.uuid4())
692
+
693
+ # Determine transcript group ID before setting new context
694
+ if transcript_group_id is None:
695
+ try:
696
+ transcript_group_id = self._transcript_group_id_var.get()
697
+ except LookupError:
698
+ # No current transcript group context, this transcript has no group
699
+ transcript_group_id = None
700
+
701
+ # Set context variable for this execution context
702
+ transcript_id_token: Token[str] = self._transcript_id_var.set(transcript_id)
703
+
704
+ try:
705
+ # Send transcript data and metadata to backend
706
+ try:
707
+ self.send_transcript_metadata(
708
+ transcript_id, name, description, transcript_group_id, metadata
709
+ )
710
+ except Exception as e:
711
+ logger.warning(f"Failed sending transcript data: {e}")
712
+
713
+ yield transcript_id
714
+ finally:
715
+ # Reset context variable to previous state
716
+ self._transcript_id_var.reset(transcript_id_token)
717
+
718
+ @asynccontextmanager
719
+ async def async_transcript_context(
720
+ self,
721
+ name: Optional[str] = None,
722
+ transcript_id: Optional[str] = None,
723
+ description: Optional[str] = None,
724
+ metadata: Optional[Dict[str, Any]] = None,
725
+ transcript_group_id: Optional[str] = None,
726
+ ) -> AsyncIterator[str]:
727
+ """
728
+ Async context manager for setting up a transcript context.
729
+
730
+ Args:
731
+ name: Optional transcript name
732
+ transcript_id: Optional transcript ID (auto-generated if not provided)
733
+ description: Optional transcript description
734
+ metadata: Optional metadata to send to backend
735
+ transcript_group_id: Optional transcript group ID
736
+
737
+ Yields:
738
+ The transcript ID
739
+ """
740
+ if not self._initialized:
741
+ raise RuntimeError(
742
+ "Tracer is not initialized. Call initialize_tracing() before using transcript context."
743
+ )
665
744
 
666
- if agent_run_id is None:
667
- agent_run_id = str(uuid.uuid4())
668
745
  if transcript_id is None:
669
746
  transcript_id = str(uuid.uuid4())
670
747
 
671
- span_attributes: dict[str, Any] = {
672
- "agent_run_id": agent_run_id,
673
- "transcript_id": transcript_id,
674
- **attributes,
675
- }
748
+ # Determine transcript group ID before setting new context
749
+ if transcript_group_id is None:
750
+ try:
751
+ transcript_group_id = self._transcript_group_id_var.get()
752
+ except LookupError:
753
+ # No current transcript group context, this transcript has no group
754
+ transcript_group_id = None
676
755
 
677
- span: Any = self._tracer.start_span(
678
- "transcript_span", context=self._root_context, attributes=span_attributes
679
- )
756
+ # Set context variable for this execution context
757
+ transcript_id_token: Token[str] = self._transcript_id_var.set(transcript_id)
758
+
759
+ try:
760
+ # Send transcript data and metadata to backend
761
+ try:
762
+ self.send_transcript_metadata(
763
+ transcript_id, name, description, transcript_group_id, metadata
764
+ )
765
+ except Exception as e:
766
+ logger.warning(f"Failed sending transcript data: {e}")
680
767
 
681
- return span, agent_run_id, transcript_id
768
+ yield transcript_id
769
+ finally:
770
+ # Reset context variable to previous state
771
+ self._transcript_id_var.reset(transcript_id_token)
682
772
 
683
- def stop_transcript(self, span: Span) -> None:
773
+ def send_transcript_group_metadata(
774
+ self,
775
+ transcript_group_id: str,
776
+ name: Optional[str] = None,
777
+ description: Optional[str] = None,
778
+ parent_transcript_group_id: Optional[str] = None,
779
+ metadata: Optional[Dict[str, Any]] = None,
780
+ ) -> None:
684
781
  """
685
- Manually stop a transcript span.
782
+ Send transcript group data to the backend.
686
783
 
687
784
  Args:
688
- span: The span to stop
785
+ transcript_group_id: The transcript group ID
786
+ name: Optional transcript group name
787
+ description: Optional transcript group description
788
+ parent_transcript_group_id: Optional parent transcript group ID
789
+ metadata: Optional metadata to send
689
790
  """
690
- if span and hasattr(span, "end"):
691
- span.end()
791
+ collection_id = self.collection_id
792
+ payload: Dict[str, Any] = {
793
+ "collection_id": collection_id,
794
+ "transcript_group_id": transcript_group_id,
795
+ "timestamp": datetime.now(timezone.utc).isoformat(),
796
+ }
797
+
798
+ if name is not None:
799
+ payload["name"] = name
800
+ if description is not None:
801
+ payload["description"] = description
802
+ if parent_transcript_group_id is not None:
803
+ payload["parent_transcript_group_id"] = parent_transcript_group_id
804
+ if metadata is not None:
805
+ payload["metadata"] = metadata
806
+
807
+ self._post_json("/v1/transcript-group-metadata", payload)
692
808
 
693
- def start_span(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> Span:
809
+ @contextmanager
810
+ def transcript_group_context(
811
+ self,
812
+ name: Optional[str] = None,
813
+ transcript_group_id: Optional[str] = None,
814
+ description: Optional[str] = None,
815
+ metadata: Optional[Dict[str, Any]] = None,
816
+ parent_transcript_group_id: Optional[str] = None,
817
+ ) -> Iterator[str]:
694
818
  """
695
- Manually start a span.
819
+ Context manager for setting up a transcript group context.
696
820
 
697
821
  Args:
698
- name: Name of the span
699
- attributes: Dictionary of attributes to add to the span
822
+ name: Optional transcript group name
823
+ transcript_group_id: Optional transcript group ID (auto-generated if not provided)
824
+ description: Optional transcript group description
825
+ metadata: Optional metadata to send to backend
826
+ parent_transcript_group_id: Optional parent transcript group ID
700
827
 
701
- Returns:
702
- The created span
828
+ Yields:
829
+ The transcript group ID
703
830
  """
704
831
  if not self._initialized:
705
- self.initialize()
706
-
707
- if self._tracer is None:
708
- raise RuntimeError("Tracer not initialized")
832
+ raise RuntimeError(
833
+ "Tracer is not initialized. Call initialize_tracing() before using transcript group context."
834
+ )
709
835
 
710
- span_attributes: dict[str, Any] = attributes or {}
836
+ if transcript_group_id is None:
837
+ transcript_group_id = str(uuid.uuid4())
711
838
 
712
- span: Span = self._tracer.start_span(
713
- name, context=self._root_context, attributes=span_attributes
839
+ # Determine parent transcript group ID before setting new context
840
+ if parent_transcript_group_id is None:
841
+ try:
842
+ parent_transcript_group_id = self._transcript_group_id_var.get()
843
+ except LookupError:
844
+ # No current transcript group context, this becomes a root group
845
+ parent_transcript_group_id = None
846
+
847
+ # Set context variable for this execution context
848
+ transcript_group_id_token: Token[str] = self._transcript_group_id_var.set(
849
+ transcript_group_id
714
850
  )
715
851
 
716
- return span
852
+ try:
853
+ # Send transcript group data and metadata to backend
854
+ try:
855
+ self.send_transcript_group_metadata(
856
+ transcript_group_id, name, description, parent_transcript_group_id, metadata
857
+ )
858
+ except Exception as e:
859
+ logger.warning(f"Failed sending transcript group data: {e}")
860
+
861
+ yield transcript_group_id
862
+ finally:
863
+ # Reset context variable to previous state
864
+ self._transcript_group_id_var.reset(transcript_group_id_token)
717
865
 
718
- def stop_span(self, span: Span) -> None:
866
+ @asynccontextmanager
867
+ async def async_transcript_group_context(
868
+ self,
869
+ name: Optional[str] = None,
870
+ transcript_group_id: Optional[str] = None,
871
+ description: Optional[str] = None,
872
+ metadata: Optional[Dict[str, Any]] = None,
873
+ parent_transcript_group_id: Optional[str] = None,
874
+ ) -> AsyncIterator[str]:
719
875
  """
720
- Manually stop a span.
876
+ Async context manager for setting up a transcript group context.
721
877
 
722
878
  Args:
723
- span: The span to stop
879
+ name: Optional transcript group name
880
+ transcript_group_id: Optional transcript group ID (auto-generated if not provided)
881
+ description: Optional transcript group description
882
+ metadata: Optional metadata to send to backend
883
+ parent_transcript_group_id: Optional parent transcript group ID
884
+
885
+ Yields:
886
+ The transcript group ID
724
887
  """
725
- if span and hasattr(span, "end"):
726
- span.end()
888
+ if not self._initialized:
889
+ raise RuntimeError(
890
+ "Tracer is not initialized. Call initialize_tracing() before using transcript group context."
891
+ )
892
+
893
+ if transcript_group_id is None:
894
+ transcript_group_id = str(uuid.uuid4())
895
+
896
+ # Determine parent transcript group ID before setting new context
897
+ if parent_transcript_group_id is None:
898
+ try:
899
+ parent_transcript_group_id = self._transcript_group_id_var.get()
900
+ except LookupError:
901
+ # No current transcript group context, this becomes a root group
902
+ parent_transcript_group_id = None
903
+
904
+ # Set context variable for this execution context
905
+ transcript_group_id_token: Token[str] = self._transcript_group_id_var.set(
906
+ transcript_group_id
907
+ )
908
+
909
+ try:
910
+ # Send transcript group data and metadata to backend
911
+ try:
912
+ self.send_transcript_group_metadata(
913
+ transcript_group_id, name, description, parent_transcript_group_id, metadata
914
+ )
915
+ except Exception as e:
916
+ logger.warning(f"Failed sending transcript group data: {e}")
917
+
918
+ yield transcript_group_id
919
+ finally:
920
+ # Reset context variable to previous state
921
+ self._transcript_group_id_var.reset(transcript_group_id_token)
922
+
923
+ def _send_trace_done(self) -> None:
924
+ collection_id = self.collection_id
925
+ payload: Dict[str, Any] = {
926
+ "collection_id": collection_id,
927
+ "status": "completed",
928
+ "timestamp": datetime.now(timezone.utc).isoformat(),
929
+ }
930
+ self._post_json("/v1/trace-done", payload)
727
931
 
728
932
 
729
- # Global instance for easy access
730
933
  _global_tracer: Optional[DocentTracer] = None
731
934
 
732
935
 
733
936
  def initialize_tracing(
734
- collection_name: str = "default-service",
937
+ collection_name: str = DEFAULT_COLLECTION_NAME,
735
938
  collection_id: Optional[str] = None,
736
939
  endpoint: Union[str, List[str]] = DEFAULT_ENDPOINT,
737
940
  headers: Optional[Dict[str, str]] = None,
@@ -739,7 +942,6 @@ def initialize_tracing(
739
942
  enable_console_export: bool = False,
740
943
  enable_otlp_export: bool = True,
741
944
  disable_batch: bool = False,
742
- span_postprocess_callback: Optional[Callable[[ReadableSpan], None]] = None,
743
945
  ) -> DocentTracer:
744
946
  """
745
947
  Initialize the global Docent tracer.
@@ -756,7 +958,6 @@ def initialize_tracing(
756
958
  enable_console_export: Whether to export spans to console
757
959
  enable_otlp_export: Whether to export spans to OTLP endpoint
758
960
  disable_batch: Whether to disable batch processing (use SimpleSpanProcessor)
759
- span_postprocess_callback: Optional callback for post-processing spans
760
961
 
761
962
  Returns:
762
963
  The initialized Docent tracer
@@ -782,12 +983,8 @@ def initialize_tracing(
782
983
  enable_console_export=enable_console_export,
783
984
  enable_otlp_export=enable_otlp_export,
784
985
  disable_batch=disable_batch,
785
- span_postprocess_callback=span_postprocess_callback,
786
986
  )
787
987
  _global_tracer.initialize()
788
- else:
789
- # If already initialized, ensure it's properly set up
790
- _global_tracer.initialize()
791
988
 
792
989
  return _global_tracer
793
990
 
@@ -795,8 +992,7 @@ def initialize_tracing(
795
992
  def get_tracer() -> DocentTracer:
796
993
  """Get the global Docent tracer."""
797
994
  if _global_tracer is None:
798
- # Auto-initialize with defaults if not already done
799
- return initialize_tracing()
995
+ raise RuntimeError("Docent tracer not initialized")
800
996
  return _global_tracer
801
997
 
802
998
 
@@ -827,20 +1023,9 @@ def set_disabled(disabled: bool) -> None:
827
1023
  _global_tracer.set_disabled(disabled)
828
1024
 
829
1025
 
830
- def get_api_key() -> Optional[str]:
831
- """
832
- Get the API key from environment variable.
833
-
834
- Returns:
835
- The API key from DOCENT_API_KEY environment variable, or None if not set
836
- """
837
- return os.environ.get("DOCENT_API_KEY")
838
-
839
-
840
1026
  def agent_run_score(name: str, score: float, attributes: Optional[Dict[str, Any]] = None) -> None:
841
1027
  """
842
- Record a score event on the current span.
843
- Automatically works in both sync and async contexts.
1028
+ Send a score to the backend for the current agent run.
844
1029
 
845
1030
  Args:
846
1031
  name: Name of the score metric
@@ -848,22 +1033,16 @@ def agent_run_score(name: str, score: float, attributes: Optional[Dict[str, Any]
848
1033
  attributes: Optional additional attributes for the score event
849
1034
  """
850
1035
  try:
851
- # Get current span from our isolated context instead of global context
852
- current_span: Optional[Span] = get_tracer().get_current_docent_span()
853
- if current_span and hasattr(current_span, "add_event"):
854
- event_attributes: dict[str, Any] = {
855
- "score.name": name,
856
- "score.value": score,
857
- "event.type": "score",
858
- }
859
- if attributes:
860
- event_attributes.update(attributes)
861
-
862
- current_span.add_event(name="agent_run_score", attributes=event_attributes)
863
- else:
864
- logger.warning("No current span available for recording score")
1036
+ tracer: DocentTracer = get_tracer()
1037
+ agent_run_id = tracer.get_current_agent_run_id()
1038
+
1039
+ if not agent_run_id:
1040
+ logger.warning("No active agent run context. Score will not be sent.")
1041
+ return
1042
+
1043
+ tracer.send_agent_run_score(agent_run_id, name, score, attributes)
865
1044
  except Exception as e:
866
- logger.error(f"Failed to record score event: {e}")
1045
+ logger.error(f"Failed to send score: {e}")
867
1046
 
868
1047
 
869
1048
  def _flatten_dict(d: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
@@ -878,31 +1057,9 @@ def _flatten_dict(d: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
878
1057
  return flattened
879
1058
 
880
1059
 
881
- def _add_metadata_event_to_span(span: Span, metadata: Dict[str, Any]) -> None:
882
- """
883
- Add metadata as an event to a span.
884
-
885
- Args:
886
- span: The span to add the event to
887
- metadata: Dictionary of metadata (can be nested)
888
- """
889
- if span and hasattr(span, "add_event"):
890
- event_attributes: dict[str, Any] = {
891
- "event.type": "metadata",
892
- }
893
-
894
- # Flatten nested metadata and add as event attributes
895
- flattened_metadata = _flatten_dict(metadata)
896
- for key, value in flattened_metadata.items():
897
- event_attributes[f"metadata.{key}"] = value
898
- span.add_event(name="agent_run_metadata", attributes=event_attributes)
899
-
900
-
901
1060
  def agent_run_metadata(metadata: Dict[str, Any]) -> None:
902
1061
  """
903
- Record metadata as an event on the current span.
904
- Automatically works in both sync and async contexts.
905
- Supports nested dictionaries by flattening them with dot notation.
1062
+ Send metadata directly to the backend for the current agent run.
906
1063
 
907
1064
  Args:
908
1065
  metadata: Dictionary of metadata to attach to the current span (can be nested)
@@ -912,28 +1069,49 @@ def agent_run_metadata(metadata: Dict[str, Any]) -> None:
912
1069
  agent_run_metadata({"user": {"id": "123", "name": "John"}, "config": {"model": "gpt-4"}})
913
1070
  """
914
1071
  try:
915
- current_span: Optional[Span] = get_tracer().get_current_docent_span()
916
- if current_span:
917
- _add_metadata_event_to_span(current_span, metadata)
918
- else:
919
- logger.warning("No current span available for recording metadata")
1072
+ tracer = get_tracer()
1073
+ agent_run_id = tracer.get_current_agent_run_id()
1074
+ if not agent_run_id:
1075
+ logger.warning("No active agent run context. Metadata will not be sent.")
1076
+ return
1077
+
1078
+ tracer.send_agent_run_metadata(agent_run_id, metadata)
920
1079
  except Exception as e:
921
- logger.error(f"Failed to record metadata event: {e}")
1080
+ logger.error(f"Failed to send metadata: {e}")
922
1081
 
923
1082
 
924
- # Unified functions that automatically detect context
925
- @asynccontextmanager
926
- async def span(name: str, attributes: Optional[Dict[str, Any]] = None) -> AsyncIterator[Span]:
1083
+ def transcript_metadata(
1084
+ name: Optional[str] = None,
1085
+ description: Optional[str] = None,
1086
+ transcript_group_id: Optional[str] = None,
1087
+ metadata: Optional[Dict[str, Any]] = None,
1088
+ ) -> None:
927
1089
  """
928
- Automatically choose sync or async span based on context.
929
- Can be used with both 'with' and 'async with'.
1090
+ Send transcript metadata directly to the backend for the current transcript.
1091
+
1092
+ Args:
1093
+ name: Optional transcript name
1094
+ description: Optional transcript description
1095
+ parent_transcript_id: Optional parent transcript ID
1096
+ metadata: Optional metadata to send
1097
+
1098
+ Example:
1099
+ transcript_metadata(name="data_processing", description="Process user data")
1100
+ transcript_metadata(metadata={"user": "John", "model": "gpt-4"})
1101
+ transcript_metadata(name="validation", parent_transcript_id="parent-123")
930
1102
  """
931
- if _is_async_context() or _is_running_in_event_loop():
932
- async with get_tracer().async_span(name, attributes) as span:
933
- yield span
934
- else:
935
- with get_tracer().span(name, attributes) as span:
936
- yield span
1103
+ try:
1104
+ tracer = get_tracer()
1105
+ transcript_id = tracer.get_current_transcript_id()
1106
+ if not transcript_id:
1107
+ logger.warning("No active transcript context. Metadata will not be sent.")
1108
+ return
1109
+
1110
+ tracer.send_transcript_metadata(
1111
+ transcript_id, name, description, transcript_group_id, metadata
1112
+ )
1113
+ except Exception as e:
1114
+ logger.error(f"Failed to send transcript metadata: {e}")
937
1115
 
938
1116
 
939
1117
  class AgentRunContext:
@@ -1084,3 +1262,359 @@ def agent_run_context(
1084
1262
  pass
1085
1263
  """
1086
1264
  return AgentRunContext(agent_run_id, transcript_id, metadata=metadata, **attributes)
1265
+
1266
+
1267
+ class TranscriptContext:
1268
+ """Context manager for creating and managing transcripts."""
1269
+
1270
+ def __init__(
1271
+ self,
1272
+ name: Optional[str] = None,
1273
+ transcript_id: Optional[str] = None,
1274
+ description: Optional[str] = None,
1275
+ metadata: Optional[Dict[str, Any]] = None,
1276
+ transcript_group_id: Optional[str] = None,
1277
+ ):
1278
+ self.name = name
1279
+ self.transcript_id = transcript_id
1280
+ self.description = description
1281
+ self.metadata = metadata
1282
+ self.transcript_group_id = transcript_group_id
1283
+ self._sync_context: Optional[Any] = None
1284
+ self._async_context: Optional[Any] = None
1285
+
1286
+ def __enter__(self) -> str:
1287
+ """Sync context manager entry."""
1288
+ self._sync_context = get_tracer().transcript_context(
1289
+ name=self.name,
1290
+ transcript_id=self.transcript_id,
1291
+ description=self.description,
1292
+ metadata=self.metadata,
1293
+ transcript_group_id=self.transcript_group_id,
1294
+ )
1295
+ return self._sync_context.__enter__()
1296
+
1297
+ def __exit__(self, exc_type: type[BaseException], exc_val: Any, exc_tb: Any) -> None:
1298
+ """Sync context manager exit."""
1299
+ if self._sync_context:
1300
+ self._sync_context.__exit__(exc_type, exc_val, exc_tb)
1301
+
1302
+ async def __aenter__(self) -> str:
1303
+ """Async context manager entry."""
1304
+ self._async_context = get_tracer().async_transcript_context(
1305
+ name=self.name,
1306
+ transcript_id=self.transcript_id,
1307
+ description=self.description,
1308
+ metadata=self.metadata,
1309
+ transcript_group_id=self.transcript_group_id,
1310
+ )
1311
+ return await self._async_context.__aenter__()
1312
+
1313
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1314
+ """Async context manager exit."""
1315
+ if self._async_context:
1316
+ await self._async_context.__aexit__(exc_type, exc_val, exc_tb)
1317
+
1318
+
1319
+ def transcript(
1320
+ func: Optional[Callable[..., Any]] = None,
1321
+ *,
1322
+ name: Optional[str] = None,
1323
+ transcript_id: Optional[str] = None,
1324
+ description: Optional[str] = None,
1325
+ metadata: Optional[Dict[str, Any]] = None,
1326
+ transcript_group_id: Optional[str] = None,
1327
+ ):
1328
+ """
1329
+ Decorator to wrap a function in a transcript context.
1330
+ Injects transcript_id as a function attribute.
1331
+
1332
+ Example:
1333
+ @transcript
1334
+ def my_func(x, y):
1335
+ print(my_func.docent.transcript_id)
1336
+
1337
+ @transcript(name="data_processing", description="Process user data")
1338
+ def my_func_with_name(x, y):
1339
+ print(my_func_with_name.docent.transcript_id)
1340
+
1341
+ @transcript(metadata={"user": "John", "model": "gpt-4"})
1342
+ async def my_async_func(z):
1343
+ print(my_async_func.docent.transcript_id)
1344
+ """
1345
+ import functools
1346
+ import inspect
1347
+
1348
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
1349
+ if inspect.iscoroutinefunction(f):
1350
+
1351
+ @functools.wraps(f)
1352
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
1353
+ async with TranscriptContext(
1354
+ name=name,
1355
+ transcript_id=transcript_id,
1356
+ description=description,
1357
+ metadata=metadata,
1358
+ transcript_group_id=transcript_group_id,
1359
+ ) as transcript_id_result:
1360
+ # Store docent data as function attributes
1361
+ setattr(
1362
+ async_wrapper,
1363
+ "docent",
1364
+ type(
1365
+ "DocentData",
1366
+ (),
1367
+ {
1368
+ "transcript_id": transcript_id_result,
1369
+ },
1370
+ )(),
1371
+ )
1372
+ return await f(*args, **kwargs)
1373
+
1374
+ return async_wrapper
1375
+ else:
1376
+
1377
+ @functools.wraps(f)
1378
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
1379
+ with TranscriptContext(
1380
+ name=name,
1381
+ transcript_id=transcript_id,
1382
+ description=description,
1383
+ metadata=metadata,
1384
+ transcript_group_id=transcript_group_id,
1385
+ ) as transcript_id_result:
1386
+ # Store docent data as function attributes
1387
+ setattr(
1388
+ sync_wrapper,
1389
+ "docent",
1390
+ type(
1391
+ "DocentData",
1392
+ (),
1393
+ {
1394
+ "transcript_id": transcript_id_result,
1395
+ },
1396
+ )(),
1397
+ )
1398
+ return f(*args, **kwargs)
1399
+
1400
+ return sync_wrapper
1401
+
1402
+ if func is None:
1403
+ return decorator
1404
+ else:
1405
+ return decorator(func)
1406
+
1407
+
1408
+ def transcript_context(
1409
+ name: Optional[str] = None,
1410
+ transcript_id: Optional[str] = None,
1411
+ description: Optional[str] = None,
1412
+ metadata: Optional[Dict[str, Any]] = None,
1413
+ transcript_group_id: Optional[str] = None,
1414
+ ) -> TranscriptContext:
1415
+ """
1416
+ Create a transcript context for tracing.
1417
+
1418
+ Args:
1419
+ name: Optional transcript name
1420
+ transcript_id: Optional transcript ID (auto-generated if not provided)
1421
+ description: Optional transcript description
1422
+ metadata: Optional metadata to attach to the transcript
1423
+ parent_transcript_id: Optional parent transcript ID
1424
+
1425
+ Returns:
1426
+ A context manager that can be used with both 'with' and 'async with'
1427
+
1428
+ Example:
1429
+ # Sync usage
1430
+ with transcript_context(name="data_processing") as transcript_id:
1431
+ pass
1432
+
1433
+ # Async usage
1434
+ async with transcript_context(description="Process user data") as transcript_id:
1435
+ pass
1436
+
1437
+ # With metadata
1438
+ with transcript_context(metadata={"user": "John", "model": "gpt-4"}) as transcript_id:
1439
+ pass
1440
+ """
1441
+ return TranscriptContext(name, transcript_id, description, metadata, transcript_group_id)
1442
+
1443
+
1444
+ class TranscriptGroupContext:
1445
+ """Context manager for creating and managing transcript groups."""
1446
+
1447
+ def __init__(
1448
+ self,
1449
+ name: Optional[str] = None,
1450
+ transcript_group_id: Optional[str] = None,
1451
+ description: Optional[str] = None,
1452
+ metadata: Optional[Dict[str, Any]] = None,
1453
+ parent_transcript_group_id: Optional[str] = None,
1454
+ ):
1455
+ self.name = name
1456
+ self.transcript_group_id = transcript_group_id
1457
+ self.description = description
1458
+ self.metadata = metadata
1459
+ self.parent_transcript_group_id = parent_transcript_group_id
1460
+ self._sync_context: Optional[Any] = None
1461
+ self._async_context: Optional[Any] = None
1462
+
1463
+ def __enter__(self) -> str:
1464
+ """Sync context manager entry."""
1465
+ self._sync_context = get_tracer().transcript_group_context(
1466
+ name=self.name,
1467
+ transcript_group_id=self.transcript_group_id,
1468
+ description=self.description,
1469
+ metadata=self.metadata,
1470
+ parent_transcript_group_id=self.parent_transcript_group_id,
1471
+ )
1472
+ return self._sync_context.__enter__()
1473
+
1474
+ def __exit__(self, exc_type: type[BaseException], exc_val: Any, exc_tb: Any) -> None:
1475
+ """Sync context manager exit."""
1476
+ if self._sync_context:
1477
+ self._sync_context.__exit__(exc_type, exc_val, exc_tb)
1478
+
1479
+ async def __aenter__(self) -> str:
1480
+ """Async context manager entry."""
1481
+ self._async_context = get_tracer().async_transcript_group_context(
1482
+ name=self.name,
1483
+ transcript_group_id=self.transcript_group_id,
1484
+ description=self.description,
1485
+ metadata=self.metadata,
1486
+ parent_transcript_group_id=self.parent_transcript_group_id,
1487
+ )
1488
+ return await self._async_context.__aenter__()
1489
+
1490
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1491
+ """Async context manager exit."""
1492
+ if self._async_context:
1493
+ await self._async_context.__aexit__(exc_type, exc_val, exc_tb)
1494
+
1495
+
1496
+ def transcript_group(
1497
+ func: Optional[Callable[..., Any]] = None,
1498
+ *,
1499
+ name: Optional[str] = None,
1500
+ transcript_group_id: Optional[str] = None,
1501
+ description: Optional[str] = None,
1502
+ metadata: Optional[Dict[str, Any]] = None,
1503
+ parent_transcript_group_id: Optional[str] = None,
1504
+ ):
1505
+ """
1506
+ Decorator to wrap a function in a transcript group context.
1507
+ Injects transcript_group_id as a function attribute.
1508
+
1509
+ Example:
1510
+ @transcript_group
1511
+ def my_func(x, y):
1512
+ print(my_func.docent.transcript_group_id)
1513
+
1514
+ @transcript_group(name="data_processing", description="Process user data")
1515
+ def my_func_with_name(x, y):
1516
+ print(my_func_with_name.docent.transcript_group_id)
1517
+
1518
+ @transcript_group(metadata={"user": "John", "model": "gpt-4"})
1519
+ async def my_async_func(z):
1520
+ print(my_async_func.docent.transcript_group_id)
1521
+ """
1522
+ import functools
1523
+ import inspect
1524
+
1525
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
1526
+ if inspect.iscoroutinefunction(f):
1527
+
1528
+ @functools.wraps(f)
1529
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
1530
+ async with TranscriptGroupContext(
1531
+ name=name,
1532
+ transcript_group_id=transcript_group_id,
1533
+ description=description,
1534
+ metadata=metadata,
1535
+ parent_transcript_group_id=parent_transcript_group_id,
1536
+ ) as transcript_group_id_result:
1537
+ # Store docent data as function attributes
1538
+ setattr(
1539
+ async_wrapper,
1540
+ "docent",
1541
+ type(
1542
+ "DocentData",
1543
+ (),
1544
+ {
1545
+ "transcript_group_id": transcript_group_id_result,
1546
+ },
1547
+ )(),
1548
+ )
1549
+ return await f(*args, **kwargs)
1550
+
1551
+ return async_wrapper
1552
+ else:
1553
+
1554
+ @functools.wraps(f)
1555
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
1556
+ with TranscriptGroupContext(
1557
+ name=name,
1558
+ transcript_group_id=transcript_group_id,
1559
+ description=description,
1560
+ metadata=metadata,
1561
+ parent_transcript_group_id=parent_transcript_group_id,
1562
+ ) as transcript_group_id_result:
1563
+ # Store docent data as function attributes
1564
+ setattr(
1565
+ sync_wrapper,
1566
+ "docent",
1567
+ type(
1568
+ "DocentData",
1569
+ (),
1570
+ {
1571
+ "transcript_group_id": transcript_group_id_result,
1572
+ },
1573
+ )(),
1574
+ )
1575
+ return f(*args, **kwargs)
1576
+
1577
+ return sync_wrapper
1578
+
1579
+ if func is None:
1580
+ return decorator
1581
+ else:
1582
+ return decorator(func)
1583
+
1584
+
1585
+ def transcript_group_context(
1586
+ name: Optional[str] = None,
1587
+ transcript_group_id: Optional[str] = None,
1588
+ description: Optional[str] = None,
1589
+ metadata: Optional[Dict[str, Any]] = None,
1590
+ parent_transcript_group_id: Optional[str] = None,
1591
+ ) -> TranscriptGroupContext:
1592
+ """
1593
+ Create a transcript group context for tracing.
1594
+
1595
+ Args:
1596
+ name: Optional transcript group name
1597
+ transcript_group_id: Optional transcript group ID (auto-generated if not provided)
1598
+ description: Optional transcript group description
1599
+ metadata: Optional metadata to attach to the transcript group
1600
+ parent_transcript_group_id: Optional parent transcript group ID
1601
+
1602
+ Returns:
1603
+ A context manager that can be used with both 'with' and 'async with'
1604
+
1605
+ Example:
1606
+ # Sync usage
1607
+ with transcript_group_context(name="data_processing") as transcript_group_id:
1608
+ pass
1609
+
1610
+ # Async usage
1611
+ async with transcript_group_context(description="Process user data") as transcript_group_id:
1612
+ pass
1613
+
1614
+ # With metadata
1615
+ with transcript_group_context(metadata={"user": "John", "model": "gpt-4"}) as transcript_group_id:
1616
+ pass
1617
+ """
1618
+ return TranscriptGroupContext(
1619
+ name, transcript_group_id, description, metadata, parent_transcript_group_id
1620
+ )