lmnr 0.4.58__tar.gz → 0.4.60__tar.gz

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 (38) hide show
  1. {lmnr-0.4.58 → lmnr-0.4.60}/PKG-INFO +1 -1
  2. {lmnr-0.4.58 → lmnr-0.4.60}/pyproject.toml +1 -1
  3. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/config/__init__.py +3 -0
  4. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/decorators/base.py +45 -27
  5. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/attributes.py +3 -0
  6. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/tracing.py +25 -5
  7. lmnr-0.4.60/src/lmnr/sdk/__init__.py +0 -0
  8. lmnr-0.4.60/src/lmnr/sdk/browser/playwright_patch.py +349 -0
  9. lmnr-0.4.60/src/lmnr/sdk/browser/rrweb/rrweb.min.js +18 -0
  10. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/decorators.py +16 -3
  11. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/evaluations.py +8 -0
  12. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/laminar.py +49 -13
  13. lmnr-0.4.60/src/lmnr/version.py +5 -0
  14. lmnr-0.4.58/src/lmnr/openllmetry_sdk/version.py +0 -1
  15. lmnr-0.4.58/src/lmnr/sdk/browser/playwright_patch.py +0 -248
  16. {lmnr-0.4.58 → lmnr-0.4.60}/LICENSE +0 -0
  17. {lmnr-0.4.58 → lmnr-0.4.60}/README.md +0 -0
  18. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/__init__.py +0 -0
  19. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/cli.py +0 -0
  20. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/.flake8 +0 -0
  21. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/.python-version +0 -0
  22. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/__init__.py +0 -0
  23. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/decorators/__init__.py +0 -0
  24. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/instruments.py +0 -0
  25. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/__init__.py +0 -0
  26. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -0
  27. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/context_manager.py +0 -0
  28. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/__init__.py +0 -0
  29. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -0
  30. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/json_encoder.py +0 -0
  31. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/package_check.py +0 -0
  32. /lmnr-0.4.58/src/lmnr/sdk/__init__.py → /lmnr-0.4.60/src/lmnr/py.typed +0 -0
  33. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/browser/__init__.py +0 -0
  34. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/datasets.py +0 -0
  35. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/eval_control.py +0 -0
  36. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/log.py +0 -0
  37. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/types.py +0 -0
  38. {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: lmnr
3
- Version: 0.4.58
3
+ Version: 0.4.60
4
4
  Summary: Python SDK for Laminar
5
5
  License: Apache-2.0
6
6
  Author: lmnr.ai
@@ -6,7 +6,7 @@
6
6
 
7
7
  [project]
8
8
  name = "lmnr"
9
- version = "0.4.58"
9
+ version = "0.4.60"
10
10
  description = "Python SDK for Laminar"
11
11
  authors = [
12
12
  { name = "lmnr.ai", email = "founders@lmnr.ai" }
@@ -7,3 +7,6 @@ def is_tracing_enabled() -> bool:
7
7
 
8
8
  def is_content_tracing_enabled() -> bool:
9
9
  return (os.getenv("TRACELOOP_TRACE_CONTENT") or "true").lower() == "true"
10
+
11
+
12
+ MAX_MANUAL_SPAN_PAYLOAD_SIZE = 1024 * 1024 # 1MB
@@ -4,7 +4,7 @@ import logging
4
4
  import os
5
5
  import pydantic
6
6
  import types
7
- from typing import Any, Optional
7
+ from typing import Any, Literal, Optional, Union
8
8
 
9
9
  from opentelemetry import trace
10
10
  from opentelemetry import context as context_api
@@ -12,9 +12,10 @@ from opentelemetry.trace import Span
12
12
 
13
13
  from lmnr.sdk.utils import get_input_from_func_args, is_method
14
14
  from lmnr.openllmetry_sdk.tracing import get_tracer
15
- from lmnr.openllmetry_sdk.tracing.attributes import SPAN_INPUT, SPAN_OUTPUT
15
+ from lmnr.openllmetry_sdk.tracing.attributes import SPAN_INPUT, SPAN_OUTPUT, SPAN_TYPE
16
16
  from lmnr.openllmetry_sdk.tracing.tracing import TracerWrapper
17
17
  from lmnr.openllmetry_sdk.utils.json_encoder import JSONEncoder
18
+ from lmnr.openllmetry_sdk.config import MAX_MANUAL_SPAN_PAYLOAD_SIZE
18
19
 
19
20
 
20
21
  class CustomJSONEncoder(JSONEncoder):
@@ -38,6 +39,9 @@ def json_dumps(data: dict) -> str:
38
39
 
39
40
  def entity_method(
40
41
  name: Optional[str] = None,
42
+ ignore_input: bool = False,
43
+ ignore_output: bool = False,
44
+ span_type: Union[Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]] = "DEFAULT",
41
45
  ):
42
46
  def decorate(fn):
43
47
  @wraps(fn)
@@ -48,21 +52,22 @@ def entity_method(
48
52
  span_name = name or fn.__name__
49
53
 
50
54
  with get_tracer() as tracer:
51
- span = tracer.start_span(span_name)
55
+ span = tracer.start_span(span_name, attributes={SPAN_TYPE: span_type})
52
56
 
53
57
  ctx = trace.set_span_in_context(span, context_api.get_current())
54
58
  ctx_token = context_api.attach(ctx)
55
59
 
56
60
  try:
57
- if _should_send_prompts():
58
- span.set_attribute(
59
- SPAN_INPUT,
60
- json_dumps(
61
- get_input_from_func_args(
62
- fn, is_method(fn), args, kwargs
63
- )
64
- ),
61
+ if _should_send_prompts() and not ignore_input:
62
+ inp = json_dumps(
63
+ get_input_from_func_args(fn, is_method(fn), args, kwargs)
65
64
  )
65
+ if len(inp) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
66
+ span.set_attribute(
67
+ SPAN_INPUT, "Laminar: input too large to record"
68
+ )
69
+ else:
70
+ span.set_attribute(SPAN_INPUT, inp)
66
71
  except TypeError:
67
72
  pass
68
73
 
@@ -78,11 +83,14 @@ def entity_method(
78
83
  return _handle_generator(span, res)
79
84
 
80
85
  try:
81
- if _should_send_prompts():
82
- span.set_attribute(
83
- SPAN_OUTPUT,
84
- json_dumps(res),
85
- )
86
+ if _should_send_prompts() and not ignore_output:
87
+ output = json_dumps(res)
88
+ if len(output) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
89
+ span.set_attribute(
90
+ SPAN_OUTPUT, "Laminar: output too large to record"
91
+ )
92
+ else:
93
+ span.set_attribute(SPAN_OUTPUT, output)
86
94
  except TypeError:
87
95
  pass
88
96
 
@@ -99,6 +107,9 @@ def entity_method(
99
107
  # Async Decorators
100
108
  def aentity_method(
101
109
  name: Optional[str] = None,
110
+ ignore_input: bool = False,
111
+ ignore_output: bool = False,
112
+ span_type: Union[Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]] = "DEFAULT",
102
113
  ):
103
114
  def decorate(fn):
104
115
  @wraps(fn)
@@ -109,21 +120,22 @@ def aentity_method(
109
120
  span_name = name or fn.__name__
110
121
 
111
122
  with get_tracer() as tracer:
112
- span = tracer.start_span(span_name)
123
+ span = tracer.start_span(span_name, attributes={SPAN_TYPE: span_type})
113
124
 
114
125
  ctx = trace.set_span_in_context(span, context_api.get_current())
115
126
  ctx_token = context_api.attach(ctx)
116
127
 
117
128
  try:
118
- if _should_send_prompts():
119
- span.set_attribute(
120
- SPAN_INPUT,
121
- json_dumps(
122
- get_input_from_func_args(
123
- fn, is_method(fn), args, kwargs
124
- )
125
- ),
129
+ if _should_send_prompts() and not ignore_input:
130
+ inp = json_dumps(
131
+ get_input_from_func_args(fn, is_method(fn), args, kwargs)
126
132
  )
133
+ if len(inp) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
134
+ span.set_attribute(
135
+ SPAN_INPUT, "Laminar: input too large to record"
136
+ )
137
+ else:
138
+ span.set_attribute(SPAN_INPUT, inp)
127
139
  except TypeError:
128
140
  pass
129
141
 
@@ -139,8 +151,14 @@ def aentity_method(
139
151
  return await _ahandle_generator(span, ctx_token, res)
140
152
 
141
153
  try:
142
- if _should_send_prompts():
143
- span.set_attribute(SPAN_OUTPUT, json_dumps(res))
154
+ if _should_send_prompts() and not ignore_output:
155
+ output = json_dumps(res)
156
+ if len(output) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
157
+ span.set_attribute(
158
+ SPAN_OUTPUT, "Laminar: output too large to record"
159
+ )
160
+ else:
161
+ span.set_attribute(SPAN_OUTPUT, output)
144
162
  except TypeError:
145
163
  pass
146
164
 
@@ -5,7 +5,10 @@ SPAN_INPUT = "lmnr.span.input"
5
5
  SPAN_OUTPUT = "lmnr.span.output"
6
6
  SPAN_TYPE = "lmnr.span.type"
7
7
  SPAN_PATH = "lmnr.span.path"
8
+ SPAN_IDS_PATH = "lmnr.span.ids_path"
8
9
  SPAN_INSTRUMENTATION_SOURCE = "lmnr.span.instrumentation_source"
10
+ SPAN_SDK_VERSION = "lmnr.span.sdk_version"
11
+ SPAN_LANGUAGE_VERSION = "lmnr.span.language_version"
9
12
  OVERRIDE_PARENT_SPAN = "lmnr.internal.override_parent_span"
10
13
 
11
14
  ASSOCIATION_PROPERTIES = "lmnr.association.properties"
@@ -1,7 +1,7 @@
1
1
  import atexit
2
2
  import copy
3
3
  import logging
4
-
4
+ import uuid
5
5
 
6
6
  from contextvars import Context
7
7
  from lmnr.sdk.log import VerboseColorfulFormatter
@@ -9,7 +9,10 @@ from lmnr.openllmetry_sdk.instruments import Instruments
9
9
  from lmnr.sdk.browser import init_browser_tracing
10
10
  from lmnr.openllmetry_sdk.tracing.attributes import (
11
11
  ASSOCIATION_PROPERTIES,
12
+ SPAN_IDS_PATH,
12
13
  SPAN_INSTRUMENTATION_SOURCE,
14
+ SPAN_SDK_VERSION,
15
+ SPAN_LANGUAGE_VERSION,
13
16
  SPAN_PATH,
14
17
  TRACING_LEVEL,
15
18
  )
@@ -23,6 +26,7 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
23
26
  from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
24
27
  OTLPSpanExporter as GRPCExporter,
25
28
  )
29
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import Compression
26
30
  from opentelemetry.instrumentation.threading import ThreadingInstrumentor
27
31
  from opentelemetry.context import get_value, attach, get_current, set_value, Context
28
32
  from opentelemetry.propagate import set_global_textmap
@@ -38,6 +42,8 @@ from opentelemetry.trace import get_tracer_provider, ProxyTracerProvider
38
42
 
39
43
  from typing import Dict, Optional, Set
40
44
 
45
+ from lmnr.version import SDK_VERSION, PYTHON_VERSION
46
+
41
47
  module_logger = logging.getLogger(__name__)
42
48
  console_log_handler = logging.StreamHandler()
43
49
  console_log_handler.setFormatter(VerboseColorfulFormatter())
@@ -72,6 +78,7 @@ class TracerWrapper(object):
72
78
  __tracer_provider: TracerProvider = None
73
79
  __logger: logging.Logger = None
74
80
  __span_id_to_path: dict[int, list[str]] = {}
81
+ __span_id_lists: dict[int, list[str]] = {}
75
82
 
76
83
  def __new__(
77
84
  cls,
@@ -132,9 +139,9 @@ class TracerWrapper(object):
132
139
  )
133
140
 
134
141
  if not instrument_set:
135
- cls.__logger.warning(
136
- "No valid instruments set. Remove 'instrument' "
137
- "argument to use all instruments, or set a valid instrument."
142
+ cls.__logger.info(
143
+ "No instruments set through Laminar. "
144
+ "Only enabling basic OpenTelemetry tracing."
138
145
  )
139
146
 
140
147
  obj.__content_allow_list = ContentAllowList()
@@ -162,12 +169,22 @@ class TracerWrapper(object):
162
169
  parent_span_path = span_path_in_context or (
163
170
  self.__span_id_to_path.get(span.parent.span_id) if span.parent else None
164
171
  )
172
+ parent_span_ids_path = (
173
+ self.__span_id_lists.get(span.parent.span_id, []) if span.parent else []
174
+ )
165
175
  span_path = parent_span_path + [span.name] if parent_span_path else [span.name]
176
+ span_ids_path = parent_span_ids_path + [
177
+ str(uuid.UUID(int=span.get_span_context().span_id))
178
+ ]
166
179
  span.set_attribute(SPAN_PATH, span_path)
180
+ span.set_attribute(SPAN_IDS_PATH, span_ids_path)
167
181
  set_value("span_path", span_path, get_current())
168
182
  self.__span_id_to_path[span.get_span_context().span_id] = span_path
183
+ self.__span_id_lists[span.get_span_context().span_id] = span_ids_path
169
184
 
170
185
  span.set_attribute(SPAN_INSTRUMENTATION_SOURCE, "python")
186
+ span.set_attribute(SPAN_SDK_VERSION, SDK_VERSION)
187
+ span.set_attribute(SPAN_LANGUAGE_VERSION, f"python@{PYTHON_VERSION}")
171
188
 
172
189
  association_properties = get_value("association_properties")
173
190
  if association_properties is not None:
@@ -203,6 +220,7 @@ class TracerWrapper(object):
203
220
  def clear(cls):
204
221
  # Any state cleanup. Now used in between tests
205
222
  cls.__span_id_to_path = {}
223
+ cls.__span_id_lists = {}
206
224
 
207
225
  def flush(self):
208
226
  self.__spans_processor.force_flush()
@@ -268,7 +286,9 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor
268
286
  if "http" in api_endpoint.lower() or "https" in api_endpoint.lower():
269
287
  return HTTPExporter(endpoint=f"{api_endpoint}/v1/traces", headers=headers)
270
288
  else:
271
- return GRPCExporter(endpoint=f"{api_endpoint}", headers=headers)
289
+ return GRPCExporter(
290
+ endpoint=f"{api_endpoint}", headers=headers, compression=Compression.Gzip
291
+ )
272
292
 
273
293
 
274
294
  def init_tracer_provider(resource: Resource) -> TracerProvider:
File without changes
@@ -0,0 +1,349 @@
1
+ import opentelemetry
2
+ import uuid
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ import os
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ try:
11
+ from playwright.async_api import BrowserContext, Page
12
+ from playwright.sync_api import (
13
+ BrowserContext as SyncBrowserContext,
14
+ Page as SyncPage,
15
+ )
16
+ except ImportError as e:
17
+ raise ImportError(
18
+ f"Attempted to import {__file__}, but it is designed "
19
+ "to patch Playwright, which is not installed. Use `pip install playwright` "
20
+ "to install Playwright or remove this import."
21
+ ) from e
22
+
23
+ _original_new_page = None
24
+ _original_new_page_async = None
25
+
26
+ current_dir = os.path.dirname(os.path.abspath(__file__))
27
+ with open(os.path.join(current_dir, "rrweb", "rrweb.min.js"), "r") as f:
28
+ RRWEB_CONTENT = f"() => {{ {f.read()} }}"
29
+
30
+ INJECT_PLACEHOLDER = """
31
+ ([baseUrl, projectApiKey]) => {
32
+ const serverUrl = `${baseUrl}/v1/browser-sessions/events`;
33
+ const FLUSH_INTERVAL = 1000;
34
+ const HEARTBEAT_INTERVAL = 1000;
35
+
36
+ window.lmnrRrwebEventsBatch = [];
37
+
38
+ window.lmnrSendRrwebEventsBatch = async () => {
39
+ if (window.lmnrRrwebEventsBatch.length === 0) return;
40
+
41
+ const eventsPayload = {
42
+ sessionId: window.lmnrRrwebSessionId,
43
+ traceId: window.lmnrTraceId,
44
+ events: window.lmnrRrwebEventsBatch
45
+ };
46
+
47
+ try {
48
+ const jsonString = JSON.stringify(eventsPayload);
49
+ const uint8Array = new TextEncoder().encode(jsonString);
50
+
51
+ const cs = new CompressionStream('gzip');
52
+ const compressedStream = await new Response(
53
+ new Response(uint8Array).body.pipeThrough(cs)
54
+ ).arrayBuffer();
55
+
56
+ const compressedArray = new Uint8Array(compressedStream);
57
+
58
+ const blob = new Blob([compressedArray], { type: 'application/octet-stream' });
59
+
60
+ const response = await fetch(serverUrl, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ 'Content-Encoding': 'gzip',
65
+ 'Authorization': `Bearer ${projectApiKey}`,
66
+ 'Accept': 'application/json'
67
+ },
68
+ body: blob,
69
+ mode: 'cors',
70
+ credentials: 'omit'
71
+ });
72
+
73
+ if (!response.ok) {
74
+ console.error(`HTTP error! status: ${response.status}`);
75
+ if (response.status === 0) {
76
+ console.error('Possible CORS issue - check network tab for details');
77
+ }
78
+ }
79
+
80
+ window.lmnrRrwebEventsBatch = [];
81
+ } catch (error) {
82
+ console.error('Failed to send events:', error);
83
+ }
84
+ };
85
+
86
+ setInterval(() => window.lmnrSendRrwebEventsBatch(), FLUSH_INTERVAL);
87
+
88
+ setInterval(() => {
89
+ window.lmnrRrwebEventsBatch.push({
90
+ type: 6,
91
+ data: { source: 'heartbeat' },
92
+ timestamp: Date.now()
93
+ });
94
+ }, HEARTBEAT_INTERVAL);
95
+
96
+ window.lmnrRrweb.record({
97
+ emit(event) {
98
+ window.lmnrRrwebEventsBatch.push(event);
99
+ }
100
+ });
101
+
102
+ window.addEventListener('beforeunload', () => {
103
+ window.lmnrSendRrwebEventsBatch();
104
+ });
105
+ }
106
+ """
107
+
108
+
109
+ def retry_sync(func, retries=5, delay=0.5, error_message="Operation failed"):
110
+ """Utility function for retry logic in synchronous operations"""
111
+ for attempt in range(retries):
112
+ try:
113
+ result = func()
114
+ if result: # If function returns truthy value, consider it successful
115
+ return result
116
+ if attempt == retries - 1: # Last attempt
117
+ logger.error(f"{error_message} after all retries")
118
+ return None
119
+ except Exception as e:
120
+ if attempt == retries - 1: # Last attempt
121
+ logger.error(f"{error_message}: {e}")
122
+ return None
123
+ time.sleep(delay)
124
+ return None
125
+
126
+
127
+ async def retry_async(func, retries=5, delay=0.5, error_message="Operation failed"):
128
+ """Utility function for retry logic in asynchronous operations"""
129
+ for attempt in range(retries):
130
+ try:
131
+ result = await func()
132
+ if result: # If function returns truthy value, consider it successful
133
+ return result
134
+ if attempt == retries - 1: # Last attempt
135
+ logger.error(f"{error_message} after all retries")
136
+ return None
137
+ except Exception as e:
138
+ if attempt == retries - 1: # Last attempt
139
+ logger.error(f"{error_message}: {e}")
140
+ return None
141
+ await asyncio.sleep(delay)
142
+ return None
143
+
144
+
145
+ def init_playwright_tracing(http_url: str, project_api_key: str):
146
+
147
+ def inject_rrweb(page: SyncPage):
148
+ # Wait for the page to be in a ready state first
149
+ page.wait_for_load_state("domcontentloaded")
150
+
151
+ # First check if rrweb is already loaded
152
+ is_loaded = page.evaluate(
153
+ """
154
+ () => typeof window.lmnrRrweb !== 'undefined'
155
+ """
156
+ )
157
+
158
+ if not is_loaded:
159
+
160
+ def load_rrweb():
161
+ page.evaluate(RRWEB_CONTENT)
162
+ # Verify script loaded successfully
163
+ page.wait_for_function(
164
+ """(() => typeof window.lmnrRrweb !== 'undefined')""",
165
+ timeout=5000,
166
+ )
167
+ return True
168
+
169
+ if not retry_sync(
170
+ load_rrweb, delay=1, error_message="Failed to load rrweb"
171
+ ):
172
+ return
173
+
174
+ # Get current trace ID from active span
175
+ current_span = opentelemetry.trace.get_current_span()
176
+ if current_span.is_recording():
177
+ current_span.set_attribute("lmnr.internal.has_browser_session", True)
178
+
179
+ trace_id = format(current_span.get_span_context().trace_id, "032x")
180
+ session_id = str(uuid.uuid4().hex)
181
+
182
+ def set_window_vars():
183
+ page.evaluate(
184
+ """([traceId, sessionId]) => {
185
+ window.lmnrRrwebSessionId = sessionId;
186
+ window.lmnrTraceId = traceId;
187
+ }""",
188
+ [trace_id, session_id],
189
+ )
190
+ return page.evaluate(
191
+ """
192
+ () => window.lmnrRrwebSessionId && window.lmnrTraceId
193
+ """
194
+ )
195
+
196
+ if not retry_sync(
197
+ set_window_vars, error_message="Failed to set window variables"
198
+ ):
199
+ return
200
+
201
+ # Update the recording setup to include trace ID
202
+ page.evaluate(
203
+ INJECT_PLACEHOLDER,
204
+ [http_url, project_api_key],
205
+ )
206
+
207
+ async def inject_rrweb_async(page: Page):
208
+ # Wait for the page to be in a ready state first
209
+ await page.wait_for_load_state("domcontentloaded")
210
+
211
+ # First check if rrweb is already loaded
212
+ is_loaded = await page.evaluate(
213
+ """
214
+ () => typeof window.lmnrRrweb !== 'undefined'
215
+ """
216
+ )
217
+
218
+ if not is_loaded:
219
+
220
+ async def load_rrweb():
221
+ await page.evaluate(RRWEB_CONTENT)
222
+ # Verify script loaded successfully
223
+ await page.wait_for_function(
224
+ """(() => typeof window.lmnrRrweb !== 'undefined')""",
225
+ timeout=5000,
226
+ )
227
+ return True
228
+
229
+ if not await retry_async(
230
+ load_rrweb, delay=1, error_message="Failed to load rrweb"
231
+ ):
232
+ return
233
+
234
+ # Get current trace ID from active span
235
+ current_span = opentelemetry.trace.get_current_span()
236
+ if current_span.is_recording():
237
+ current_span.set_attribute("lmnr.internal.has_browser_session", True)
238
+
239
+ trace_id = format(current_span.get_span_context().trace_id, "032x")
240
+ session_id = str(uuid.uuid4().hex)
241
+
242
+ async def set_window_vars():
243
+ await page.evaluate(
244
+ """([traceId, sessionId]) => {
245
+ window.lmnrRrwebSessionId = sessionId;
246
+ window.lmnrTraceId = traceId;
247
+ }""",
248
+ [trace_id, session_id],
249
+ )
250
+ return await page.evaluate(
251
+ """
252
+ () => window.lmnrRrwebSessionId && window.lmnrTraceId
253
+ """
254
+ )
255
+
256
+ if not await retry_async(
257
+ set_window_vars, error_message="Failed to set window variables"
258
+ ):
259
+ return
260
+
261
+ # Update the recording setup to include trace ID
262
+ await page.evaluate(
263
+ INJECT_PLACEHOLDER,
264
+ [http_url, project_api_key],
265
+ )
266
+
267
+ def handle_navigation(page: SyncPage):
268
+ def on_load():
269
+ inject_rrweb(page)
270
+
271
+ page.on("load", on_load)
272
+ inject_rrweb(page)
273
+
274
+ async def handle_navigation_async(page: Page):
275
+ async def on_load():
276
+ await inject_rrweb_async(page)
277
+
278
+ page.on("load", lambda: asyncio.create_task(on_load()))
279
+ await inject_rrweb_async(page)
280
+
281
+ async def patched_new_page_async(self: BrowserContext, *args, **kwargs):
282
+ # Modify CSP to allow required domains
283
+ async def handle_route(route):
284
+ try:
285
+ response = await route.fetch()
286
+ headers = dict(response.headers)
287
+
288
+ # Find and modify CSP header
289
+ for header_name in headers:
290
+ if header_name.lower() == "content-security-policy":
291
+ csp = headers[header_name]
292
+ parts = csp.split(";")
293
+ for i, part in enumerate(parts):
294
+ if "connect-src" in part:
295
+ parts[i] = f"{part.strip()} {http_url}"
296
+ headers[header_name] = ";".join(parts)
297
+
298
+ await route.fulfill(response=response, headers=headers)
299
+ except Exception as e:
300
+ logger.debug(f"Error handling route: {e}")
301
+ await route.continue_()
302
+
303
+ # Intercept all navigation requests to modify CSP headers
304
+ await self.route("**/*", handle_route)
305
+ page = await _original_new_page_async(self, *args, **kwargs)
306
+ await handle_navigation_async(page)
307
+ return page
308
+
309
+ def patched_new_page(self: SyncBrowserContext, *args, **kwargs):
310
+ # Modify CSP to allow required domains
311
+ def handle_route(route):
312
+ try:
313
+ response = route.fetch()
314
+ headers = dict(response.headers)
315
+
316
+ # Find and modify CSP header
317
+ for header_name in headers:
318
+ if header_name.lower() == "content-security-policy":
319
+ csp = headers[header_name]
320
+ parts = csp.split(";")
321
+ for i, part in enumerate(parts):
322
+ if "connect-src" in part:
323
+ parts[i] = f"{part.strip()} {http_url}"
324
+ if not any("connect-src" in part for part in parts):
325
+ parts.append(f" connect-src 'self' {http_url}")
326
+ headers[header_name] = ";".join(parts)
327
+
328
+ route.fulfill(response=response, headers=headers)
329
+ except Exception as e:
330
+ logger.debug(f"Error handling route: {e}")
331
+ route.continue_()
332
+
333
+ # Intercept all navigation requests to modify CSP headers
334
+ self.route("**/*", handle_route)
335
+ page = _original_new_page(self, *args, **kwargs)
336
+ handle_navigation(page)
337
+ return page
338
+
339
+ def patch_browser():
340
+ global _original_new_page, _original_new_page_async
341
+ if _original_new_page_async is None:
342
+ _original_new_page_async = BrowserContext.new_page
343
+ BrowserContext.new_page = patched_new_page_async
344
+
345
+ if _original_new_page is None:
346
+ _original_new_page = SyncBrowserContext.new_page
347
+ SyncBrowserContext.new_page = patched_new_page
348
+
349
+ patch_browser()