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.
- lmnr/__init__.py +32 -11
- lmnr/cli/__init__.py +270 -0
- lmnr/cli/datasets.py +371 -0
- lmnr/cli/evals.py +111 -0
- lmnr/cli/rules.py +42 -0
- lmnr/opentelemetry_lib/__init__.py +70 -0
- lmnr/opentelemetry_lib/decorators/__init__.py +337 -0
- lmnr/opentelemetry_lib/litellm/__init__.py +685 -0
- lmnr/opentelemetry_lib/litellm/utils.py +100 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/__init__.py +849 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/config.py +13 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_emitter.py +211 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_models.py +41 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/span_utils.py +401 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/streaming.py +425 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/utils.py +332 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/version.py +1 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/__init__.py +451 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/proxy.py +144 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_agent/__init__.py +100 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/__init__.py +476 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/utils.py +12 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/__init__.py +599 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/config.py +9 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +26 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/utils.py +330 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/__init__.py +488 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/config.py +8 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_emitter.py +143 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_models.py +41 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/span_utils.py +229 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/utils.py +92 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/version.py +1 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/__init__.py +381 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/utils.py +36 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/__init__.py +121 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/utils.py +60 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +472 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1185 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +305 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +312 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +197 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +368 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +325 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +135 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +786 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openhands_ai/__init__.py +388 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/opentelemetry/__init__.py +69 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/skyvern/__init__.py +191 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/threading/__init__.py +197 -0
- lmnr/opentelemetry_lib/tracing/__init__.py +263 -0
- lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +516 -0
- lmnr/{openllmetry_sdk → opentelemetry_lib}/tracing/attributes.py +21 -8
- lmnr/opentelemetry_lib/tracing/context.py +200 -0
- lmnr/opentelemetry_lib/tracing/exporter.py +153 -0
- lmnr/opentelemetry_lib/tracing/instruments.py +140 -0
- lmnr/opentelemetry_lib/tracing/processor.py +193 -0
- lmnr/opentelemetry_lib/tracing/span.py +398 -0
- lmnr/opentelemetry_lib/tracing/tracer.py +57 -0
- lmnr/opentelemetry_lib/tracing/utils.py +62 -0
- lmnr/opentelemetry_lib/utils/package_check.py +18 -0
- lmnr/opentelemetry_lib/utils/wrappers.py +11 -0
- lmnr/sdk/browser/__init__.py +0 -0
- lmnr/sdk/browser/background_send_events.py +158 -0
- lmnr/sdk/browser/browser_use_cdp_otel.py +100 -0
- lmnr/sdk/browser/browser_use_otel.py +142 -0
- lmnr/sdk/browser/bubus_otel.py +71 -0
- lmnr/sdk/browser/cdp_utils.py +518 -0
- lmnr/sdk/browser/inject_script.js +514 -0
- lmnr/sdk/browser/patchright_otel.py +151 -0
- lmnr/sdk/browser/playwright_otel.py +322 -0
- lmnr/sdk/browser/pw_utils.py +363 -0
- lmnr/sdk/browser/recorder/record.umd.min.cjs +84 -0
- lmnr/sdk/browser/utils.py +70 -0
- lmnr/sdk/client/asynchronous/async_client.py +180 -0
- lmnr/sdk/client/asynchronous/resources/__init__.py +6 -0
- lmnr/sdk/client/asynchronous/resources/base.py +32 -0
- lmnr/sdk/client/asynchronous/resources/browser_events.py +41 -0
- lmnr/sdk/client/asynchronous/resources/datasets.py +131 -0
- lmnr/sdk/client/asynchronous/resources/evals.py +266 -0
- lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
- lmnr/sdk/client/asynchronous/resources/tags.py +83 -0
- lmnr/sdk/client/synchronous/resources/__init__.py +6 -0
- lmnr/sdk/client/synchronous/resources/base.py +32 -0
- lmnr/sdk/client/synchronous/resources/browser_events.py +40 -0
- lmnr/sdk/client/synchronous/resources/datasets.py +131 -0
- lmnr/sdk/client/synchronous/resources/evals.py +263 -0
- lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
- lmnr/sdk/client/synchronous/resources/tags.py +83 -0
- lmnr/sdk/client/synchronous/sync_client.py +191 -0
- lmnr/sdk/datasets/__init__.py +94 -0
- lmnr/sdk/datasets/file_utils.py +91 -0
- lmnr/sdk/decorators.py +163 -26
- lmnr/sdk/eval_control.py +3 -2
- lmnr/sdk/evaluations.py +403 -191
- lmnr/sdk/laminar.py +1080 -549
- lmnr/sdk/log.py +7 -2
- lmnr/sdk/types.py +246 -134
- lmnr/sdk/utils.py +151 -7
- lmnr/version.py +46 -0
- {lmnr-0.4.53.dev0.dist-info → lmnr-0.7.26.dist-info}/METADATA +152 -106
- lmnr-0.7.26.dist-info/RECORD +116 -0
- lmnr-0.7.26.dist-info/WHEEL +4 -0
- lmnr-0.7.26.dist-info/entry_points.txt +3 -0
- lmnr/cli.py +0 -101
- lmnr/openllmetry_sdk/.python-version +0 -1
- lmnr/openllmetry_sdk/__init__.py +0 -72
- lmnr/openllmetry_sdk/config/__init__.py +0 -9
- lmnr/openllmetry_sdk/decorators/base.py +0 -185
- lmnr/openllmetry_sdk/instruments.py +0 -38
- lmnr/openllmetry_sdk/tracing/__init__.py +0 -1
- lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -24
- lmnr/openllmetry_sdk/tracing/context_manager.py +0 -13
- lmnr/openllmetry_sdk/tracing/tracing.py +0 -884
- lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -61
- lmnr/openllmetry_sdk/utils/package_check.py +0 -7
- lmnr/openllmetry_sdk/version.py +0 -1
- lmnr/sdk/datasets.py +0 -55
- lmnr-0.4.53.dev0.dist-info/LICENSE +0 -75
- lmnr-0.4.53.dev0.dist-info/RECORD +0 -33
- lmnr-0.4.53.dev0.dist-info/WHEEL +0 -4
- lmnr-0.4.53.dev0.dist-info/entry_points.txt +0 -3
- /lmnr/{openllmetry_sdk → opentelemetry_lib}/.flake8 +0 -0
- /lmnr/{openllmetry_sdk → opentelemetry_lib}/utils/__init__.py +0 -0
- /lmnr/{openllmetry_sdk → opentelemetry_lib}/utils/json_encoder.py +0 -0
- /lmnr/{openllmetry_sdk/decorators/__init__.py → py.typed} +0 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import orjson
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from weakref import WeakKeyDictionary
|
|
8
|
+
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
|
|
11
|
+
from lmnr.sdk.decorators import observe
|
|
12
|
+
from lmnr.sdk.browser.utils import retry_async
|
|
13
|
+
from lmnr.sdk.browser.background_send_events import (
|
|
14
|
+
get_background_loop,
|
|
15
|
+
track_async_send,
|
|
16
|
+
)
|
|
17
|
+
from lmnr.sdk.client.asynchronous.async_client import AsyncLaminarClient
|
|
18
|
+
from lmnr.opentelemetry_lib.tracing.context import get_current_context
|
|
19
|
+
from lmnr.opentelemetry_lib.tracing import TracerWrapper
|
|
20
|
+
from lmnr.sdk.log import get_default_logger
|
|
21
|
+
from lmnr.sdk.types import MaskInputOptions
|
|
22
|
+
|
|
23
|
+
logger = get_default_logger(__name__)
|
|
24
|
+
|
|
25
|
+
OLD_BUFFER_TIMEOUT = 60
|
|
26
|
+
CDP_OPERATION_TIMEOUT_SECONDS = 10
|
|
27
|
+
|
|
28
|
+
# CDP ContextId is int
|
|
29
|
+
frame_to_isolated_context_id: dict[str, int] = {}
|
|
30
|
+
|
|
31
|
+
# Store locks per event loop to avoid pytest-asyncio issues
|
|
32
|
+
_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakKeyDictionary()
|
|
33
|
+
_locks_lock = threading.Lock()
|
|
34
|
+
_fallback_lock = asyncio.Lock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_lock() -> asyncio.Lock:
|
|
38
|
+
"""Get or create a lock for the current event loop.
|
|
39
|
+
|
|
40
|
+
This ensures each event loop gets its own lock instance, avoiding
|
|
41
|
+
cross-event-loop binding issues that occur in pytest-asyncio when
|
|
42
|
+
tests run in different event loops.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
# No event loop running
|
|
48
|
+
logger.warning("No event loop running, using fallback lock")
|
|
49
|
+
return _fallback_lock
|
|
50
|
+
|
|
51
|
+
with _locks_lock:
|
|
52
|
+
if loop not in _locks:
|
|
53
|
+
_locks[loop] = asyncio.Lock()
|
|
54
|
+
return _locks[loop]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
58
|
+
with open(os.path.join(current_dir, "recorder", "record.umd.min.cjs"), "r") as f:
|
|
59
|
+
RRWEB_CONTENT = f"() => {{ {f.read()} }}"
|
|
60
|
+
|
|
61
|
+
with open(os.path.join(current_dir, "inject_script.js"), "r") as f:
|
|
62
|
+
INJECT_SCRIPT_CONTENT = f.read()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def should_skip_page(cdp_session):
|
|
66
|
+
"""Checks if the page url is an error page or an empty page.
|
|
67
|
+
This function returns True in case of any error in our code, because
|
|
68
|
+
it is safer to not record events than to try to inject the recorder
|
|
69
|
+
into something that is already broken.
|
|
70
|
+
"""
|
|
71
|
+
cdp_client = cdp_session.cdp_client
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
# Get the current page URL
|
|
75
|
+
result = await asyncio.wait_for(
|
|
76
|
+
cdp_client.send.Runtime.evaluate(
|
|
77
|
+
{
|
|
78
|
+
"expression": "window.location.href",
|
|
79
|
+
"returnByValue": True,
|
|
80
|
+
},
|
|
81
|
+
session_id=cdp_session.session_id,
|
|
82
|
+
),
|
|
83
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
url = result.get("result", {}).get("value", "")
|
|
87
|
+
|
|
88
|
+
# Comprehensive list of browser error URLs
|
|
89
|
+
error_url_patterns = [
|
|
90
|
+
"about:blank",
|
|
91
|
+
# Chrome error pages
|
|
92
|
+
"chrome-error://",
|
|
93
|
+
"chrome://network-error/",
|
|
94
|
+
"chrome://network-errors/",
|
|
95
|
+
# Chrome crash and debugging pages
|
|
96
|
+
"chrome://crash/",
|
|
97
|
+
"chrome://crashdump/",
|
|
98
|
+
"chrome://kill/",
|
|
99
|
+
"chrome://hang/",
|
|
100
|
+
"chrome://shorthang/",
|
|
101
|
+
"chrome://gpuclean/",
|
|
102
|
+
"chrome://gpucrash/",
|
|
103
|
+
"chrome://gpuhang/",
|
|
104
|
+
"chrome://memory-exhaust/",
|
|
105
|
+
"chrome://memory-pressure-critical/",
|
|
106
|
+
"chrome://memory-pressure-moderate/",
|
|
107
|
+
"chrome://inducebrowsercrashforrealz/",
|
|
108
|
+
"chrome://inducebrowserdcheckforrealz/",
|
|
109
|
+
"chrome://inducebrowserheapcorruption/",
|
|
110
|
+
"chrome://heapcorruptioncrash/",
|
|
111
|
+
"chrome://badcastcrash/",
|
|
112
|
+
"chrome://ppapiflashcrash/",
|
|
113
|
+
"chrome://ppapiflashhang/",
|
|
114
|
+
"chrome://quit/",
|
|
115
|
+
"chrome://restart/",
|
|
116
|
+
# Firefox error pages
|
|
117
|
+
"about:neterror",
|
|
118
|
+
"about:certerror",
|
|
119
|
+
"about:blocked",
|
|
120
|
+
# Firefox crash and debugging pages
|
|
121
|
+
"about:crashcontent",
|
|
122
|
+
"about:crashparent",
|
|
123
|
+
"about:crashes",
|
|
124
|
+
"about:tabcrashed",
|
|
125
|
+
# Edge error pages (similar to Chrome)
|
|
126
|
+
"edge-error://",
|
|
127
|
+
"edge://crash/",
|
|
128
|
+
"edge://kill/",
|
|
129
|
+
"edge://hang/",
|
|
130
|
+
# Safari/WebKit error indicators (data URLs with error content)
|
|
131
|
+
"webkit-error://",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
# Check if current URL matches any error pattern
|
|
135
|
+
if any(url.startswith(pattern) for pattern in error_url_patterns):
|
|
136
|
+
logger.debug(f"Detected browser error page from URL: {url}")
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# Additional check for data URLs that might contain error pages
|
|
140
|
+
if url.startswith("data:") and any(
|
|
141
|
+
error_term in url.lower()
|
|
142
|
+
for error_term in ["error", "crash", "failed", "unavailable", "not found"]
|
|
143
|
+
):
|
|
144
|
+
logger.debug(f"Detected error page from data URL: {url[:100]}...")
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
except asyncio.TimeoutError:
|
|
150
|
+
logger.debug("Timeout error when checking if error page")
|
|
151
|
+
return True
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.debug(f"Error during checking if error page: {e}")
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_mask_input_setting() -> MaskInputOptions:
|
|
158
|
+
"""Get the mask_input setting from session recording configuration."""
|
|
159
|
+
try:
|
|
160
|
+
config = TracerWrapper.get_session_recording_options()
|
|
161
|
+
return config.get(
|
|
162
|
+
"mask_input_options",
|
|
163
|
+
MaskInputOptions(
|
|
164
|
+
textarea=False,
|
|
165
|
+
text=False,
|
|
166
|
+
number=False,
|
|
167
|
+
select=False,
|
|
168
|
+
email=False,
|
|
169
|
+
tel=False,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
except (AttributeError, Exception):
|
|
173
|
+
# Fallback to default configuration if TracerWrapper is not initialized
|
|
174
|
+
return MaskInputOptions(
|
|
175
|
+
textarea=False,
|
|
176
|
+
text=False,
|
|
177
|
+
number=False,
|
|
178
|
+
select=False,
|
|
179
|
+
email=False,
|
|
180
|
+
tel=False,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
185
|
+
async def get_isolated_context_id(cdp_session) -> int | None:
|
|
186
|
+
async with get_lock():
|
|
187
|
+
tree = {}
|
|
188
|
+
try:
|
|
189
|
+
tree = await asyncio.wait_for(
|
|
190
|
+
cdp_session.cdp_client.send.Page.getFrameTree(
|
|
191
|
+
session_id=cdp_session.session_id
|
|
192
|
+
),
|
|
193
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
194
|
+
)
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
logger.debug("Timeout error when getting frame tree")
|
|
197
|
+
return None
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.debug(f"Failed to get frame tree: {e}")
|
|
200
|
+
return None
|
|
201
|
+
frame = tree.get("frameTree", {}).get("frame", {})
|
|
202
|
+
frame_id = frame.get("id")
|
|
203
|
+
loader_id = frame.get("loaderId")
|
|
204
|
+
|
|
205
|
+
if frame_id is None or loader_id is None:
|
|
206
|
+
logger.debug("Failed to get frame id or loader id")
|
|
207
|
+
return None
|
|
208
|
+
key = f"{frame_id}_{loader_id}"
|
|
209
|
+
|
|
210
|
+
if key in frame_to_isolated_context_id:
|
|
211
|
+
return frame_to_isolated_context_id[key]
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
result = await asyncio.wait_for(
|
|
215
|
+
cdp_session.cdp_client.send.Page.createIsolatedWorld(
|
|
216
|
+
{
|
|
217
|
+
"frameId": frame_id,
|
|
218
|
+
"worldName": "laminar-isolated-context",
|
|
219
|
+
},
|
|
220
|
+
session_id=cdp_session.session_id,
|
|
221
|
+
),
|
|
222
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
223
|
+
)
|
|
224
|
+
except asyncio.TimeoutError:
|
|
225
|
+
logger.debug("Timeout error when getting isolated context id")
|
|
226
|
+
return None
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.debug(f"Failed to get isolated context id: {e}")
|
|
229
|
+
return None
|
|
230
|
+
isolated_context_id = result["executionContextId"]
|
|
231
|
+
frame_to_isolated_context_id[key] = isolated_context_id
|
|
232
|
+
return isolated_context_id
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
236
|
+
async def inject_session_recorder(cdp_session) -> int | None:
|
|
237
|
+
"""Injects the session recorder base as well as the recorder itself.
|
|
238
|
+
Returns the isolated context id if successful.
|
|
239
|
+
"""
|
|
240
|
+
isolated_context_id = None
|
|
241
|
+
cdp_client = cdp_session.cdp_client
|
|
242
|
+
try:
|
|
243
|
+
should_skip = True
|
|
244
|
+
try:
|
|
245
|
+
should_skip = await should_skip_page(cdp_session)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.debug(f"Failed to check if error page: {e}")
|
|
248
|
+
|
|
249
|
+
if should_skip:
|
|
250
|
+
logger.debug("Empty page detected, skipping session recorder injection")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
isolated_context_id = await get_isolated_context_id(cdp_session)
|
|
254
|
+
try:
|
|
255
|
+
is_loaded = await is_recorder_present(cdp_session, isolated_context_id)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.debug(f"Failed to check if session recorder is loaded: {e}")
|
|
258
|
+
is_loaded = False
|
|
259
|
+
|
|
260
|
+
if is_loaded:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if isolated_context_id is None:
|
|
264
|
+
logger.debug("Failed to get isolated context id")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
async def load_session_recorder():
|
|
268
|
+
try:
|
|
269
|
+
await asyncio.wait_for(
|
|
270
|
+
cdp_client.send.Runtime.evaluate(
|
|
271
|
+
{
|
|
272
|
+
"expression": f"({RRWEB_CONTENT})()",
|
|
273
|
+
"contextId": isolated_context_id,
|
|
274
|
+
},
|
|
275
|
+
session_id=cdp_session.session_id,
|
|
276
|
+
),
|
|
277
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
278
|
+
)
|
|
279
|
+
return True
|
|
280
|
+
except asyncio.TimeoutError:
|
|
281
|
+
logger.debug("Timeout error when loading session recorder base")
|
|
282
|
+
return False
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.debug(f"Failed to load session recorder base: {e}")
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
if not await retry_async(
|
|
288
|
+
load_session_recorder,
|
|
289
|
+
retries=3,
|
|
290
|
+
delay=1,
|
|
291
|
+
error_message="Failed to load session recorder",
|
|
292
|
+
):
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
await asyncio.wait_for(
|
|
297
|
+
cdp_client.send.Runtime.evaluate(
|
|
298
|
+
{
|
|
299
|
+
"expression": f"({INJECT_SCRIPT_CONTENT})({orjson.dumps(get_mask_input_setting()).decode('utf-8')}, true)",
|
|
300
|
+
"contextId": isolated_context_id,
|
|
301
|
+
},
|
|
302
|
+
session_id=cdp_session.session_id,
|
|
303
|
+
),
|
|
304
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
305
|
+
)
|
|
306
|
+
return isolated_context_id
|
|
307
|
+
except asyncio.TimeoutError:
|
|
308
|
+
logger.debug("Timeout error when injecting session recorder")
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.debug(f"Failed to inject recorder: {e}")
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.debug(f"Error during session recorder injection: {e}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
317
|
+
@observe(name="cdp_use.session", ignore_input=True, ignore_output=True)
|
|
318
|
+
async def start_recording_events(
|
|
319
|
+
cdp_session,
|
|
320
|
+
lmnr_session_id: str,
|
|
321
|
+
client: AsyncLaminarClient,
|
|
322
|
+
):
|
|
323
|
+
cdp_client = cdp_session.cdp_client
|
|
324
|
+
|
|
325
|
+
ctx = get_current_context()
|
|
326
|
+
span = trace.get_current_span(ctx)
|
|
327
|
+
trace_id = format(span.get_span_context().trace_id, "032x")
|
|
328
|
+
span.set_attribute("lmnr.internal.has_browser_session", True)
|
|
329
|
+
|
|
330
|
+
isolated_context_id = await inject_session_recorder(cdp_session)
|
|
331
|
+
if isolated_context_id is None:
|
|
332
|
+
logger.debug("Failed to inject session recorder, not registering bindings")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
# Get the background loop for async sends (independent of CDP's loop)
|
|
336
|
+
background_loop = get_background_loop()
|
|
337
|
+
|
|
338
|
+
# Buffer for reassembling chunks
|
|
339
|
+
chunk_buffers = {}
|
|
340
|
+
|
|
341
|
+
async def send_events_from_browser(chunk: dict):
|
|
342
|
+
try:
|
|
343
|
+
# Handle chunked data
|
|
344
|
+
batch_id = chunk["batchId"]
|
|
345
|
+
chunk_index = chunk["chunkIndex"]
|
|
346
|
+
total_chunks = chunk["totalChunks"]
|
|
347
|
+
data = chunk["data"]
|
|
348
|
+
|
|
349
|
+
# Initialize buffer for this batch if needed
|
|
350
|
+
if batch_id not in chunk_buffers:
|
|
351
|
+
chunk_buffers[batch_id] = {
|
|
352
|
+
"chunks": {},
|
|
353
|
+
"total": total_chunks,
|
|
354
|
+
"timestamp": time.time(),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# Store chunk
|
|
358
|
+
chunk_buffers[batch_id]["chunks"][chunk_index] = data
|
|
359
|
+
|
|
360
|
+
# Check if we have all chunks
|
|
361
|
+
if len(chunk_buffers[batch_id]["chunks"]) == total_chunks:
|
|
362
|
+
# Reassemble the full message
|
|
363
|
+
full_data = ""
|
|
364
|
+
for i in range(total_chunks):
|
|
365
|
+
full_data += chunk_buffers[batch_id]["chunks"][i]
|
|
366
|
+
|
|
367
|
+
# Parse the JSON
|
|
368
|
+
events = orjson.loads(full_data)
|
|
369
|
+
|
|
370
|
+
# Send to server in background loop (independent of CDP's loop)
|
|
371
|
+
if events and len(events) > 0:
|
|
372
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
373
|
+
client._browser_events.send(lmnr_session_id, trace_id, events),
|
|
374
|
+
background_loop,
|
|
375
|
+
)
|
|
376
|
+
track_async_send(future)
|
|
377
|
+
|
|
378
|
+
# Clean up buffer
|
|
379
|
+
del chunk_buffers[batch_id]
|
|
380
|
+
|
|
381
|
+
# Clean up old incomplete buffers
|
|
382
|
+
current_time = time.time()
|
|
383
|
+
to_delete = []
|
|
384
|
+
for bid, buffer in chunk_buffers.items():
|
|
385
|
+
if current_time - buffer["timestamp"] > OLD_BUFFER_TIMEOUT:
|
|
386
|
+
to_delete.append(bid)
|
|
387
|
+
for bid in to_delete:
|
|
388
|
+
logger.debug(f"Cleaning up incomplete chunk buffer: {bid}")
|
|
389
|
+
del chunk_buffers[bid]
|
|
390
|
+
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.debug(f"Could not send events: {e}")
|
|
393
|
+
|
|
394
|
+
# cdp_use.cdp.runtime.events.BindingCalledEvent
|
|
395
|
+
async def send_events_callback(event, cdp_session_id: str | None = None):
|
|
396
|
+
if event["name"] != "lmnrSendEvents":
|
|
397
|
+
return
|
|
398
|
+
if event["executionContextId"] != isolated_context_id:
|
|
399
|
+
return
|
|
400
|
+
asyncio.create_task(send_events_from_browser(orjson.loads(event["payload"])))
|
|
401
|
+
|
|
402
|
+
await cdp_client.send.Runtime.addBinding(
|
|
403
|
+
{
|
|
404
|
+
"name": "lmnrSendEvents",
|
|
405
|
+
"executionContextId": isolated_context_id,
|
|
406
|
+
},
|
|
407
|
+
session_id=cdp_session.session_id,
|
|
408
|
+
)
|
|
409
|
+
cdp_client.register.Runtime.bindingCalled(send_events_callback)
|
|
410
|
+
|
|
411
|
+
await enable_target_discovery(cdp_session)
|
|
412
|
+
register_on_target_created(cdp_session, lmnr_session_id, client)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
416
|
+
async def enable_target_discovery(cdp_session):
|
|
417
|
+
cdp_client = cdp_session.cdp_client
|
|
418
|
+
await cdp_client.send.Target.setDiscoverTargets(
|
|
419
|
+
{
|
|
420
|
+
"discover": True,
|
|
421
|
+
},
|
|
422
|
+
session_id=cdp_session.session_id,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
427
|
+
def register_on_target_created(
|
|
428
|
+
cdp_session, lmnr_session_id: str, client: AsyncLaminarClient
|
|
429
|
+
):
|
|
430
|
+
# cdp_use.cdp.target.events.TargetCreatedEvent
|
|
431
|
+
def on_target_created(event, cdp_session_id: str | None = None):
|
|
432
|
+
target_info = event["targetInfo"]
|
|
433
|
+
if target_info["type"] == "page":
|
|
434
|
+
asyncio.create_task(inject_session_recorder(cdp_session=cdp_session))
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
cdp_session.cdp_client.register.Target.targetCreated(on_target_created)
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.debug(f"Failed to register on target created: {e}")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# browser_use.browser.session.CDPSession (browser-use >= 0.6.0)
|
|
443
|
+
async def is_recorder_present(
|
|
444
|
+
cdp_session, isolated_context_id: int | None = None
|
|
445
|
+
) -> bool:
|
|
446
|
+
# This function returns True on any error, because it is safer to not record
|
|
447
|
+
# events than to try to inject the recorder into a broken context.
|
|
448
|
+
cdp_client = cdp_session.cdp_client
|
|
449
|
+
if isolated_context_id is None:
|
|
450
|
+
isolated_context_id = await get_isolated_context_id(cdp_session)
|
|
451
|
+
if isolated_context_id is None:
|
|
452
|
+
logger.debug("Failed to get isolated context id")
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
result = await asyncio.wait_for(
|
|
457
|
+
cdp_client.send.Runtime.evaluate(
|
|
458
|
+
{
|
|
459
|
+
"expression": "typeof window.lmnrRrweb !== 'undefined'",
|
|
460
|
+
"contextId": isolated_context_id,
|
|
461
|
+
},
|
|
462
|
+
session_id=cdp_session.session_id,
|
|
463
|
+
),
|
|
464
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
465
|
+
)
|
|
466
|
+
if result and "result" in result and "value" in result["result"]:
|
|
467
|
+
return result["result"]["value"]
|
|
468
|
+
return False
|
|
469
|
+
except asyncio.TimeoutError:
|
|
470
|
+
logger.debug("Timeout error when checking if session recorder is present")
|
|
471
|
+
return True
|
|
472
|
+
except Exception:
|
|
473
|
+
logger.debug("Exception when checking if session recorder is present")
|
|
474
|
+
return True
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def take_full_snapshot(cdp_session):
|
|
478
|
+
cdp_client = cdp_session.cdp_client
|
|
479
|
+
isolated_context_id = await get_isolated_context_id(cdp_session)
|
|
480
|
+
if isolated_context_id is None:
|
|
481
|
+
logger.debug("Failed to get isolated context id")
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
if await should_skip_page(cdp_session):
|
|
485
|
+
logger.debug("Skipping full snapshot")
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
result = await asyncio.wait_for(
|
|
490
|
+
cdp_client.send.Runtime.evaluate(
|
|
491
|
+
{
|
|
492
|
+
"expression": """(() => {
|
|
493
|
+
if (window.lmnrRrweb) {
|
|
494
|
+
try {
|
|
495
|
+
window.lmnrRrweb.record.takeFullSnapshot();
|
|
496
|
+
return true;
|
|
497
|
+
} catch (e) {
|
|
498
|
+
console.error("Error taking full snapshot:", e);
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
})()""",
|
|
504
|
+
"contextId": isolated_context_id,
|
|
505
|
+
},
|
|
506
|
+
session_id=cdp_session.session_id,
|
|
507
|
+
),
|
|
508
|
+
timeout=CDP_OPERATION_TIMEOUT_SECONDS,
|
|
509
|
+
)
|
|
510
|
+
except asyncio.TimeoutError:
|
|
511
|
+
logger.debug("Timeout error when taking full snapshot")
|
|
512
|
+
return False
|
|
513
|
+
except Exception as e:
|
|
514
|
+
logger.debug(f"Error when taking full snapshot: {e}")
|
|
515
|
+
return False
|
|
516
|
+
if result and "result" in result and "value" in result["result"]:
|
|
517
|
+
return result["result"]["value"]
|
|
518
|
+
return False
|