lmnr 0.4.53.dev0__py3-none-any.whl → 0.7.26__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.
Files changed (133) hide show
  1. lmnr/__init__.py +32 -11
  2. lmnr/cli/__init__.py +270 -0
  3. lmnr/cli/datasets.py +371 -0
  4. lmnr/cli/evals.py +111 -0
  5. lmnr/cli/rules.py +42 -0
  6. lmnr/opentelemetry_lib/__init__.py +70 -0
  7. lmnr/opentelemetry_lib/decorators/__init__.py +337 -0
  8. lmnr/opentelemetry_lib/litellm/__init__.py +685 -0
  9. lmnr/opentelemetry_lib/litellm/utils.py +100 -0
  10. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/__init__.py +849 -0
  11. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/config.py +13 -0
  12. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_emitter.py +211 -0
  13. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_models.py +41 -0
  14. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/span_utils.py +401 -0
  15. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/streaming.py +425 -0
  16. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/utils.py +332 -0
  17. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/version.py +1 -0
  18. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/__init__.py +451 -0
  19. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/proxy.py +144 -0
  20. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_agent/__init__.py +100 -0
  21. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/__init__.py +476 -0
  22. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/utils.py +12 -0
  23. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/__init__.py +599 -0
  24. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/config.py +9 -0
  25. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +26 -0
  26. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/utils.py +330 -0
  27. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/__init__.py +488 -0
  28. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/config.py +8 -0
  29. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_emitter.py +143 -0
  30. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_models.py +41 -0
  31. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/span_utils.py +229 -0
  32. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/utils.py +92 -0
  33. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/version.py +1 -0
  34. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/__init__.py +381 -0
  35. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/utils.py +36 -0
  36. lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/__init__.py +121 -0
  37. lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/utils.py +60 -0
  38. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
  39. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +472 -0
  40. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1185 -0
  41. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +305 -0
  42. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
  43. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +312 -0
  44. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  45. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  46. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
  47. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +197 -0
  48. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
  49. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +368 -0
  50. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +325 -0
  51. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +135 -0
  52. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +786 -0
  53. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
  54. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openhands_ai/__init__.py +388 -0
  55. lmnr/opentelemetry_lib/opentelemetry/instrumentation/opentelemetry/__init__.py +69 -0
  56. lmnr/opentelemetry_lib/opentelemetry/instrumentation/skyvern/__init__.py +191 -0
  57. lmnr/opentelemetry_lib/opentelemetry/instrumentation/threading/__init__.py +197 -0
  58. lmnr/opentelemetry_lib/tracing/__init__.py +263 -0
  59. lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +516 -0
  60. lmnr/{openllmetry_sdk → opentelemetry_lib}/tracing/attributes.py +21 -8
  61. lmnr/opentelemetry_lib/tracing/context.py +200 -0
  62. lmnr/opentelemetry_lib/tracing/exporter.py +153 -0
  63. lmnr/opentelemetry_lib/tracing/instruments.py +140 -0
  64. lmnr/opentelemetry_lib/tracing/processor.py +193 -0
  65. lmnr/opentelemetry_lib/tracing/span.py +398 -0
  66. lmnr/opentelemetry_lib/tracing/tracer.py +57 -0
  67. lmnr/opentelemetry_lib/tracing/utils.py +62 -0
  68. lmnr/opentelemetry_lib/utils/package_check.py +18 -0
  69. lmnr/opentelemetry_lib/utils/wrappers.py +11 -0
  70. lmnr/sdk/browser/__init__.py +0 -0
  71. lmnr/sdk/browser/background_send_events.py +158 -0
  72. lmnr/sdk/browser/browser_use_cdp_otel.py +100 -0
  73. lmnr/sdk/browser/browser_use_otel.py +142 -0
  74. lmnr/sdk/browser/bubus_otel.py +71 -0
  75. lmnr/sdk/browser/cdp_utils.py +518 -0
  76. lmnr/sdk/browser/inject_script.js +514 -0
  77. lmnr/sdk/browser/patchright_otel.py +151 -0
  78. lmnr/sdk/browser/playwright_otel.py +322 -0
  79. lmnr/sdk/browser/pw_utils.py +363 -0
  80. lmnr/sdk/browser/recorder/record.umd.min.cjs +84 -0
  81. lmnr/sdk/browser/utils.py +70 -0
  82. lmnr/sdk/client/asynchronous/async_client.py +180 -0
  83. lmnr/sdk/client/asynchronous/resources/__init__.py +6 -0
  84. lmnr/sdk/client/asynchronous/resources/base.py +32 -0
  85. lmnr/sdk/client/asynchronous/resources/browser_events.py +41 -0
  86. lmnr/sdk/client/asynchronous/resources/datasets.py +131 -0
  87. lmnr/sdk/client/asynchronous/resources/evals.py +266 -0
  88. lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
  89. lmnr/sdk/client/asynchronous/resources/tags.py +83 -0
  90. lmnr/sdk/client/synchronous/resources/__init__.py +6 -0
  91. lmnr/sdk/client/synchronous/resources/base.py +32 -0
  92. lmnr/sdk/client/synchronous/resources/browser_events.py +40 -0
  93. lmnr/sdk/client/synchronous/resources/datasets.py +131 -0
  94. lmnr/sdk/client/synchronous/resources/evals.py +263 -0
  95. lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
  96. lmnr/sdk/client/synchronous/resources/tags.py +83 -0
  97. lmnr/sdk/client/synchronous/sync_client.py +191 -0
  98. lmnr/sdk/datasets/__init__.py +94 -0
  99. lmnr/sdk/datasets/file_utils.py +91 -0
  100. lmnr/sdk/decorators.py +163 -26
  101. lmnr/sdk/eval_control.py +3 -2
  102. lmnr/sdk/evaluations.py +403 -191
  103. lmnr/sdk/laminar.py +1080 -549
  104. lmnr/sdk/log.py +7 -2
  105. lmnr/sdk/types.py +246 -134
  106. lmnr/sdk/utils.py +151 -7
  107. lmnr/version.py +46 -0
  108. {lmnr-0.4.53.dev0.dist-info → lmnr-0.7.26.dist-info}/METADATA +152 -106
  109. lmnr-0.7.26.dist-info/RECORD +116 -0
  110. lmnr-0.7.26.dist-info/WHEEL +4 -0
  111. lmnr-0.7.26.dist-info/entry_points.txt +3 -0
  112. lmnr/cli.py +0 -101
  113. lmnr/openllmetry_sdk/.python-version +0 -1
  114. lmnr/openllmetry_sdk/__init__.py +0 -72
  115. lmnr/openllmetry_sdk/config/__init__.py +0 -9
  116. lmnr/openllmetry_sdk/decorators/base.py +0 -185
  117. lmnr/openllmetry_sdk/instruments.py +0 -38
  118. lmnr/openllmetry_sdk/tracing/__init__.py +0 -1
  119. lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -24
  120. lmnr/openllmetry_sdk/tracing/context_manager.py +0 -13
  121. lmnr/openllmetry_sdk/tracing/tracing.py +0 -884
  122. lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -61
  123. lmnr/openllmetry_sdk/utils/package_check.py +0 -7
  124. lmnr/openllmetry_sdk/version.py +0 -1
  125. lmnr/sdk/datasets.py +0 -55
  126. lmnr-0.4.53.dev0.dist-info/LICENSE +0 -75
  127. lmnr-0.4.53.dev0.dist-info/RECORD +0 -33
  128. lmnr-0.4.53.dev0.dist-info/WHEEL +0 -4
  129. lmnr-0.4.53.dev0.dist-info/entry_points.txt +0 -3
  130. /lmnr/{openllmetry_sdk → opentelemetry_lib}/.flake8 +0 -0
  131. /lmnr/{openllmetry_sdk → opentelemetry_lib}/utils/__init__.py +0 -0
  132. /lmnr/{openllmetry_sdk → opentelemetry_lib}/utils/json_encoder.py +0 -0
  133. /lmnr/{openllmetry_sdk/decorators/__init__.py → py.typed} +0 -0
lmnr/sdk/laminar.py CHANGED
@@ -1,154 +1,360 @@
1
1
  from contextlib import contextmanager
2
2
  from contextvars import Context
3
- from lmnr.openllmetry_sdk import Traceloop
4
- from lmnr.openllmetry_sdk.instruments import Instruments
5
- from lmnr.openllmetry_sdk.tracing import get_tracer
6
- from lmnr.openllmetry_sdk.tracing.attributes import (
3
+ import warnings
4
+ from lmnr.opentelemetry_lib import TracerManager
5
+ from lmnr.opentelemetry_lib.tracing import TracerWrapper, get_current_context
6
+ from lmnr.opentelemetry_lib.tracing.context import (
7
+ CONTEXT_METADATA_KEY,
8
+ CONTEXT_SESSION_ID_KEY,
9
+ CONTEXT_TRACE_TYPE_KEY,
10
+ CONTEXT_USER_ID_KEY,
11
+ attach_context,
12
+ detach_context,
13
+ get_event_attributes_from_context,
14
+ push_span_context,
15
+ set_association_prop_context,
16
+ )
17
+ from opentelemetry.context import get_value
18
+ from lmnr.opentelemetry_lib.tracing.attributes import (
7
19
  ASSOCIATION_PROPERTIES,
20
+ PARENT_SPAN_IDS_PATH,
21
+ PARENT_SPAN_PATH,
22
+ USER_ID,
8
23
  Attributes,
9
24
  SPAN_TYPE,
10
- OVERRIDE_PARENT_SPAN,
11
25
  )
12
- from lmnr.openllmetry_sdk.decorators.base import json_dumps
13
- from opentelemetry import context as context_api, trace
14
- from opentelemetry.context import attach, detach
15
- from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
26
+ from lmnr.opentelemetry_lib.tracing.instruments import Instruments
27
+ from lmnr.opentelemetry_lib.tracing.processor import LaminarSpanProcessor
28
+ from lmnr.opentelemetry_lib.tracing.span import LaminarSpan
29
+ from lmnr.opentelemetry_lib.tracing.tracer import get_tracer_with_context
30
+ from lmnr.opentelemetry_lib.tracing.utils import set_association_props_in_context
31
+ from lmnr.sdk.utils import get_otel_env_var
32
+
33
+ from opentelemetry import trace
34
+ from opentelemetry import context as context_api
35
+ from opentelemetry.trace import INVALID_TRACE_ID, Span, Status, StatusCode, use_span
36
+ from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
16
37
  from opentelemetry.util.types import AttributeValue
17
38
 
18
- from pydantic.alias_generators import to_snake
19
- from typing import Any, Awaitable, Literal, Optional, Set, Union
39
+ from typing import Any, Iterator, Literal
40
+ from typing_extensions import TypedDict
20
41
 
21
- import aiohttp
22
- import asyncio
23
- import copy
24
42
  import datetime
25
- import dotenv
26
- import json
27
43
  import logging
28
44
  import os
29
- import random
30
- import requests
31
45
  import re
32
- import urllib.parse
33
46
  import uuid
34
47
 
35
- from lmnr.openllmetry_sdk.tracing.attributes import (
36
- SESSION_ID,
37
- SPAN_INPUT,
38
- SPAN_OUTPUT,
39
- TRACE_TYPE,
40
- )
41
- from lmnr.openllmetry_sdk.tracing.tracing import (
42
- remove_association_properties,
43
- set_association_properties,
44
- update_association_properties,
45
- )
48
+ from lmnr.opentelemetry_lib.tracing.attributes import SESSION_ID, TRACE_TYPE
49
+
50
+ from lmnr.sdk.utils import from_env, is_otel_attribute_value_type, json_dumps
46
51
 
47
52
  from .log import VerboseColorfulFormatter
48
53
 
49
54
  from .types import (
50
- CreateEvaluationResponse,
51
- EvaluationResultDatapoint,
52
- GetDatapointsResponse,
53
- PipelineRunError,
54
- PipelineRunResponse,
55
- NodeInput,
56
- PipelineRunRequest,
57
- SemanticSearchRequest,
58
- SemanticSearchResponse,
55
+ LaminarSpanContext,
56
+ SessionRecordingOptions,
59
57
  TraceType,
60
- TracingLevel,
61
58
  )
62
59
 
63
60
 
61
+ class ParsedParentSpanContext(TypedDict):
62
+ """Parsed information from a parent span context."""
63
+
64
+ otel_span_context: trace.SpanContext | None
65
+ path: list[str]
66
+ span_ids_path: list[str]
67
+ user_id: str | None
68
+ session_id: str | None
69
+ trace_type: TraceType | None
70
+ metadata: dict[str, Any] | None
71
+
72
+
73
+ def _parse_parent_span_context(
74
+ parent_span_context: LaminarSpanContext | dict | str | None,
75
+ logger: logging.Logger,
76
+ ) -> ParsedParentSpanContext:
77
+ """Parse parent_span_context and extract all relevant information.
78
+
79
+ Args:
80
+ parent_span_context: Parent span context to parse
81
+ logger: Logger for warnings
82
+
83
+ Returns:
84
+ ParsedParentSpanContext with otel_span_context, path, span_ids_path,
85
+ user_id, session_id, trace_type, and metadata
86
+ """
87
+ if parent_span_context is None:
88
+ return ParsedParentSpanContext(
89
+ otel_span_context=None,
90
+ path=[],
91
+ span_ids_path=[],
92
+ user_id=None,
93
+ session_id=None,
94
+ trace_type=None,
95
+ metadata=None,
96
+ )
97
+
98
+ path = []
99
+ span_ids_path = []
100
+ user_id = None
101
+ session_id = None
102
+ trace_type = None
103
+ metadata = None
104
+ laminar_span_context = None
105
+
106
+ # Try to deserialize if dict or str
107
+ if isinstance(parent_span_context, (dict, str)):
108
+ try:
109
+ laminar_span_context = LaminarSpanContext.deserialize(parent_span_context)
110
+ except Exception:
111
+ logger.warning(
112
+ f"Could not deserialize parent_span_context: {parent_span_context}. "
113
+ "Will use it as is."
114
+ )
115
+ laminar_span_context = parent_span_context
116
+ else:
117
+ laminar_span_context = parent_span_context
118
+
119
+ # Extract path and association props from LaminarSpanContext
120
+ if isinstance(laminar_span_context, LaminarSpanContext):
121
+ path = laminar_span_context.span_path
122
+ span_ids_path = laminar_span_context.span_ids_path
123
+ user_id = laminar_span_context.user_id
124
+ session_id = laminar_span_context.session_id
125
+ if laminar_span_context.trace_type is not None:
126
+ try:
127
+ trace_type = (
128
+ TraceType(laminar_span_context.trace_type)
129
+ if isinstance(laminar_span_context.trace_type, str)
130
+ else laminar_span_context.trace_type
131
+ )
132
+ except (ValueError, TypeError):
133
+ pass
134
+ metadata = laminar_span_context.metadata
135
+
136
+ # Convert to OTEL span context
137
+ try:
138
+ otel_span_context = LaminarSpanContext.try_to_otel_span_context(
139
+ laminar_span_context, logger
140
+ )
141
+ except ValueError as exc:
142
+ logger.warning(f"Invalid span context provided: {exc}")
143
+ return ParsedParentSpanContext(
144
+ otel_span_context=None,
145
+ path=path,
146
+ span_ids_path=span_ids_path,
147
+ user_id=user_id,
148
+ session_id=session_id,
149
+ trace_type=trace_type,
150
+ metadata=metadata,
151
+ )
152
+
153
+ return ParsedParentSpanContext(
154
+ otel_span_context=otel_span_context,
155
+ path=path,
156
+ span_ids_path=span_ids_path,
157
+ user_id=user_id,
158
+ session_id=session_id,
159
+ trace_type=trace_type,
160
+ metadata=metadata,
161
+ )
162
+
163
+
64
164
  class Laminar:
65
- __base_http_url: str
66
- __base_grpc_url: str
67
- __project_api_key: Optional[str] = None
68
- __env: dict[str, str] = {}
165
+ __project_api_key: str | None = None
69
166
  __initialized: bool = False
167
+ __base_http_url: str | None = None
168
+ __global_metadata: dict[str, AttributeValue] = {}
70
169
 
71
170
  @classmethod
72
171
  def initialize(
73
172
  cls,
74
- project_api_key: Optional[str] = None,
75
- env: dict[str, str] = {},
76
- base_url: Optional[str] = None,
77
- http_port: Optional[int] = None,
78
- grpc_port: Optional[int] = None,
79
- instruments: Optional[Set[Instruments]] = None,
173
+ project_api_key: str | None = None,
174
+ base_url: str | None = None,
175
+ base_http_url: str | None = None,
176
+ http_port: int | None = None,
177
+ grpc_port: int | None = None,
178
+ instruments: (
179
+ list[Instruments] | set[Instruments] | tuple[Instruments] | None
180
+ ) = None,
181
+ disabled_instruments: (
182
+ list[Instruments] | set[Instruments] | tuple[Instruments] | None
183
+ ) = None,
80
184
  disable_batch: bool = False,
185
+ max_export_batch_size: int | None = None,
186
+ export_timeout_seconds: int | None = None,
187
+ set_global_tracer_provider: bool = True,
188
+ otel_logger_level: int = logging.ERROR,
189
+ session_recording_options: SessionRecordingOptions | None = None,
190
+ force_http: bool = False,
191
+ metadata: dict[str, AttributeValue] | None = None,
81
192
  ):
82
193
  """Initialize Laminar context across the application.
83
194
  This method must be called before using any other Laminar methods or
84
195
  decorators.
85
196
 
86
197
  Args:
87
- project_api_key (Optional[str], optional): Laminar project api key.\
88
- You can generate one by going to the projects\
89
- settings page on the Laminar dashboard.\
90
- If not specified, it will try to read from the\
91
- LMNR_PROJECT_API_KEY environment variable\
92
- in os.environ or in .env file.
93
- Defaults to None.
94
- env (dict[str, str], optional): Default environment passed to\
95
- `run` requests, unless overriden at request time.\
96
- Usually, model provider keys are stored here.
97
- Defaults to {}.
98
- base_url (Optional[str], optional): Laminar API url. Do NOT include\
99
- the port number, use `http_port` and `grpc_port`.\
100
- If not specified, defaults to https://api.lmnr.ai.
101
- http_port (Optional[int], optional): Laminar API http port.\
102
- If not specified, defaults to 443.
103
- grpc_port (Optional[int], optional): Laminar API grpc port.\
104
- If not specified, defaults to 8443.
105
- instruments (Optional[Set[Instruments]], optional): Instruments to\
106
- enable. Defaults to all instruments. You can pass\
107
- an empty set to disable all instruments. Read more:\
108
- https://docs.lmnr.ai/tracing/automatic-instrumentation
198
+ project_api_key (str | None, optional): Laminar project api key.\
199
+ You can generate one by going to the projects settings page on\
200
+ the Laminar dashboard. If not specified, we will try to read\
201
+ from the LMNR_PROJECT_API_KEY environment variable in os.environ\
202
+ or in .env file. Defaults to None.
203
+ base_url (str | None, optional): Laminar API url. Do NOT include\
204
+ the port number, use `http_port` and `grpc_port`. If not\
205
+ specified, defaults to https://api.lmnr.ai.
206
+ base_http_url (str | None, optional): Laminar API http url. Only\
207
+ set this if your Laminar backend HTTP is proxied through a\
208
+ different host. If not specified, defaults to\
209
+ https://api.lmnr.ai.
210
+ http_port (int | None, optional): Laminar API http port. If not\
211
+ specified, defaults to 443.
212
+ grpc_port (int | None, optional): Laminar API grpc port. If not\
213
+ specified, defaults to 8443.
214
+ instruments (set[Instruments] | list[Instruments] | tuple[Instruments] | None, optional):
215
+ Instruments to enable. Defaults to all instruments. You can pass\
216
+ an empty set to disable all instruments. Read more:\
217
+ https://docs.lmnr.ai/tracing/automatic-instrumentation
218
+ disabled_instruments (set[Instruments] | list[Instruments] | tuple[Instruments] | None, optional):
219
+ Instruments to disable. Defaults to None.
109
220
  disable_batch (bool, optional): If set to True, spans will be sent\
110
- immediately to the backend. Useful for debugging, but\
111
- may cause performance overhead in production.
112
- Defaults to False.
113
-
221
+ immediately to the backend. Useful for debugging, but may cause\
222
+ performance overhead in production. Defaults to False.
223
+ max_export_batch_size (int | None, optional): Maximum number of spans\
224
+ to export in a single batch. If not specified, defaults to 64\
225
+ (lower than the OpenTelemetry default of 512). If you see\
226
+ `DEADLINE_EXCEEDED` errors, try reducing this value.
227
+ export_timeout_seconds (int | None, optional): Timeout for the OTLP\
228
+ exporter. Defaults to 30 seconds (unlike the OpenTelemetry\
229
+ default of 10 seconds). Defaults to None.
230
+ set_global_tracer_provider (bool, optional): If set to True, the\
231
+ Laminar tracer provider will be set as the global tracer provider.\
232
+ OpenTelemetry allows only one tracer provider per app, so set this\
233
+ to False, if you are using another tracing library. Setting this to\
234
+ False may break some external instrumentations, e.g. LiteLLM.\
235
+ Defaults to True.
236
+ otel_logger_level (int, optional): OpenTelemetry logger level. Defaults\
237
+ to logging.ERROR.
238
+ session_recording_options (SessionRecordingOptions | None, optional): Options\
239
+ for browser session recording. Currently supports 'mask_input'\
240
+ (bool) to control whether input fields are masked during recording.\
241
+ Defaults to None (uses default masking behavior).
242
+ force_http (bool, optional): If set to True, the HTTP OTEL exporter will be\
243
+ used instead of the gRPC OTEL exporter. Defaults to False.
114
244
  Raises:
115
245
  ValueError: If project API key is not set
116
246
  """
117
- cls.__project_api_key = project_api_key or os.environ.get(
118
- "LMNR_PROJECT_API_KEY"
119
- )
120
- if not cls.__project_api_key:
121
- dotenv_path = dotenv.find_dotenv(usecwd=True)
122
- cls.__project_api_key = dotenv.get_key(
123
- dotenv_path=dotenv_path, key_to_get="LMNR_PROJECT_API_KEY"
247
+ if cls.is_initialized():
248
+ cls.__logger.info(
249
+ "Laminar is already initialized. Skipping initialization."
124
250
  )
125
- if not cls.__project_api_key:
251
+ return
252
+
253
+ cls.__project_api_key = project_api_key or from_env("LMNR_PROJECT_API_KEY")
254
+
255
+ if (
256
+ not cls.__project_api_key
257
+ and not get_otel_env_var("ENDPOINT")
258
+ and not get_otel_env_var("HEADERS")
259
+ ):
126
260
  raise ValueError(
127
261
  "Please initialize the Laminar object with"
128
262
  " your project API key or set the LMNR_PROJECT_API_KEY"
129
263
  " environment variable in your environment or .env file"
130
264
  )
131
- url = base_url or "https://api.lmnr.ai"
132
- if re.search(r":\d{1,5}$", url):
133
- raise ValueError(
134
- "Please provide the `base_url` without the port number. "
135
- "Use the `http_port` and `grpc_port` arguments instead."
136
- )
137
- cls.__base_http_url = f"{url}:{http_port or 443}"
138
- cls.__base_grpc_url = f"{url}:{grpc_port or 8443}"
139
265
 
140
- cls.__env = env
141
- cls.__initialized = True
142
266
  cls._initialize_logger()
143
267
 
144
- Traceloop.init(
145
- exporter=OTLPSpanExporter(
146
- endpoint=cls.__base_grpc_url,
147
- headers={"authorization": f"Bearer {cls.__project_api_key}"},
268
+ url = base_url or from_env("LMNR_BASE_URL")
269
+ if url:
270
+ url = url.rstrip("/")
271
+ if not url.startswith("http:") and not url.startswith("https:"):
272
+ url = f"https://{url}"
273
+ if match := re.search(r":(\d{1,5})$", url):
274
+ url = url[: -len(match.group(0))]
275
+ cls.__logger.info(f"Ignoring port in base URL: {match.group(1)}")
276
+ http_url = base_http_url or url or "https://api.lmnr.ai"
277
+ if not http_url.startswith("http:") and not http_url.startswith("https:"):
278
+ http_url = f"https://{http_url}"
279
+ if match := re.search(r":(\d{1,5})$", http_url):
280
+ http_url = http_url[: -len(match.group(0))]
281
+ if http_port is None:
282
+ cls.__logger.info(f"Using HTTP port from base URL: {match.group(1)}")
283
+ http_port = int(match.group(1))
284
+ else:
285
+ cls.__logger.info(f"Using HTTP port passed as an argument: {http_port}")
286
+
287
+ cls.__initialized = True
288
+ cls.__base_http_url = f"{http_url}:{http_port or 443}"
289
+ cls.__global_metadata = metadata or {}
290
+
291
+ if not os.getenv("OTEL_ATTRIBUTE_COUNT_LIMIT"):
292
+ # each message is at least 2 attributes: role and content,
293
+ # but the default attribute limit is 128, so raise it
294
+ os.environ["OTEL_ATTRIBUTE_COUNT_LIMIT"] = "10000"
295
+
296
+ TracerManager.init(
297
+ base_url=url,
298
+ http_port=http_port or 443,
299
+ port=grpc_port or 8443,
300
+ project_api_key=cls.__project_api_key,
301
+ instruments=set(instruments) if instruments is not None else None,
302
+ block_instruments=(
303
+ set(disabled_instruments) if disabled_instruments is not None else None
148
304
  ),
149
- instruments=instruments,
150
305
  disable_batch=disable_batch,
306
+ max_export_batch_size=max_export_batch_size,
307
+ timeout_seconds=export_timeout_seconds,
308
+ set_global_tracer_provider=set_global_tracer_provider,
309
+ otel_logger_level=otel_logger_level,
310
+ session_recording_options=session_recording_options,
311
+ force_http=force_http,
312
+ )
313
+ with get_tracer_with_context() as (tracer, isolated_context):
314
+ new_ctx = context_api.set_value(
315
+ CONTEXT_METADATA_KEY, cls.__global_metadata, isolated_context
316
+ )
317
+ attach_context(new_ctx)
318
+
319
+ cls._initialize_context_from_env()
320
+
321
+ @classmethod
322
+ def _initialize_context_from_env(cls) -> None:
323
+ """Attach upstream Laminar context from the environment, if provided."""
324
+ env_context = os.getenv("LMNR_SPAN_CONTEXT")
325
+ if not env_context:
326
+ return
327
+
328
+ try:
329
+ laminar_context = LaminarSpanContext.deserialize(env_context)
330
+ except Exception as exc: # pylint: disable=broad-exception-caught
331
+ cls.__logger.warning(
332
+ "LMNR_SPAN_CONTEXT is set but could not be deserialized: %s", exc
333
+ )
334
+ return
335
+
336
+ try:
337
+ otel_span_context = LaminarSpanContext.try_to_otel_span_context(
338
+ laminar_context, cls.__logger
339
+ )
340
+ except ValueError as exc:
341
+ cls.__logger.warning(
342
+ "LMNR_SPAN_CONTEXT is set but invalid span context provided: %s", exc
343
+ )
344
+ return
345
+
346
+ base_context = trace.set_span_in_context(
347
+ trace.NonRecordingSpan(otel_span_context), get_current_context()
151
348
  )
349
+ processor = TracerWrapper.instance._span_processor
350
+ if isinstance(processor, LaminarSpanProcessor):
351
+ processor.set_parent_path_info(
352
+ otel_span_context.span_id,
353
+ laminar_context.span_path,
354
+ laminar_context.span_ids_path,
355
+ )
356
+ push_span_context(base_context)
357
+ cls.__logger.debug("Initialized Laminar parent context from LMNR_SPAN_CONTEXT.")
152
358
 
153
359
  @classmethod
154
360
  def is_initialized(cls):
@@ -167,150 +373,52 @@ class Laminar:
167
373
  console_log_handler.setFormatter(VerboseColorfulFormatter())
168
374
  cls.__logger.addHandler(console_log_handler)
169
375
 
170
- @classmethod
171
- def run(
172
- cls,
173
- pipeline: str,
174
- inputs: dict[str, NodeInput],
175
- env: dict[str, str] = {},
176
- metadata: dict[str, str] = {},
177
- parent_span_id: Optional[uuid.UUID] = None,
178
- trace_id: Optional[uuid.UUID] = None,
179
- ) -> Union[PipelineRunResponse, Awaitable[PipelineRunResponse]]:
180
- """Runs the pipeline with the given inputs. If called from an async
181
- function, must be awaited.
182
-
183
- Args:
184
- pipeline (str): name of the Laminar pipeline.\
185
- The pipeline must have a target version set.
186
- inputs (dict[str, NodeInput]):
187
- inputs to the endpoint's target pipeline.\
188
- Keys in the dictionary must match input node names
189
- env (dict[str, str], optional):
190
- Environment variables for the pipeline execution.
191
- Defaults to {}.
192
- metadata (dict[str, str], optional):
193
- any custom metadata to be stored with execution trace.
194
- Defaults to {}.
195
- parent_span_id (Optional[uuid.UUID], optional): parent span id for\
196
- the resulting span.
197
- Defaults to None.
198
- trace_id (Optional[uuid.UUID], optional): trace id for the\
199
- resulting trace.
200
- Defaults to None.
201
-
202
- Returns:
203
- PipelineRunResponse: response object containing the outputs
204
-
205
- Raises:
206
- ValueError: if project API key is not set
207
- PipelineRunError: if the endpoint run fails
208
- """
209
- if cls.__project_api_key is None:
210
- raise ValueError(
211
- "Please initialize the Laminar object with your project "
212
- "API key or set the LMNR_PROJECT_API_KEY environment variable"
213
- )
214
- try:
215
- current_span = trace.get_current_span()
216
- if current_span != trace.INVALID_SPAN:
217
- parent_span_id = parent_span_id or uuid.UUID(
218
- int=current_span.get_span_context().span_id
219
- )
220
- trace_id = trace_id or uuid.UUID(
221
- int=current_span.get_span_context().trace_id
222
- )
223
- request = PipelineRunRequest(
224
- inputs=inputs,
225
- pipeline=pipeline,
226
- env=env or cls.__env,
227
- metadata=metadata,
228
- parent_span_id=parent_span_id,
229
- trace_id=trace_id,
230
- )
231
- loop = asyncio.get_event_loop()
232
- if loop.is_running():
233
- return cls.__run(request)
234
- else:
235
- return asyncio.run(cls.__run(request))
236
- except Exception as e:
237
- raise ValueError(f"Invalid request: {e}")
238
-
239
- @classmethod
240
- def semantic_search(
241
- cls,
242
- query: str,
243
- dataset_id: uuid.UUID,
244
- limit: Optional[int] = None,
245
- threshold: Optional[float] = None,
246
- ) -> SemanticSearchResponse:
247
- """Perform a semantic search on a dataset. If called from an async
248
- function, must be awaited.
249
-
250
- Args:
251
- query (str): query string to search by
252
- dataset_id (uuid.UUID): id of the dataset to search in
253
- limit (Optional[int], optional): maximum number of results to\
254
- return. Defaults to None.
255
- threshold (Optional[float], optional): minimum score for a result\
256
- to be returned. Defaults to None.
257
-
258
- Returns:
259
- SemanticSearchResponse: response object containing the search results sorted by score in descending order
260
- """
261
- request = SemanticSearchRequest(
262
- query=query,
263
- dataset_id=dataset_id,
264
- limit=limit,
265
- threshold=threshold,
266
- )
267
- loop = asyncio.get_event_loop()
268
- if loop.is_running():
269
- return cls.__semantic_search(request)
270
- else:
271
- return asyncio.run(cls.__semantic_search(request))
272
-
273
376
  @classmethod
274
377
  def event(
275
378
  cls,
276
379
  name: str,
277
- value: Optional[AttributeValue] = None,
278
- timestamp: Optional[Union[datetime.datetime, int]] = None,
380
+ attributes: dict[str, AttributeValue] | None = None,
381
+ timestamp: datetime.datetime | int | None = None,
382
+ *,
383
+ user_id: str | None = None,
384
+ session_id: str | None = None,
279
385
  ):
280
- """Associate an event with the current span. If using manual\
281
- instrumentation, use raw OpenTelemetry `span.add_event()` instead.\
282
- `value` will be saved as a `lmnr.event.value` attribute.
386
+ """Associate an event with the current span. This is a wrapper around
387
+ `span.add_event()` that adds the event to the current span.
283
388
 
284
389
  Args:
285
390
  name (str): event name
286
- value (Optional[AttributeValue]): event value. Must be a primitive\
287
- type. Boolean true is assumed in the backend if\
288
- `value` is None.
289
- Defaults to None.
290
- timestamp (Optional[Union[datetime.datetime, int]], optional):\
291
- If int, must be epoch nanoseconds. If not\
292
- specified, relies on the underlying OpenTelemetry\
293
- implementation. Defaults to None.
391
+ attributes (dict[str, AttributeValue] | None, optional): event attributes.
392
+ Defaults to None.
393
+ timestamp (datetime.datetime | int | None, optional): If int, must\
394
+ be epoch nanoseconds. If not specified, relies on the underlying\
395
+ OpenTelemetry implementation. Defaults to None.
294
396
  """
397
+ if not cls.is_initialized():
398
+ return
399
+
295
400
  if timestamp and isinstance(timestamp, datetime.datetime):
296
401
  timestamp = int(timestamp.timestamp() * 1e9)
297
402
 
298
- event = {
299
- "lmnr.event.type": "default",
300
- }
301
- if value is not None:
302
- event["lmnr.event.value"] = value
403
+ extra_attributes = get_event_attributes_from_context()
303
404
 
304
- current_span = trace.get_current_span()
405
+ # override the user_id and session_id from the context with the ones
406
+ # passed as arguments
407
+ if user_id is not None:
408
+ extra_attributes["lmnr.event.user_id"] = user_id
409
+ if session_id is not None:
410
+ extra_attributes["lmnr.event.session_id"] = session_id
411
+
412
+ current_span = trace.get_current_span(context=get_current_context())
305
413
  if current_span == trace.INVALID_SPAN:
306
- cls.__logger.warning(
307
- "`Laminar().event()` called outside of span context. "
308
- f"Event '{name}' will not be recorded in the trace. "
309
- "Make sure to annotate the function with a decorator"
310
- )
414
+ span = cls.start_span(name)
415
+ span.add_event(name, {**(attributes or {}), **extra_attributes}, timestamp)
416
+ span.end()
311
417
  return
312
418
 
313
- current_span.add_event(name, event, timestamp)
419
+ current_span.add_event(
420
+ name, {**(attributes or {}), **extra_attributes}, timestamp
421
+ )
314
422
 
315
423
  @classmethod
316
424
  @contextmanager
@@ -318,11 +426,15 @@ class Laminar:
318
426
  cls,
319
427
  name: str,
320
428
  input: Any = None,
321
- span_type: Union[Literal["DEFAULT"], Literal["LLM"]] = "DEFAULT",
322
- context: Optional[Context] = None,
323
- trace_id: Optional[uuid.UUID] = None,
324
- labels: Optional[dict[str, str]] = None,
325
- ):
429
+ span_type: Literal["DEFAULT", "LLM", "TOOL"] = "DEFAULT",
430
+ context: Context | None = None,
431
+ labels: list[str] | None = None,
432
+ parent_span_context: LaminarSpanContext | None = None,
433
+ tags: list[str] | None = None,
434
+ user_id: str | None = None,
435
+ session_id: str | None = None,
436
+ metadata: dict[str, AttributeValue] | None = None,
437
+ ) -> Iterator[LaminarSpan]:
326
438
  """Start a new span as the current span. Useful for manual
327
439
  instrumentation. If `span_type` is set to `"LLM"`, you should report
328
440
  usage and response attributes manually. See `Laminar.set_span_attributes`
@@ -339,144 +451,198 @@ class Laminar:
339
451
  name (str): name of the span
340
452
  input (Any, optional): input to the span. Will be sent as an\
341
453
  attribute, so must be json serializable. Defaults to None.
342
- span_type (Union[Literal["DEFAULT"], Literal["LLM"]], optional):\
454
+ span_type (Literal["DEFAULT", "LLM", "TOOL"], optional):\
343
455
  type of the span. If you use `"LLM"`, you should report usage\
344
456
  and response attributes manually. Defaults to "DEFAULT".
345
- context (Optional[Context], optional): raw OpenTelemetry context\
457
+ context (Context | None, optional): raw OpenTelemetry context\
346
458
  to attach the span to. Defaults to None.
347
- trace_id (Optional[uuid.UUID], optional): [EXPERIMENTAL] override\
348
- the trace id for the span. If not provided, use the current\
349
- trace id. Defaults to None.
350
- labels (Optional[dict[str, str]], optional): labels to set for the\
351
- span. Defaults to None.
459
+ parent_span_context (LaminarSpanContext | None, optional): parent\
460
+ span context to use for the span. Useful for continuing traces\
461
+ across services. If parent_span_context is a\
462
+ raw OpenTelemetry span context, or if it is a dictionary or string\
463
+ obtained from `Laminar.get_laminar_span_context_dict()` or\
464
+ `Laminar.get_laminar_span_context_str()` respectively, it will be\
465
+ converted to a `LaminarSpanContext` if possible. See also\
466
+ `Laminar.serialize_span_context` for more information.
467
+ Defaults to None.
468
+ labels (list[str] | None, optional): [DEPRECATED] Use tags\
469
+ instead. Labels to set for the span. Defaults to None.
470
+ tags (list[str] | None, optional): tags to set for the span.
471
+ Defaults to None.
472
+ user_id (str | None, optional): user id to set for the trace.
473
+ Defaults to None.
474
+ session_id (str | None, optional): session id to set for the trace.
475
+ Defaults to None.
476
+ metadata (dict[str, AttributeValue] | None, optional): metadata to\
477
+ set for the trace. Defaults to None.
352
478
  """
353
479
 
354
480
  if not cls.is_initialized():
355
- yield
481
+ yield trace.NonRecordingSpan(
482
+ trace.SpanContext(
483
+ trace_id=RandomIdGenerator().generate_trace_id(),
484
+ span_id=RandomIdGenerator().generate_span_id(),
485
+ is_remote=False,
486
+ )
487
+ )
356
488
  return
357
489
 
358
- with get_tracer() as tracer:
359
- ctx = context or context_api.get_current()
360
- if trace_id is not None:
361
- if isinstance(trace_id, uuid.UUID):
362
- span_context = trace.SpanContext(
363
- trace_id=int(trace_id),
364
- span_id=random.getrandbits(64),
365
- is_remote=False,
366
- trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
367
- )
368
- ctx = trace.set_span_in_context(
369
- trace.NonRecordingSpan(span_context), ctx
370
- )
371
- else:
372
- cls.__logger.warning(
373
- "trace_id provided to `Laminar.start_as_current_span`"
374
- " is not a valid UUID"
375
- )
376
- ctx_token = attach(ctx)
490
+ with get_tracer_with_context() as (tracer, isolated_context):
491
+ ctx = context or isolated_context
492
+
493
+ # Parse parent_span_context and extract all info
494
+ parsed = _parse_parent_span_context(parent_span_context, cls.__logger)
495
+
496
+ # Set parent span in context if present
497
+ if parsed["otel_span_context"] is not None:
498
+ ctx = trace.set_span_in_context(
499
+ trace.NonRecordingSpan(parsed["otel_span_context"]), ctx
500
+ )
501
+
502
+ # Determine trace_type with proper priority
503
+ trace_type = None
504
+ if span_type in ["EVALUATION", "EXECUTOR", "EVALUATOR"]:
505
+ trace_type = TraceType.EVALUATION
506
+ elif parsed["trace_type"] is not None:
507
+ trace_type = parsed["trace_type"]
508
+
509
+ # Merge metadata: context (inherited) + global + parent + explicit (explicit wins)
510
+ # Get metadata from context if it exists
511
+ ctx_metadata = get_value(CONTEXT_METADATA_KEY, ctx) or {}
512
+ # Merge with priority: global < context < parent < explicit
513
+ merged_metadata = {
514
+ **(cls.__global_metadata or {}),
515
+ **(ctx_metadata or {}),
516
+ **(parsed["metadata"] or {}),
517
+ **(metadata or {}),
518
+ }
519
+
520
+ # Get association props from context (fallback values)
521
+ ctx_user_id = get_value(CONTEXT_USER_ID_KEY, ctx)
522
+ ctx_session_id = get_value(CONTEXT_SESSION_ID_KEY, ctx)
523
+
524
+ # Merge user_id and session_id with priority: context < parent < explicit
525
+ final_user_id = (
526
+ user_id
527
+ if user_id is not None
528
+ else (
529
+ parsed["user_id"] if parsed["user_id"] is not None else ctx_user_id
530
+ )
531
+ )
532
+ final_session_id = (
533
+ session_id
534
+ if session_id is not None
535
+ else (
536
+ parsed["session_id"]
537
+ if parsed["session_id"] is not None
538
+ else ctx_session_id
539
+ )
540
+ )
541
+
542
+ ctx = set_association_prop_context(
543
+ trace_type=trace_type,
544
+ user_id=final_user_id,
545
+ session_id=final_session_id,
546
+ metadata=merged_metadata if merged_metadata else None,
547
+ context=ctx,
548
+ # we need a token separately, so we manually attach the context
549
+ attach=False,
550
+ )
551
+ ctx_token = context_api.attach(ctx)
552
+ isolated_context_token = attach_context(ctx)
377
553
  label_props = {}
378
554
  try:
379
555
  if labels:
380
- label_props = dict(
381
- (f"{ASSOCIATION_PROPERTIES}.label.{k}", json_dumps(v))
382
- for k, v in labels.items() # noqa: F821
556
+ warnings.warn(
557
+ "`Laminar.start_as_current_span` `labels` is deprecated. Use `tags` instead.",
558
+ DeprecationWarning,
383
559
  )
560
+ label_props = {f"{ASSOCIATION_PROPERTIES}.labels": labels}
384
561
  except Exception:
385
562
  cls.__logger.warning(
386
563
  f"`start_as_current_span` Could not set labels: {labels}. "
387
564
  "They will be propagated to the next span."
388
565
  )
389
- with tracer.start_as_current_span(
390
- name,
391
- context=ctx,
392
- attributes={
393
- SPAN_TYPE: span_type,
394
- **(label_props),
395
- },
396
- ) as span:
397
- if trace_id is not None and isinstance(trace_id, uuid.UUID):
398
- span.set_attribute(OVERRIDE_PARENT_SPAN, True)
399
- if input is not None:
400
- span.set_attribute(
401
- SPAN_INPUT,
402
- json_dumps(input),
566
+ tag_props = {}
567
+ if tags:
568
+ if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags):
569
+ tag_props = {f"{ASSOCIATION_PROPERTIES}.tags": tags}
570
+ else:
571
+ cls.__logger.warning(
572
+ f"`start_as_current_span` Could not set tags: {tags}. Tags must be a list of strings. "
573
+ "Tags will be ignored."
403
574
  )
404
- yield span
405
-
406
- # TODO: Figure out if this is necessary
407
- try:
408
- detach(ctx_token)
409
- except Exception:
410
- pass
411
-
412
- @classmethod
413
- @contextmanager
414
- def with_labels(cls, labels: dict[str, str], context: Optional[Context] = None):
415
- """Set labels for spans within this `with` context. This is useful for
416
- adding labels to the spans created in the auto-instrumentations.
417
-
418
- Requirements:
419
- - Labels must be created in your project in advance.
420
- - Keys must be strings from your label names.
421
- - Values must be strings matching the label's allowed values.
422
575
 
423
- Usage example:
424
- ```python
425
- with Laminar.with_labels({"sentiment": "positive"}):
426
- openai_client.chat.completions.create()
427
- ```
428
- """
429
- with get_tracer():
430
- label_props = labels.copy()
431
- label_props = dict(
432
- (f"label.{k}", json_dumps(v)) for k, v in label_props.items()
433
- )
434
- update_association_properties(
435
- label_props, set_on_current_span=False, context=context
436
- )
437
- yield
438
576
  try:
439
- remove_association_properties(label_props)
440
- except Exception:
441
- cls.__logger.warning(
442
- f"`with_labels` Could not remove labels: {labels}. They will be "
443
- "propagated to the next span."
444
- )
445
- pass
577
+ with tracer.start_as_current_span(
578
+ name,
579
+ context=ctx,
580
+ attributes={
581
+ SPAN_TYPE: span_type,
582
+ PARENT_SPAN_PATH: parsed["path"],
583
+ PARENT_SPAN_IDS_PATH: parsed["span_ids_path"],
584
+ **(label_props),
585
+ **(tag_props),
586
+ # Association properties are attached to context above
587
+ # and the relevant attributes are populated in the processor
588
+ },
589
+ ) as span:
590
+ if not isinstance(span, LaminarSpan):
591
+ span = LaminarSpan(span)
592
+ span.set_input(input)
593
+ yield span
594
+ finally:
595
+ try:
596
+ detach_context(isolated_context_token)
597
+ context_api.detach(ctx_token)
598
+ except Exception:
599
+ pass
446
600
 
447
601
  @classmethod
448
602
  def start_span(
449
603
  cls,
450
604
  name: str,
451
605
  input: Any = None,
452
- span_type: Union[Literal["DEFAULT"], Literal["LLM"]] = "DEFAULT",
453
- context: Optional[Context] = None,
454
- trace_id: Optional[uuid.UUID] = None,
455
- labels: Optional[dict[str, str]] = None,
456
- ):
606
+ span_type: Literal["DEFAULT", "LLM", "TOOL"] = "DEFAULT",
607
+ context: Context | None = None,
608
+ parent_span_context: LaminarSpanContext | None = None,
609
+ labels: dict[str, str] | None = None,
610
+ tags: list[str] | None = None,
611
+ user_id: str | None = None,
612
+ session_id: str | None = None,
613
+ metadata: dict[str, AttributeValue] | None = None,
614
+ ) -> LaminarSpan | Span:
457
615
  """Start a new span. Useful for manual instrumentation.
458
616
  If `span_type` is set to `"LLM"`, you should report usage and response
459
617
  attributes manually. See `Laminar.set_span_attributes` for more
460
618
  information.
461
619
 
620
+ Note that spans started with this method must be ended manually.
621
+ In addition, they must be ended in LIFO order, e.g.
622
+ span1 = Laminar.start_span("span1")
623
+ span2 = Laminar.start_span("span2")
624
+ span2.end()
625
+ span1.end()
626
+ Otherwise, the behavior is undefined.
627
+
462
628
  Usage example:
463
629
  ```python
464
- from src.lmnr import Laminar, use_span
630
+ from src.lmnr import Laminar
465
631
  def foo(span):
466
- with use_span(span):
632
+ with Laminar.use_span(span):
467
633
  with Laminar.start_as_current_span("foo_inner"):
468
634
  some_function()
469
-
635
+
470
636
  def bar():
471
- with use_span(span):
637
+ with Laminar.use_span(span):
472
638
  openai_client.chat.completions.create()
473
-
639
+
474
640
  span = Laminar.start_span("outer")
475
641
  foo(span)
476
642
  bar(span)
477
643
  # IMPORTANT: End the span manually
478
644
  span.end()
479
-
645
+
480
646
  # Results in:
481
647
  # | outer
482
648
  # | | foo
@@ -489,125 +655,388 @@ class Laminar:
489
655
  name (str): name of the span
490
656
  input (Any, optional): input to the span. Will be sent as an\
491
657
  attribute, so must be json serializable. Defaults to None.
492
- span_type (Union[Literal["DEFAULT"], Literal["LLM"]], optional):\
658
+ span_type (Literal["DEFAULT", "LLM", "TOOL"], optional):\
493
659
  type of the span. If you use `"LLM"`, you should report usage\
494
660
  and response attributes manually. Defaults to "DEFAULT".
495
- context (Optional[Context], optional): raw OpenTelemetry context\
661
+ context (Context | None, optional): raw OpenTelemetry context\
496
662
  to attach the span to. Defaults to None.
497
- trace_id (Optional[uuid.UUID], optional): [EXPERIMENTAL] override\
498
- the trace id for the span. If not provided, use the current\
499
- trace id. Defaults to None.
500
- labels (Optional[dict[str, str]], optional): labels to set for the\
501
- span. Defaults to None.
663
+ parent_span_context (LaminarSpanContext | None, optional): parent\
664
+ span context to use for the span. Useful for continuing traces\
665
+ across services. If parent_span_context is a\
666
+ raw OpenTelemetry span context, or if it is a dictionary or string\
667
+ obtained from `Laminar.get_laminar_span_context_dict()` or\
668
+ `Laminar.get_laminar_span_context_str()` respectively, it will be\
669
+ converted to a `LaminarSpanContext` if possible. See also\
670
+ `Laminar.get_span_context`, `Laminar.get_span_context_dict` and\
671
+ `Laminar.get_span_context_str` for more information.
672
+ Defaults to None.
673
+ tags (list[str] | None, optional): tags to set for the span.
674
+ Defaults to None.
675
+ labels (dict[str, str] | None, optional): [DEPRECATED] Use tags\
676
+ instead. Labels to set for the span. Defaults to None.
677
+ user_id (str | None, optional): user id to set for the trace.
678
+ Defaults to None.
679
+ session_id (str | None, optional): session id to set for the trace.
680
+ Defaults to None.
681
+ metadata (dict[str, AttributeValue] | None, optional): metadata to\
682
+ set for the trace. Defaults to None.
502
683
  """
503
- with get_tracer() as tracer:
504
- ctx = context or context_api.get_current()
505
- if trace_id is not None:
506
- if isinstance(trace_id, uuid.UUID):
507
- span_context = trace.SpanContext(
508
- trace_id=int(trace_id),
509
- span_id=random.getrandbits(64),
510
- is_remote=False,
511
- trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
512
- )
513
- ctx = trace.set_span_in_context(
514
- trace.NonRecordingSpan(span_context), ctx
515
- )
516
- else:
517
- cls.__logger.warning(
518
- "trace_id provided to `Laminar.start_span`"
519
- " is not a valid UUID"
520
- )
684
+ if not cls.is_initialized():
685
+ return trace.NonRecordingSpan(
686
+ trace.SpanContext(
687
+ trace_id=RandomIdGenerator().generate_trace_id(),
688
+ span_id=RandomIdGenerator().generate_span_id(),
689
+ is_remote=False,
690
+ )
691
+ )
692
+
693
+ with get_tracer_with_context() as (tracer, isolated_context):
694
+ ctx = context or isolated_context
695
+
696
+ # Parse parent_span_context and extract all info
697
+ parsed = _parse_parent_span_context(parent_span_context, cls.__logger)
698
+
699
+ # Set parent span in context if present
700
+ if parsed["otel_span_context"] is not None:
701
+ ctx = trace.set_span_in_context(
702
+ trace.NonRecordingSpan(parsed["otel_span_context"]), ctx
703
+ )
704
+
705
+ # Get association props from context (fallback values)
706
+ ctx_user_id = get_value(CONTEXT_USER_ID_KEY, ctx)
707
+ ctx_session_id = get_value(CONTEXT_SESSION_ID_KEY, ctx)
708
+ ctx_metadata = get_value(CONTEXT_METADATA_KEY, ctx)
709
+
521
710
  label_props = {}
522
711
  try:
523
712
  if labels:
524
- label_props = dict(
525
- (f"{ASSOCIATION_PROPERTIES}.label.{k}", json_dumps(v))
526
- for k, v in labels.items() # noqa: F821
713
+ warnings.warn(
714
+ "`Laminar.start_span` `labels` is deprecated. Use `tags` instead.",
715
+ DeprecationWarning,
527
716
  )
717
+ label_props = {
718
+ f"{ASSOCIATION_PROPERTIES}.labels": json_dumps(labels)
719
+ }
528
720
  except Exception:
529
721
  cls.__logger.warning(
530
722
  f"`start_span` Could not set labels: {labels}. They will be "
531
723
  "propagated to the next span."
532
724
  )
725
+ tag_props = {}
726
+ if tags:
727
+ if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags):
728
+ tag_props = {f"{ASSOCIATION_PROPERTIES}.tags": tags}
729
+ else:
730
+ cls.__logger.warning(
731
+ f"`start_span` Could not set tags: {tags}. Tags must be a list of strings. "
732
+ + "Tags will be ignored."
733
+ )
734
+
735
+ # Determine trace_type with proper priority: explicit > parent > context
736
+ trace_type = None
737
+ if span_type in ["EVALUATION", "EXECUTOR", "EVALUATOR"]:
738
+ trace_type = TraceType.EVALUATION
739
+ elif parsed["trace_type"] is not None:
740
+ trace_type = parsed["trace_type"]
741
+ else:
742
+ # Get trace_type from context if not set explicitly or from parent
743
+ ctx_trace_type = get_value(CONTEXT_TRACE_TYPE_KEY, ctx)
744
+ if ctx_trace_type:
745
+ try:
746
+ trace_type = TraceType(ctx_trace_type)
747
+ except (ValueError, TypeError):
748
+ pass
749
+
750
+ # Merge with priority: global < context < parent < explicit
751
+ merged_metadata = {
752
+ **(cls.__global_metadata or {}),
753
+ **(ctx_metadata or {}),
754
+ **(parsed["metadata"] or {}),
755
+ **(metadata or {}),
756
+ }
757
+
758
+ # Merge user_id and session_id with priority: context < parent < explicit
759
+ final_user_id = (
760
+ user_id
761
+ if user_id is not None
762
+ else (
763
+ parsed["user_id"] if parsed["user_id"] is not None else ctx_user_id
764
+ )
765
+ )
766
+ final_session_id = (
767
+ session_id
768
+ if session_id is not None
769
+ else (
770
+ parsed["session_id"]
771
+ if parsed["session_id"] is not None
772
+ else ctx_session_id
773
+ )
774
+ )
775
+
776
+ # Build association_props using merged values
777
+ association_props = cls._get_association_prop_attributes(
778
+ user_id=final_user_id,
779
+ session_id=final_session_id,
780
+ metadata=merged_metadata if merged_metadata else None,
781
+ trace_type=trace_type,
782
+ )
783
+
533
784
  span = tracer.start_span(
534
785
  name,
535
786
  context=ctx,
536
787
  attributes={
537
788
  SPAN_TYPE: span_type,
789
+ PARENT_SPAN_PATH: parsed["path"],
790
+ PARENT_SPAN_IDS_PATH: parsed["span_ids_path"],
538
791
  **(label_props),
792
+ **(tag_props),
793
+ **(association_props),
539
794
  },
540
795
  )
541
- if trace_id is not None and isinstance(trace_id, uuid.UUID):
542
- span.set_attribute(OVERRIDE_PARENT_SPAN, True)
543
- if input is not None:
544
- span.set_attribute(
545
- SPAN_INPUT,
546
- json_dumps(input),
547
- )
796
+
797
+ if not isinstance(span, LaminarSpan):
798
+ span = LaminarSpan(span)
799
+ span.set_input(input)
548
800
  return span
549
801
 
550
802
  @classmethod
551
- def set_span_output(cls, output: Any = None):
552
- """Set the output of the current span. Useful for manual
553
- instrumentation.
803
+ @contextmanager
804
+ def use_span(
805
+ cls,
806
+ span: Span,
807
+ end_on_exit: bool = False,
808
+ record_exception: bool = True,
809
+ set_status_on_exception: bool = True,
810
+ ) -> Iterator[LaminarSpan | Span]:
811
+ """Use a span as the current span. Useful for manual instrumentation.
812
+
813
+ Fully copies the implementation of `use_span` from opentelemetry.trace
814
+ and replaces the context API with Laminar's isolated context.
554
815
 
555
816
  Args:
556
- output (Any, optional): output of the span. Will be sent as an\
557
- attribute, so must be json serializable. Defaults to None.
817
+ span: The span that should be activated in the current context.
818
+ end_on_exit: Whether to end the span automatically when leaving the
819
+ context manager scope.
820
+ record_exception: Whether to record any exceptions raised within the
821
+ context as error event on the span.
822
+ set_status_on_exception: Only relevant if the returned span is used
823
+ in a with/context manager. Defines whether the span status will
824
+ be automatically set to ERROR when an uncaught exception is
825
+ raised in the span with block. The span status won't be set by
826
+ this mechanism if it was previously set manually.
558
827
  """
559
- span = trace.get_current_span()
560
- if output is not None and span != trace.INVALID_SPAN:
561
- span.set_attribute(SPAN_OUTPUT, json_dumps(output))
828
+ if not cls.is_initialized():
829
+ with use_span(
830
+ span, end_on_exit, record_exception, set_status_on_exception
831
+ ) as s:
832
+ yield s
833
+ return
834
+
835
+ wrapper = TracerWrapper()
836
+
837
+ try:
838
+ # Set association props in context before push_span_context
839
+ # so child spans inherit them
840
+ assoc_props_token = set_association_props_in_context(span)
841
+ if assoc_props_token and isinstance(span, LaminarSpan):
842
+ span._lmnr_assoc_props_token = assoc_props_token
843
+
844
+ context = wrapper.push_span_context(span)
845
+ # Some auto-instrumentations are not under our control, so they
846
+ # don't have access to our isolated context. We attach the context
847
+ # to the OTEL global context, so that spans know their parent
848
+ # span and trace_id.
849
+ isolated_context_token = attach_context(context)
850
+ context_token = context_api.attach(context)
851
+ if isinstance(span, LaminarSpan):
852
+ yield span
853
+ else:
854
+ yield LaminarSpan(span)
855
+
856
+ # Record only exceptions that inherit Exception class but not BaseException, because
857
+ # classes that directly inherit BaseException are not technically errors, e.g. GeneratorExit.
858
+ # See https://github.com/open-telemetry/opentelemetry-python/issues/4484
859
+ except Exception as exc: # pylint: disable=broad-exception-caught
860
+ if isinstance(span, Span) and span.is_recording():
861
+ # Record the exception as an event
862
+ if record_exception:
863
+ span.record_exception(
864
+ exc, attributes=get_event_attributes_from_context()
865
+ )
866
+
867
+ # Set status in case exception was raised
868
+ if set_status_on_exception:
869
+ span.set_status(
870
+ Status(
871
+ status_code=StatusCode.ERROR,
872
+ description=f"{type(exc).__name__}: {exc}",
873
+ )
874
+ )
875
+
876
+ # This causes parent spans to set their status to ERROR and to record
877
+ # an exception as an event if a child span raises an exception even if
878
+ # such child span was started with both record_exception and
879
+ # set_status_on_exception attributes set to False.
880
+ raise
881
+
882
+ finally:
883
+ try:
884
+ context_api.detach(context_token)
885
+ detach_context(isolated_context_token)
886
+ wrapper.pop_span_context()
887
+ finally:
888
+ if end_on_exit:
889
+ span.end()
562
890
 
563
891
  @classmethod
564
- @contextmanager
565
- def set_tracing_level(self, level: TracingLevel):
566
- """Set the tracing level for the current span and the context
567
- (i.e. any children spans created from the current span in the current
568
- thread).
569
-
570
- Tracing level can be one of:
571
- - `TracingLevel.ALL`: Enable tracing for the current span and all
572
- children spans.
573
- - `TracingLevel.META_ONLY`: Enable tracing for the current span and all
574
- children spans, but only record metadata, e.g. tokens, costs.
575
- - `TracingLevel.OFF`: Disable recording any spans.
892
+ def start_active_span(
893
+ cls,
894
+ name: str,
895
+ input: Any = None,
896
+ span_type: Literal["DEFAULT", "LLM", "TOOL"] = "DEFAULT",
897
+ context: Context | None = None,
898
+ parent_span_context: LaminarSpanContext | None = None,
899
+ tags: list[str] | None = None,
900
+ user_id: str | None = None,
901
+ session_id: str | None = None,
902
+ metadata: dict[str, AttributeValue] | None = None,
903
+ ) -> LaminarSpan | Span:
904
+ """Start a span and mark it as active within the current context.
905
+ All spans started after this one will be children of this span.
906
+ Useful for manual instrumentation. Must be ended manually.
907
+ If `span_type` is set to `"LLM"`, you should report usage and response
908
+ attributes manually. See `Laminar.set_span_attributes` for more
909
+ information. Returns the span object.
576
910
 
577
- Example:
911
+ Note that ending the started span in a different async context yields
912
+ unexpected results. When propagating spans across different async or
913
+ threading contexts, it is recommended to either:
914
+ - Make sure to start and end the span in the same async context or thread, or
915
+ - Use `Laminar.start_span` + `Laminar.use_span` where possible.
916
+
917
+ Note that spans started with this method must be ended manually.
918
+ In addition, they must be ended in LIFO order, e.g.
919
+ span1 = Laminar.start_active_span("span1")
920
+ span2 = Laminar.start_active_span("span2")
921
+ span2.end()
922
+ span1.end()
923
+ Otherwise, the behavior is undefined.
924
+
925
+ Usage example:
578
926
  ```python
579
- from lmnr import Laminar, TracingLevel
927
+ from src.lmnr import Laminar, observe
928
+
929
+ @observe()
930
+ def foo():
931
+ with Laminar.start_as_current_span("foo_inner"):
932
+ some_function()
580
933
 
581
- with Laminar.set_tracing_level(TracingLevel.META_ONLY):
934
+ @observe()
935
+ def bar():
582
936
  openai_client.chat.completions.create()
937
+
938
+ span = Laminar.start_active_span("outer")
939
+ foo()
940
+ bar()
941
+ # IMPORTANT: End the span manually
942
+ span.end()
943
+
944
+ # Results in:
945
+ # | outer
946
+ # | | foo
947
+ # | | | foo_inner
948
+ # | | bar
949
+ # | | | openai.chat
583
950
  ```
951
+
952
+ Args:
953
+ name (str): name of the span
954
+ input (Any, optional): input to the span. Will be sent as an\
955
+ attribute, so must be json serializable. Defaults to None.
956
+ span_type (Literal["DEFAULT", "LLM", "TOOL"], optional):\
957
+ type of the span. If you use `"LLM"`, you should report usage\
958
+ and response attributes manually. Defaults to "DEFAULT".
959
+ context (Context | None, optional): raw OpenTelemetry context\
960
+ to attach the span to. Defaults to None.
961
+ parent_span_context (LaminarSpanContext | None, optional): parent\
962
+ span context to use for the span. Useful for continuing traces\
963
+ across services. If parent_span_context is a\
964
+ raw OpenTelemetry span context, or if it is a dictionary or string\
965
+ obtained from `Laminar.get_laminar_span_context_dict()` or\
966
+ `Laminar.get_laminar_span_context_str()` respectively, it will be\
967
+ converted to a `LaminarSpanContext` if possible. See also\
968
+ `Laminar.get_span_context`, `Laminar.get_span_context_dict` and\
969
+ `Laminar.get_span_context_str` for more information.
970
+ Defaults to None.
971
+ tags (list[str] | None, optional): tags to set for the span.
972
+ Defaults to None.
973
+ user_id (str | None, optional): user id to set for the trace.
974
+ Defaults to None.
975
+ session_id (str | None, optional): session id to set for the trace.
976
+ Defaults to None.
977
+ metadata (dict[str, AttributeValue] | None, optional): metadata to\
978
+ set for the trace. Defaults to None.
584
979
  """
585
- if level == TracingLevel.ALL:
586
- yield
980
+ span = cls.start_span(
981
+ name=name,
982
+ input=input,
983
+ span_type=span_type,
984
+ context=context,
985
+ parent_span_context=parent_span_context,
986
+ tags=tags,
987
+ user_id=user_id,
988
+ session_id=session_id,
989
+ metadata=metadata,
990
+ )
991
+ if not cls.is_initialized():
992
+ return span
993
+ wrapper = TracerWrapper()
994
+
995
+ # Set association props in context before push_span_context
996
+ # so child spans inherit them
997
+ assoc_props_token = set_association_props_in_context(span)
998
+ if assoc_props_token and isinstance(span, LaminarSpan):
999
+ span._lmnr_assoc_props_token = assoc_props_token
1000
+
1001
+ context = wrapper.push_span_context(span)
1002
+ context_token = context_api.attach(context)
1003
+ isolated_context_token = attach_context(context)
1004
+ span._lmnr_ctx_token = context_token
1005
+ span._lmnr_isolated_ctx_token = isolated_context_token
1006
+ if isinstance(span, LaminarSpan):
1007
+ return span
587
1008
  else:
588
- level = "meta_only" if level == TracingLevel.META_ONLY else "off"
589
- update_association_properties({"tracing_level": level})
590
- yield
591
- try:
592
- remove_association_properties({"tracing_level": level})
593
- except Exception:
594
- pass
1009
+ return LaminarSpan(span)
1010
+
1011
+ @classmethod
1012
+ def set_span_output(cls, output: Any = None):
1013
+ """Set the output of the current span. Useful for manual
1014
+ instrumentation.
1015
+
1016
+ Args:
1017
+ output (Any, optional): output of the span. Will be sent as an\
1018
+ attribute, so must be json serializable. Defaults to None.
1019
+ """
1020
+ span = cls.get_current_span()
1021
+ if span is None:
1022
+ return
1023
+ span.set_output(output)
595
1024
 
596
1025
  @classmethod
597
1026
  def set_span_attributes(
598
1027
  cls,
599
- attributes: dict[Attributes, Any],
1028
+ attributes: dict[Attributes | str, Any],
600
1029
  ):
601
1030
  """Set attributes for the current span. Useful for manual
602
1031
  instrumentation.
603
1032
  Example:
604
1033
  ```python
605
- with L.start_as_current_span(
1034
+ with Laminar.start_as_current_span(
606
1035
  name="my_span_name", input=input["messages"], span_type="LLM"
607
1036
  ):
608
1037
  response = await my_custom_call_to_openai(input)
609
- L.set_span_output(response["choices"][0]["message"]["content"])
610
- L.set_span_attributes({
1038
+ Laminar.set_span_output(response["choices"][0]["message"]["content"])
1039
+ Laminar.set_span_attributes({
611
1040
  Attributes.PROVIDER: 'openai',
612
1041
  Attributes.REQUEST_MODEL: input["model"],
613
1042
  Attributes.RESPONSE_MODEL: response["model"],
@@ -618,132 +1047,245 @@ class Laminar:
618
1047
  ```
619
1048
 
620
1049
  Args:
621
- attributes (dict[ATTRIBUTES, Any]): attributes to set for the span
1050
+ attributes (dict[Attributes | str, Any]): attributes to set for the span
622
1051
  """
623
- span = trace.get_current_span()
624
- if span == trace.INVALID_SPAN:
1052
+ span = cls.get_current_span()
1053
+ if span == trace.INVALID_SPAN or span is None:
625
1054
  return
626
1055
 
627
1056
  for key, value in attributes.items():
628
- # Python 3.12+ should do: if key not in Attributes:
629
- try:
630
- Attributes(key.value)
631
- except (TypeError, AttributeError):
632
- cls.__logger.warning(
633
- f"Attribute {key} is not a valid Laminar attribute."
634
- )
635
- continue
636
- if not isinstance(value, (str, int, float, bool)):
637
- span.set_attribute(key.value, json_dumps(value))
1057
+ if isinstance(key, Attributes):
1058
+ key = key.value
1059
+ if not is_otel_attribute_value_type(value):
1060
+ span.set_attribute(key, json_dumps(value))
638
1061
  else:
639
- span.set_attribute(key.value, value)
1062
+ span.set_attribute(key, value)
640
1063
 
641
1064
  @classmethod
642
- def set_session(
643
- cls,
644
- session_id: Optional[str] = None,
645
- ):
646
- """Set the session and user id for the current span and the context
647
- (i.e. any children spans created from the current span in the current
648
- thread).
1065
+ def get_laminar_span_context(
1066
+ cls, span: trace.Span | None = None
1067
+ ) -> LaminarSpanContext | None:
1068
+ """Get the laminar span context for a given span.
1069
+ If no span is provided, the current active span will be used.
1070
+ """
1071
+ if not cls.is_initialized():
1072
+ return None
1073
+
1074
+ span = span or cls.get_current_span()
1075
+ if span == trace.INVALID_SPAN or span is None:
1076
+ return None
1077
+ if not isinstance(span, LaminarSpan):
1078
+ span = LaminarSpan(span)
1079
+ return span.get_laminar_span_context()
1080
+
1081
+ @classmethod
1082
+ def get_laminar_span_context_dict(
1083
+ cls, span: trace.Span | None = None
1084
+ ) -> dict | None:
1085
+ span_context = cls.get_laminar_span_context(span)
1086
+ if span_context is None:
1087
+ return None
1088
+ return span_context.model_dump()
1089
+
1090
+ @classmethod
1091
+ def serialize_span_context(cls, span: trace.Span | None = None) -> str | None:
1092
+ """Get the laminar span context for a given span as a string.
1093
+ If no span is provided, the current active span will be used.
1094
+
1095
+ This is useful for continuing a trace across services.
1096
+
1097
+ Example:
1098
+ ```python
1099
+ # service A:
1100
+ with Laminar.start_as_current_span("service_a"):
1101
+ span_context = Laminar.serialize_span_context()
1102
+ # send span_context to service B
1103
+ call_service_b(request, headers={"laminar-span-context": span_context})
1104
+
1105
+ # service B:
1106
+ def call_service_b(request, headers):
1107
+ span_context = Laminar.deserialize_span_context(headers["laminar-span-context"])
1108
+ with Laminar.start_as_current_span("service_b", parent_span_context=span_context):
1109
+ # rest of the function
1110
+ pass
1111
+ ```
1112
+
1113
+ This will result in a trace like:
1114
+ ```
1115
+ service_a
1116
+ service_b
1117
+ ```
1118
+ """
1119
+ span_context = cls.get_laminar_span_context(span)
1120
+ if span_context is None:
1121
+ return None
1122
+ return str(span_context)
1123
+
1124
+ @classmethod
1125
+ def deserialize_span_context(cls, span_context: dict | str) -> LaminarSpanContext:
1126
+ return LaminarSpanContext.deserialize(span_context)
1127
+
1128
+ @classmethod
1129
+ def get_current_span(cls, context: Context | None = None) -> LaminarSpan | None:
1130
+ """Get the current active span. If a context is provided, the span will
1131
+ be retrieved from that context.
649
1132
 
650
1133
  Args:
651
- session_id (Optional[str], optional): Custom session id.\
652
- Useful to debug and group long-running\
653
- sessions/conversations.
654
- Defaults to None.
1134
+ context (Context | None, optional): The context to get the span\
1135
+ from. If not provided, the current context will be used.
1136
+ Defaults to None.
1137
+
1138
+ Returns:
1139
+ LaminarSpan | None: The current active span, or None if there is no\
1140
+ active span.
655
1141
  """
656
- association_properties = {}
657
- if session_id is not None:
658
- association_properties[SESSION_ID] = session_id
659
- update_association_properties(association_properties)
1142
+ context = context or get_current_context()
1143
+ span = trace.get_current_span(context=context)
1144
+ if span == trace.INVALID_SPAN:
1145
+ return None
1146
+ if isinstance(span, LaminarSpan):
1147
+ return span
1148
+ else:
1149
+ return LaminarSpan(span)
660
1150
 
661
1151
  @classmethod
662
- def set_metadata(cls, metadata: dict[str, str]):
663
- """Set the metadata for the current trace.
1152
+ def flush(cls) -> bool:
1153
+ """Flush the internal tracer.
1154
+
1155
+ Returns:
1156
+ bool: True if the tracer was flushed, False otherwise
1157
+ (e.g. no tracer or timeout).
1158
+ """
1159
+ if not cls.is_initialized():
1160
+ return False
1161
+ return TracerManager.flush()
1162
+
1163
+ @classmethod
1164
+ def force_flush(cls):
1165
+ """Force flush the internal tracer. WARNING: Any active spans are
1166
+ removed from context; that is, spans started afterwards will start
1167
+ a new trace.
1168
+
1169
+ Actually shuts down the span processor and re-initializes it as long
1170
+ as it is a LaminarSpanProcessor. This is not recommended in production
1171
+ workflows, but is useful at the end of Lambda functions, where a regular
1172
+ flush might be killed by the Lambda runtime, because the actual export
1173
+ inside it runs in a background thread.
1174
+ """
1175
+ if not cls.is_initialized():
1176
+ return
1177
+ TracerManager.force_reinit_processor()
1178
+
1179
+ @classmethod
1180
+ def shutdown(cls):
1181
+ if cls.is_initialized():
1182
+ TracerManager.shutdown()
1183
+ cls.__initialized = False
1184
+
1185
+ @classmethod
1186
+ def set_span_tags(cls, tags: list[str]):
1187
+ """Set the tags for the current span.
664
1188
 
665
1189
  Args:
666
- metadata (dict[str, str]): Metadata to set for the trace. Willl be\
667
- sent as attributes, so must be json serializable.
1190
+ tags (list[str]): Tags to set for the span.
668
1191
  """
669
- props = {f"metadata.{k}": json_dumps(v) for k, v in metadata.items()}
670
- update_association_properties(props)
1192
+ if not cls.is_initialized():
1193
+ return
1194
+
1195
+ span = cls.get_current_span()
1196
+ if span is None:
1197
+ return
1198
+ span.set_tags(tags)
671
1199
 
672
1200
  @classmethod
673
- def clear_metadata(cls):
674
- """Clear the metadata from the context"""
675
- props: dict = copy.copy(context_api.get_value("association_properties"))
676
- metadata_keys = [k for k in props.keys() if k.startswith("metadata.")]
677
- for k in metadata_keys:
678
- props.pop(k)
679
- set_association_properties(props)
1201
+ def add_span_tags(cls, tags: list[str]):
1202
+ """Add tags to the current span."""
1203
+ span = cls.get_current_span()
1204
+ if span is None:
1205
+ return
1206
+ span.add_tags(tags)
680
1207
 
681
1208
  @classmethod
682
- def clear_session(cls):
683
- """Clear the session and user id from the context"""
684
- props: dict = copy.copy(context_api.get_value("association_properties"))
685
- props.pop("session_id", None)
686
- props.pop("user_id", None)
687
- set_association_properties(props)
1209
+ def set_trace_session_id(cls, session_id: str | None = None):
1210
+ """Set the session id for the current trace.
1211
+ Overrides any existing session id.
1212
+
1213
+ Args:
1214
+ session_id (str | None, optional): Custom session id. Defaults to None.
1215
+ """
1216
+ if not cls.is_initialized():
1217
+ return
1218
+
1219
+ context = set_association_prop_context(session_id=session_id, attach=True)
1220
+
1221
+ span = cls.get_current_span(context=context)
1222
+ if span is None:
1223
+ cls.__logger.warning("No active span to set session id on")
1224
+ return
1225
+ span.set_trace_session_id(session_id)
688
1226
 
689
1227
  @classmethod
690
- async def create_evaluation(
691
- cls,
692
- data: list[EvaluationResultDatapoint],
693
- group_id: Optional[str] = None,
694
- name: Optional[str] = None,
695
- ) -> CreateEvaluationResponse:
696
- async with aiohttp.ClientSession() as session:
697
- async with session.post(
698
- cls.__base_http_url + "/v1/evaluations",
699
- json={
700
- "groupId": group_id,
701
- "name": name,
702
- "points": [datapoint.to_dict() for datapoint in data],
703
- },
704
- headers=cls._headers(),
705
- ) as response:
706
- if response.status != 200:
707
- try:
708
- resp_json = await response.json()
709
- raise ValueError(
710
- f"Error creating evaluation {json.dumps(resp_json)}"
711
- )
712
- except aiohttp.ClientError:
713
- text = await response.text()
714
- raise ValueError(f"Error creating evaluation {text}")
715
- resp_json = await response.json()
716
- return CreateEvaluationResponse.model_validate(resp_json)
1228
+ def set_trace_user_id(cls, user_id: str | None = None):
1229
+ """Set the user id for the current trace.
1230
+ Overrides any existing user id.
1231
+
1232
+ Args:
1233
+ user_id (str | None, optional): Custom user id. Defaults to None.
1234
+ """
1235
+ if not cls.is_initialized():
1236
+ return
1237
+
1238
+ context = set_association_prop_context(user_id=user_id, attach=True)
1239
+
1240
+ span = cls.get_current_span(context=context)
1241
+ if span is None:
1242
+ cls.__logger.warning("No active span to set user id on")
1243
+ return
1244
+ span.set_trace_user_id(user_id)
717
1245
 
718
1246
  @classmethod
719
- def get_datapoints(
720
- cls,
721
- dataset_name: str,
722
- offset: int,
723
- limit: int,
724
- ) -> GetDatapointsResponse:
725
- # TODO: Use aiohttp. Currently, this function is called from within
726
- # `LaminarDataset.__len__`, which is sync, but can be called from
727
- # both sync and async. Python does not make it easy to mix things this
728
- # way, so we should probably refactor `LaminarDataset`.
729
- params = {"name": dataset_name, "offset": offset, "limit": limit}
730
- url = (
731
- cls.__base_http_url
732
- + "/v1/datasets/datapoints?"
733
- + urllib.parse.urlencode(params)
1247
+ def set_trace_metadata(cls, metadata: dict[str, AttributeValue]):
1248
+ """Set the metadata for the current trace.
1249
+
1250
+ Args:
1251
+ metadata (dict[str, AttributeValue]): Metadata to set for the trace.
1252
+ """
1253
+ if not cls.is_initialized():
1254
+ return
1255
+
1256
+ merged_metadata = {**cls.__global_metadata, **(metadata or {})}
1257
+
1258
+ span = cls.get_current_span()
1259
+ if span is None:
1260
+ cls.__logger.warning("No active span to set metadata on")
1261
+ return
1262
+ span.set_trace_metadata(merged_metadata)
1263
+
1264
+ @classmethod
1265
+ def get_base_http_url(cls):
1266
+ return cls.__base_http_url
1267
+
1268
+ @classmethod
1269
+ def get_project_api_key(cls):
1270
+ return cls.__project_api_key
1271
+
1272
+ @classmethod
1273
+ def get_trace_id(cls) -> uuid.UUID | None:
1274
+ """Get the trace id for the current active span represented as a UUID.
1275
+ Returns None if there is no active span.
1276
+
1277
+ Returns:
1278
+ uuid.UUID | None: The trace id for the current span, or None if\
1279
+ there is no active span.
1280
+ """
1281
+ trace_id = (
1282
+ trace.get_current_span(context=get_current_context())
1283
+ .get_span_context()
1284
+ .trace_id
734
1285
  )
735
- response = requests.get(url, headers=cls._headers())
736
- if response.status_code != 200:
737
- try:
738
- resp_json = response.json()
739
- raise ValueError(
740
- f"Error fetching datapoints: [{response.status_code}] {json.dumps(resp_json)}"
741
- )
742
- except requests.exceptions.RequestException:
743
- raise ValueError(
744
- f"Error fetching datapoints: [{response.status_code}] {response.text}"
745
- )
746
- return GetDatapointsResponse.model_validate(response.json())
1286
+ if trace_id == INVALID_TRACE_ID:
1287
+ return None
1288
+ return uuid.UUID(int=trace_id)
747
1289
 
748
1290
  @classmethod
749
1291
  def _headers(cls):
@@ -762,56 +1304,45 @@ class Laminar:
762
1304
  Args:
763
1305
  trace_type (TraceType): Type of the trace
764
1306
  """
765
- association_properties = {
766
- TRACE_TYPE: trace_type.value,
767
- }
768
- update_association_properties(association_properties)
1307
+ if not cls.is_initialized():
1308
+ return
769
1309
 
770
- @classmethod
771
- async def __run(
772
- cls,
773
- request: PipelineRunRequest,
774
- ) -> PipelineRunResponse:
775
- async with aiohttp.ClientSession() as session:
776
- async with session.post(
777
- cls.__base_http_url + "/v1/pipeline/run",
778
- data=json.dumps(request.to_dict()),
779
- headers=cls._headers(),
780
- ) as response:
781
- if response.status != 200:
782
- raise PipelineRunError(response)
783
- try:
784
- resp_json = await response.json()
785
- keys = list(resp_json.keys())
786
- for key in keys:
787
- value = resp_json[key]
788
- del resp_json[key]
789
- resp_json[to_snake(key)] = value
790
- return PipelineRunResponse(**resp_json)
791
- except Exception:
792
- raise PipelineRunError(response)
1310
+ span = trace.get_current_span(context=get_current_context())
1311
+ if span == trace.INVALID_SPAN:
1312
+ cls.__logger.warning("No active span to set trace type on")
1313
+ return
1314
+ span.set_attribute(f"{ASSOCIATION_PROPERTIES}.{TRACE_TYPE}", trace_type.value)
793
1315
 
794
1316
  @classmethod
795
- async def __semantic_search(
1317
+ def _get_association_prop_attributes(
796
1318
  cls,
797
- request: SemanticSearchRequest,
798
- ) -> SemanticSearchResponse:
799
- async with aiohttp.ClientSession() as session:
800
- async with session.post(
801
- cls.__base_http_url + "/v1/semantic-search",
802
- data=json.dumps(request.to_dict()),
803
- headers=cls._headers(),
804
- ) as response:
805
- if response.status != 200:
806
- raise ValueError(
807
- f"Error performing semantic search: [{response.status}] {response.text}"
808
- )
809
- try:
810
- resp_json = await response.json()
811
- for result in resp_json["results"]:
812
- result["dataset_id"] = uuid.UUID(result["datasetId"])
813
- return SemanticSearchResponse(**resp_json)
814
- except Exception as e:
815
- raise ValueError(
816
- f"Error parsing semantic search response: status={response.status} error={e}"
817
- )
1319
+ user_id: str | None = None,
1320
+ session_id: str | None = None,
1321
+ trace_type: TraceType | None = None,
1322
+ metadata: dict[str, AttributeValue] | None = None,
1323
+ ) -> dict[str, AttributeValue]:
1324
+ association_properties = {}
1325
+ if user_id is not None:
1326
+ association_properties[f"{ASSOCIATION_PROPERTIES}.{USER_ID}"] = user_id
1327
+ if session_id is not None:
1328
+ association_properties[f"{ASSOCIATION_PROPERTIES}.{SESSION_ID}"] = (
1329
+ session_id
1330
+ )
1331
+ if trace_type is not None:
1332
+ trace_type_val = (
1333
+ trace_type.value if isinstance(trace_type, TraceType) else trace_type
1334
+ )
1335
+ association_properties[f"{ASSOCIATION_PROPERTIES}.{TRACE_TYPE}"] = (
1336
+ trace_type_val
1337
+ )
1338
+
1339
+ merged_metadata = {**cls.__global_metadata, **(metadata or {})}
1340
+ association_properties.update(
1341
+ {
1342
+ f"{ASSOCIATION_PROPERTIES}.metadata.{k}": (
1343
+ v if is_otel_attribute_value_type(v) else json_dumps(v)
1344
+ )
1345
+ for k, v in merged_metadata.items()
1346
+ }
1347
+ )
1348
+ return association_properties