lmnr 0.6.16__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 (113) hide show
  1. lmnr/__init__.py +6 -15
  2. lmnr/cli/__init__.py +270 -0
  3. lmnr/cli/datasets.py +371 -0
  4. lmnr/{cli.py → cli/evals.py} +20 -102
  5. lmnr/cli/rules.py +42 -0
  6. lmnr/opentelemetry_lib/__init__.py +9 -2
  7. lmnr/opentelemetry_lib/decorators/__init__.py +274 -168
  8. lmnr/opentelemetry_lib/litellm/__init__.py +352 -38
  9. lmnr/opentelemetry_lib/litellm/utils.py +82 -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 +191 -129
  24. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +26 -0
  25. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/utils.py +126 -41
  26. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/__init__.py +488 -0
  27. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/config.py +8 -0
  28. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_emitter.py +143 -0
  29. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_models.py +41 -0
  30. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/span_utils.py +229 -0
  31. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/utils.py +92 -0
  32. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/version.py +1 -0
  33. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/__init__.py +381 -0
  34. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/utils.py +36 -0
  35. lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/__init__.py +16 -16
  36. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
  37. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +472 -0
  38. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1185 -0
  39. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +305 -0
  40. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
  41. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +312 -0
  42. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  43. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  44. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
  45. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +197 -0
  46. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
  47. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +368 -0
  48. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +325 -0
  49. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +135 -0
  50. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +786 -0
  51. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
  52. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openhands_ai/__init__.py +388 -0
  53. lmnr/opentelemetry_lib/opentelemetry/instrumentation/opentelemetry/__init__.py +69 -0
  54. lmnr/opentelemetry_lib/opentelemetry/instrumentation/skyvern/__init__.py +59 -61
  55. lmnr/opentelemetry_lib/opentelemetry/instrumentation/threading/__init__.py +197 -0
  56. lmnr/opentelemetry_lib/tracing/__init__.py +119 -18
  57. lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +124 -25
  58. lmnr/opentelemetry_lib/tracing/attributes.py +4 -0
  59. lmnr/opentelemetry_lib/tracing/context.py +200 -0
  60. lmnr/opentelemetry_lib/tracing/exporter.py +109 -15
  61. lmnr/opentelemetry_lib/tracing/instruments.py +22 -5
  62. lmnr/opentelemetry_lib/tracing/processor.py +128 -30
  63. lmnr/opentelemetry_lib/tracing/span.py +398 -0
  64. lmnr/opentelemetry_lib/tracing/tracer.py +40 -1
  65. lmnr/opentelemetry_lib/tracing/utils.py +62 -0
  66. lmnr/opentelemetry_lib/utils/package_check.py +9 -0
  67. lmnr/opentelemetry_lib/utils/wrappers.py +11 -0
  68. lmnr/sdk/browser/background_send_events.py +158 -0
  69. lmnr/sdk/browser/browser_use_cdp_otel.py +100 -0
  70. lmnr/sdk/browser/browser_use_otel.py +12 -12
  71. lmnr/sdk/browser/bubus_otel.py +71 -0
  72. lmnr/sdk/browser/cdp_utils.py +518 -0
  73. lmnr/sdk/browser/inject_script.js +514 -0
  74. lmnr/sdk/browser/patchright_otel.py +18 -44
  75. lmnr/sdk/browser/playwright_otel.py +104 -187
  76. lmnr/sdk/browser/pw_utils.py +249 -210
  77. lmnr/sdk/browser/recorder/record.umd.min.cjs +84 -0
  78. lmnr/sdk/browser/utils.py +1 -1
  79. lmnr/sdk/client/asynchronous/async_client.py +47 -15
  80. lmnr/sdk/client/asynchronous/resources/__init__.py +2 -7
  81. lmnr/sdk/client/asynchronous/resources/browser_events.py +1 -0
  82. lmnr/sdk/client/asynchronous/resources/datasets.py +131 -0
  83. lmnr/sdk/client/asynchronous/resources/evals.py +122 -18
  84. lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
  85. lmnr/sdk/client/asynchronous/resources/tags.py +4 -10
  86. lmnr/sdk/client/synchronous/resources/__init__.py +2 -2
  87. lmnr/sdk/client/synchronous/resources/datasets.py +131 -0
  88. lmnr/sdk/client/synchronous/resources/evals.py +83 -17
  89. lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
  90. lmnr/sdk/client/synchronous/resources/tags.py +4 -10
  91. lmnr/sdk/client/synchronous/sync_client.py +47 -15
  92. lmnr/sdk/datasets/__init__.py +94 -0
  93. lmnr/sdk/datasets/file_utils.py +91 -0
  94. lmnr/sdk/decorators.py +103 -23
  95. lmnr/sdk/evaluations.py +122 -33
  96. lmnr/sdk/laminar.py +816 -333
  97. lmnr/sdk/log.py +7 -2
  98. lmnr/sdk/types.py +124 -143
  99. lmnr/sdk/utils.py +115 -2
  100. lmnr/version.py +1 -1
  101. {lmnr-0.6.16.dist-info → lmnr-0.7.26.dist-info}/METADATA +71 -78
  102. lmnr-0.7.26.dist-info/RECORD +116 -0
  103. lmnr-0.7.26.dist-info/WHEEL +4 -0
  104. lmnr-0.7.26.dist-info/entry_points.txt +3 -0
  105. lmnr/opentelemetry_lib/tracing/context_properties.py +0 -65
  106. lmnr/sdk/browser/rrweb/rrweb.umd.min.cjs +0 -98
  107. lmnr/sdk/client/asynchronous/resources/agent.py +0 -329
  108. lmnr/sdk/client/synchronous/resources/agent.py +0 -323
  109. lmnr/sdk/datasets.py +0 -60
  110. lmnr-0.6.16.dist-info/LICENSE +0 -75
  111. lmnr-0.6.16.dist-info/RECORD +0 -61
  112. lmnr-0.6.16.dist-info/WHEEL +0 -4
  113. lmnr-0.6.16.dist-info/entry_points.txt +0 -3
@@ -1,17 +1,23 @@
1
1
  import asyncio
2
- import logging
3
2
  import os
4
3
  import time
5
- import threading
4
+
5
+ import orjson
6
6
 
7
7
  from opentelemetry import trace
8
8
 
9
+ from lmnr.opentelemetry_lib.tracing.context import get_current_context
10
+ from lmnr.opentelemetry_lib.tracing import TracerWrapper
9
11
  from lmnr.opentelemetry_lib.utils.package_check import is_package_installed
10
12
  from lmnr.sdk.decorators import observe
11
13
  from lmnr.sdk.browser.utils import retry_sync, retry_async
12
- from lmnr.sdk.client.synchronous.sync_client import LaminarClient
14
+ from lmnr.sdk.browser.background_send_events import (
15
+ get_background_loop,
16
+ track_async_send,
17
+ )
13
18
  from lmnr.sdk.client.asynchronous.async_client import AsyncLaminarClient
14
-
19
+ from lmnr.sdk.log import get_default_logger
20
+ from lmnr.sdk.types import MaskInputOptions
15
21
 
16
22
  try:
17
23
  if is_package_installed("playwright"):
@@ -33,292 +39,325 @@ except ImportError as e:
33
39
  "or `pip install patchright` to install one of the supported browsers."
34
40
  ) from e
35
41
 
36
- logger = logging.getLogger(__name__)
42
+ logger = get_default_logger(__name__)
37
43
 
38
- current_dir = os.path.dirname(os.path.abspath(__file__))
39
- with open(os.path.join(current_dir, "rrweb", "rrweb.umd.min.cjs"), "r") as f:
40
- RRWEB_CONTENT = f"() => {{ {f.read()} }}"
44
+ OLD_BUFFER_TIMEOUT = 60
41
45
 
42
- INJECT_PLACEHOLDER = """
43
- () => {
44
- const BATCH_SIZE = 1000; // Maximum events to store in memory
45
-
46
- window.lmnrRrwebEventsBatch = new Set();
47
-
48
- // Utility function to compress individual event data
49
- async function compressEventData(data) {
50
- const jsonString = JSON.stringify(data);
51
- const blob = new Blob([jsonString], { type: 'application/json' });
52
- const compressedStream = blob.stream().pipeThrough(new CompressionStream('gzip'));
53
- const compressedResponse = new Response(compressedStream);
54
- const compressedData = await compressedResponse.arrayBuffer();
55
- return Array.from(new Uint8Array(compressedData));
56
- }
57
-
58
- window.lmnrGetAndClearEvents = () => {
59
- const events = window.lmnrRrwebEventsBatch;
60
- window.lmnrRrwebEventsBatch = new Set();
61
- return Array.from(events);
62
- };
63
-
64
- // Add heartbeat events
65
- setInterval(async () => {
66
- window.lmnrRrweb.record.addCustomEvent('heartbeat', {
67
- title: document.title,
68
- url: document.URL,
69
- })
70
-
71
- }, 1000);
72
-
73
- window.lmnrRrweb.record({
74
- async emit(event) {
75
- // Compress the data field
76
- const compressedEvent = {
77
- ...event,
78
- data: await compressEventData(event.data)
79
- };
80
- window.lmnrRrwebEventsBatch.add(compressedEvent);
81
- },
82
- recordCanvas: true,
83
- collectFonts: true,
84
- recordCrossOriginIframes: true
85
- });
86
- }
87
- """
88
-
89
-
90
- async def send_events_async(
91
- page: Page, session_id: str, trace_id: str, client: AsyncLaminarClient
46
+
47
+ def create_send_events_handler(
48
+ chunk_buffers: dict,
49
+ session_id: str,
50
+ trace_id: str,
51
+ client: AsyncLaminarClient,
52
+ background_loop: asyncio.AbstractEventLoop,
92
53
  ):
93
- """Fetch events from the page and send them to the server"""
94
- try:
95
- # Check if function exists first
96
- events = await page.evaluate(
97
- """
98
- () => {
99
- if (typeof window.lmnrGetAndClearEvents !== 'function') {
100
- return [];
101
- }
102
- return window.lmnrGetAndClearEvents();
103
- }
104
- """
105
- )
54
+ """
55
+ Create an async event handler for sending browser events.
106
56
 
107
- if not events or len(events) == 0:
108
- return
57
+ This handler reassembles chunked event data and submits it to the background
58
+ loop for async HTTP sending. The handler itself processes chunks synchronously
59
+ but delegates the actual HTTP send to the background loop.
109
60
 
110
- await client._browser_events.send(session_id, trace_id, events)
111
- except Exception as e:
112
- if str(e).startswith("Page.evaluate: Execution context was destroyed"):
113
- logger.info("Execution context was destroyed, injecting rrweb again")
114
- await inject_rrweb_async(page)
115
- await send_events_async(page, session_id, trace_id, client)
116
- else:
117
- logger.debug(f"Could not send events: {e}")
61
+ Args:
62
+ chunk_buffers: Dictionary to store incomplete chunk batches
63
+ session_id: Browser session ID
64
+ trace_id: OpenTelemetry trace ID
65
+ client: Async Laminar client for HTTP requests
66
+ background_loop: Background event loop for async sends
118
67
 
68
+ Returns:
69
+ An async function that handles incoming event chunks from the browser
70
+ """
119
71
 
120
- def send_events_sync(
121
- page: SyncPage, session_id: str, trace_id: str, client: LaminarClient
122
- ):
123
- """Synchronous version of send_events"""
124
- try:
125
- events = page.evaluate(
126
- """
127
- () => {
128
- if (typeof window.lmnrGetAndClearEvents !== 'function') {
129
- return [];
130
- }
131
- return window.lmnrGetAndClearEvents();
132
- }
133
- """
134
- )
135
- if not events or len(events) == 0:
136
- return
72
+ async def send_events_from_browser(chunk):
73
+ try:
74
+ # Handle chunked data
75
+ batch_id = chunk["batchId"]
76
+ chunk_index = chunk["chunkIndex"]
77
+ total_chunks = chunk["totalChunks"]
78
+ data = chunk["data"]
79
+
80
+ # Initialize buffer for this batch if needed
81
+ if batch_id not in chunk_buffers:
82
+ chunk_buffers[batch_id] = {
83
+ "chunks": {},
84
+ "total": total_chunks,
85
+ "timestamp": time.time(),
86
+ }
137
87
 
138
- client._browser_events.send(session_id, trace_id, events)
88
+ # Store chunk
89
+ chunk_buffers[batch_id]["chunks"][chunk_index] = data
90
+
91
+ # Check if we have all chunks
92
+ if len(chunk_buffers[batch_id]["chunks"]) == total_chunks:
93
+ # Reassemble the full message
94
+ full_data = ""
95
+ for i in range(total_chunks):
96
+ full_data += chunk_buffers[batch_id]["chunks"][i]
97
+
98
+ # Parse the JSON
99
+ events = orjson.loads(full_data)
100
+
101
+ # Send to server in background loop (independent of Playwright's loop)
102
+ if events and len(events) > 0:
103
+ future = asyncio.run_coroutine_threadsafe(
104
+ client._browser_events.send(session_id, trace_id, events),
105
+ background_loop,
106
+ )
107
+ track_async_send(future)
108
+
109
+ # Clean up buffer
110
+ del chunk_buffers[batch_id]
111
+
112
+ # Clean up old incomplete buffers
113
+ current_time = time.time()
114
+ to_delete = []
115
+ for bid, buffer in chunk_buffers.items():
116
+ if current_time - buffer["timestamp"] > OLD_BUFFER_TIMEOUT:
117
+ to_delete.append(bid)
118
+ for bid in to_delete:
119
+ logger.debug(f"Cleaning up incomplete chunk buffer: {bid}")
120
+ del chunk_buffers[bid]
139
121
 
140
- except Exception as e:
141
- if str(e).startswith("Page.evaluate: Execution context was destroyed"):
142
- logger.info("Execution context was destroyed, injecting rrweb again")
143
- inject_rrweb_sync(page)
144
- send_events_sync(page, session_id, trace_id, client)
145
- else:
122
+ except Exception as e:
146
123
  logger.debug(f"Could not send events: {e}")
147
124
 
125
+ return send_events_from_browser
126
+
127
+
128
+ current_dir = os.path.dirname(os.path.abspath(__file__))
129
+ with open(os.path.join(current_dir, "recorder", "record.umd.min.cjs"), "r") as f:
130
+ RRWEB_CONTENT = f"() => {{ {f.read()} }}"
148
131
 
149
- def inject_rrweb_sync(page: SyncPage):
132
+ with open(os.path.join(current_dir, "inject_script.js"), "r") as f:
133
+ INJECT_SCRIPT_CONTENT = f.read()
134
+
135
+
136
+ def get_mask_input_setting() -> MaskInputOptions:
137
+ """Get the mask_input setting from session recording configuration."""
150
138
  try:
151
- page.wait_for_load_state("domcontentloaded")
139
+ config = TracerWrapper.get_session_recording_options()
140
+ return config.get(
141
+ "mask_input_options",
142
+ MaskInputOptions(
143
+ textarea=False,
144
+ text=False,
145
+ number=False,
146
+ select=False,
147
+ email=False,
148
+ tel=False,
149
+ ),
150
+ )
151
+ except (AttributeError, Exception):
152
+ # Fallback to default configuration if TracerWrapper is not initialized
153
+ return MaskInputOptions(
154
+ textarea=False,
155
+ text=False,
156
+ number=False,
157
+ select=False,
158
+ email=False,
159
+ tel=False,
160
+ )
152
161
 
153
- # Wrap the evaluate call in a try-catch
162
+
163
+ def inject_session_recorder_sync(page: SyncPage):
164
+ try:
154
165
  try:
155
166
  is_loaded = page.evaluate(
156
167
  """() => typeof window.lmnrRrweb !== 'undefined'"""
157
168
  )
158
169
  except Exception as e:
159
- logger.debug(f"Failed to check if rrweb is loaded: {e}")
170
+ logger.debug(f"Failed to check if session recorder is loaded: {e}")
160
171
  is_loaded = False
161
172
 
162
173
  if not is_loaded:
163
174
 
164
- def load_rrweb():
175
+ def load_session_recorder():
165
176
  try:
177
+ if page.is_closed():
178
+ return False
166
179
  page.evaluate(RRWEB_CONTENT)
167
180
  return True
168
181
  except Exception as e:
169
- logger.debug(f"Failed to load rrweb: {e}")
182
+ logger.debug(f"Failed to load session recorder: {e}")
170
183
  return False
171
184
 
172
185
  if not retry_sync(
173
- load_rrweb, delay=1, error_message="Failed to load rrweb"
186
+ load_session_recorder,
187
+ delay=1,
188
+ error_message="Failed to load session recorder",
174
189
  ):
175
190
  return
176
191
 
177
- try:
178
- page.evaluate(INJECT_PLACEHOLDER)
179
- except Exception as e:
180
- logger.debug(f"Failed to inject rrweb placeholder: {e}")
192
+ try:
193
+ if not page.is_closed():
194
+ page.evaluate(
195
+ f"({INJECT_SCRIPT_CONTENT})({orjson.dumps(get_mask_input_setting()).decode('utf-8')}, false)"
196
+ )
197
+ except Exception as e:
198
+ logger.debug(f"Failed to inject session recorder: {e}")
181
199
 
182
200
  except Exception as e:
183
- logger.error(f"Error during rrweb injection: {e}")
201
+ logger.error(f"Error during session recorder injection: {e}")
184
202
 
185
203
 
186
- async def inject_rrweb_async(page: Page):
204
+ async def inject_session_recorder_async(page: Page):
187
205
  try:
188
- await page.wait_for_load_state("domcontentloaded")
189
-
190
- # Wrap the evaluate call in a try-catch
191
206
  try:
192
207
  is_loaded = await page.evaluate(
193
208
  """() => typeof window.lmnrRrweb !== 'undefined'"""
194
209
  )
195
210
  except Exception as e:
196
- logger.debug(f"Failed to check if rrweb is loaded: {e}")
211
+ logger.debug(f"Failed to check if session recorder is loaded: {e}")
197
212
  is_loaded = False
198
213
 
199
214
  if not is_loaded:
200
215
 
201
- async def load_rrweb():
216
+ async def load_session_recorder():
202
217
  try:
218
+ if page.is_closed():
219
+ return False
203
220
  await page.evaluate(RRWEB_CONTENT)
204
221
  return True
205
222
  except Exception as e:
206
- logger.debug(f"Failed to load rrweb: {e}")
223
+ logger.debug(f"Failed to load session recorder: {e}")
207
224
  return False
208
225
 
209
226
  if not await retry_async(
210
- load_rrweb, delay=1, error_message="Failed to load rrweb"
227
+ load_session_recorder,
228
+ delay=1,
229
+ error_message="Failed to load session recorder",
211
230
  ):
212
231
  return
213
232
 
214
- try:
215
- await page.evaluate(INJECT_PLACEHOLDER)
216
- except Exception as e:
217
- logger.debug(f"Failed to inject rrweb placeholder: {e}")
233
+ try:
234
+ if not page.is_closed():
235
+ await page.evaluate(
236
+ f"({INJECT_SCRIPT_CONTENT})({orjson.dumps(get_mask_input_setting()).decode('utf-8')}, false)"
237
+ )
238
+ except Exception as e:
239
+ logger.debug(f"Failed to inject session recorder placeholder: {e}")
218
240
 
219
241
  except Exception as e:
220
- logger.error(f"Error during rrweb injection: {e}")
242
+ logger.error(f"Error during session recorder injection: {e}")
221
243
 
222
244
 
223
245
  @observe(name="playwright.page", ignore_input=True, ignore_output=True)
224
- def handle_navigation_sync(
225
- page: SyncPage, session_id: str, trace_id: str, client: LaminarClient
246
+ def start_recording_events_sync(
247
+ page: SyncPage, session_id: str, client: AsyncLaminarClient
226
248
  ):
227
- trace.get_current_span().set_attribute("lmnr.internal.has_browser_session", True)
228
- original_bring_to_front = page.bring_to_front
229
-
230
- def bring_to_front():
231
- original_bring_to_front()
232
- page.evaluate(
233
- """() => {
234
- if (window.lmnrRrweb) {
235
- try {
236
- window.lmnrRrweb.record.takeFullSnapshot();
237
- } catch (e) {
238
- console.error("Error taking full snapshot:", e);
239
- }
240
- }
241
- }"""
242
- )
243
249
 
244
- page.bring_to_front = bring_to_front
250
+ ctx = get_current_context()
251
+ span = trace.get_current_span(ctx)
252
+ trace_id = format(span.get_span_context().trace_id, "032x")
253
+ span.set_attribute("lmnr.internal.has_browser_session", True)
254
+
255
+ # Get the background loop for async sends
256
+ background_loop = get_background_loop()
245
257
 
246
- def on_load():
258
+ # Buffer for reassembling chunks
259
+ chunk_buffers = {}
260
+
261
+ # Create the async event handler (shared implementation)
262
+ send_events_from_browser = create_send_events_handler(
263
+ chunk_buffers, session_id, trace_id, client, background_loop
264
+ )
265
+
266
+ def submit_event(chunk):
267
+ """Sync wrapper that submits async handler to background loop."""
247
268
  try:
248
- inject_rrweb_sync(page)
269
+ # Submit async handler to background loop
270
+ asyncio.run_coroutine_threadsafe(
271
+ send_events_from_browser(chunk),
272
+ background_loop,
273
+ )
249
274
  except Exception as e:
250
- logger.error(f"Error in on_load handler: {e}")
275
+ logger.debug(f"Error submitting event: {e}")
251
276
 
252
- def collection_loop():
253
- while not page.is_closed(): # Stop when page closes
254
- send_events_sync(page, session_id, trace_id, client)
255
- time.sleep(2)
277
+ try:
278
+ page.expose_function("lmnrSendEvents", submit_event)
279
+ except Exception as e:
280
+ logger.debug(f"Could not expose function: {e}")
256
281
 
257
- thread = threading.Thread(target=collection_loop, daemon=True)
258
- thread.start()
282
+ inject_session_recorder_sync(page)
259
283
 
260
- def on_close():
284
+ def on_load(p):
261
285
  try:
262
- send_events_sync(page, session_id, trace_id, client)
263
- thread.join()
264
- except Exception:
265
- pass
286
+ if not p.is_closed():
287
+ inject_session_recorder_sync(p)
288
+ except Exception as e:
289
+ logger.debug(f"Error in on_load handler: {e}")
266
290
 
267
- page.on("load", on_load)
268
- page.on("close", on_close)
269
- inject_rrweb_sync(page)
291
+ page.on("domcontentloaded", on_load)
270
292
 
271
293
 
272
294
  @observe(name="playwright.page", ignore_input=True, ignore_output=True)
273
- async def handle_navigation_async(
274
- page: Page, session_id: str, trace_id: str, client: AsyncLaminarClient
295
+ async def start_recording_events_async(
296
+ page: Page, session_id: str, client: AsyncLaminarClient
275
297
  ):
276
- trace.get_current_span().set_attribute("lmnr.internal.has_browser_session", True)
298
+ ctx = get_current_context()
299
+ span = trace.get_current_span(ctx)
300
+ trace_id = format(span.get_span_context().trace_id, "032x")
301
+ span.set_attribute("lmnr.internal.has_browser_session", True)
277
302
 
278
- async def collection_loop():
279
- try:
280
- while not page.is_closed(): # Stop when page closes
281
- await send_events_async(page, session_id, trace_id, client)
282
- await asyncio.sleep(2)
283
- logger.info("Event collection stopped")
284
- except Exception as e:
285
- logger.error(f"Event collection stopped: {e}")
303
+ # Get the background loop for async sends (independent of Playwright's loop)
304
+ background_loop = get_background_loop()
305
+
306
+ # Buffer for reassembling chunks
307
+ chunk_buffers = {}
308
+
309
+ # Create the async event handler (shared implementation)
310
+ send_events_from_browser = create_send_events_handler(
311
+ chunk_buffers, session_id, trace_id, client, background_loop
312
+ )
313
+
314
+ try:
315
+ await page.expose_function("lmnrSendEvents", send_events_from_browser)
316
+ except Exception as e:
317
+ logger.debug(f"Could not expose function: {e}")
286
318
 
287
- # Create and store task
288
- task = asyncio.create_task(collection_loop())
319
+ await inject_session_recorder_async(page)
289
320
 
290
- async def on_load():
321
+ async def on_load(p):
291
322
  try:
292
- await inject_rrweb_async(page)
323
+ # Check if page is closed before attempting to inject
324
+ if not p.is_closed():
325
+ await inject_session_recorder_async(p)
293
326
  except Exception as e:
294
- logger.error(f"Error in on_load handler: {e}")
327
+ logger.debug(f"Error in on_load handler: {e}")
295
328
 
296
- async def on_close():
297
- try:
298
- task.cancel()
299
- await send_events_async(page, session_id, trace_id, client)
300
- except Exception:
301
- pass
302
-
303
- page.on("load", lambda: asyncio.create_task(on_load()))
304
- page.on("close", lambda: asyncio.create_task(on_close()))
305
-
306
- original_bring_to_front = page.bring_to_front
307
-
308
- async def bring_to_front():
309
- await original_bring_to_front()
310
-
311
- await page.evaluate(
312
- """() => {
313
- if (window.lmnrRrweb) {
314
- try {
315
- window.lmnrRrweb.record.takeFullSnapshot();
316
- } catch (e) {
317
- console.error("Error taking full snapshot:", e);
318
- }
319
- }
320
- }"""
321
- )
329
+ page.on("domcontentloaded", on_load)
322
330
 
323
- page.bring_to_front = bring_to_front
324
- await inject_rrweb_async(page)
331
+
332
+ def take_full_snapshot(page: Page):
333
+ return page.evaluate(
334
+ """() => {
335
+ if (window.lmnrRrweb) {
336
+ try {
337
+ window.lmnrRrweb.record.takeFullSnapshot();
338
+ return true;
339
+ } catch (e) {
340
+ console.error("Error taking full snapshot:", e);
341
+ return false;
342
+ }
343
+ }
344
+ return false;
345
+ }"""
346
+ )
347
+
348
+
349
+ async def take_full_snapshot_async(page: Page):
350
+ return await page.evaluate(
351
+ """() => {
352
+ if (window.lmnrRrweb) {
353
+ try {
354
+ window.lmnrRrweb.record.takeFullSnapshot();
355
+ return true;
356
+ } catch (e) {
357
+ console.error("Error taking full snapshot:", e);
358
+ return false;
359
+ }
360
+ }
361
+ return false;
362
+ }"""
363
+ )