lmnr 0.4.64__py3-none-any.whl → 0.4.66__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/openllmetry_sdk/instruments.py +1 -0
- lmnr/openllmetry_sdk/tracing/tracing.py +50 -15
- lmnr/sdk/browser/__init__.py +0 -9
- lmnr/sdk/browser/browser_use_otel.py +117 -0
- lmnr/sdk/browser/playwright_otel.py +310 -0
- lmnr/sdk/browser/utils.py +104 -0
- lmnr/sdk/client.py +313 -0
- lmnr/sdk/datasets.py +2 -2
- lmnr/sdk/evaluations.py +32 -10
- lmnr/sdk/laminar.py +72 -194
- lmnr/sdk/types.py +29 -4
- lmnr/version.py +1 -1
- {lmnr-0.4.64.dist-info → lmnr-0.4.66.dist-info}/METADATA +51 -51
- {lmnr-0.4.64.dist-info → lmnr-0.4.66.dist-info}/RECORD +17 -14
- lmnr/sdk/browser/playwright_patch.py +0 -377
- {lmnr-0.4.64.dist-info → lmnr-0.4.66.dist-info}/LICENSE +0 -0
- {lmnr-0.4.64.dist-info → lmnr-0.4.66.dist-info}/WHEEL +0 -0
- {lmnr-0.4.64.dist-info → lmnr-0.4.66.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,6 @@ import uuid
|
|
6
6
|
from contextvars import Context
|
7
7
|
from lmnr.sdk.log import VerboseColorfulFormatter
|
8
8
|
from lmnr.openllmetry_sdk.instruments import Instruments
|
9
|
-
from lmnr.sdk.browser import init_browser_tracing
|
10
9
|
from lmnr.openllmetry_sdk.tracing.attributes import (
|
11
10
|
ASSOCIATION_PROPERTIES,
|
12
11
|
SPAN_IDS_PATH,
|
@@ -28,7 +27,7 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
28
27
|
)
|
29
28
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import Compression
|
30
29
|
from opentelemetry.instrumentation.threading import ThreadingInstrumentor
|
31
|
-
from opentelemetry.context import get_value, attach, get_current, set_value
|
30
|
+
from opentelemetry.context import get_value, attach, get_current, set_value
|
32
31
|
from opentelemetry.propagate import set_global_textmap
|
33
32
|
from opentelemetry.propagators.textmap import TextMapPropagator
|
34
33
|
from opentelemetry.sdk.resources import Resource
|
@@ -38,7 +37,7 @@ from opentelemetry.sdk.trace.export import (
|
|
38
37
|
SimpleSpanProcessor,
|
39
38
|
BatchSpanProcessor,
|
40
39
|
)
|
41
|
-
from opentelemetry.
|
40
|
+
from opentelemetry.trace import get_tracer_provider, ProxyTracerProvider
|
42
41
|
|
43
42
|
from typing import Dict, Optional, Set
|
44
43
|
|
@@ -238,6 +237,10 @@ def set_association_properties(properties: dict) -> None:
|
|
238
237
|
_set_association_properties_attributes(span, properties)
|
239
238
|
|
240
239
|
|
240
|
+
def get_association_properties(context: Optional[Context] = None) -> dict:
|
241
|
+
return get_value("association_properties", context) or {}
|
242
|
+
|
243
|
+
|
241
244
|
def update_association_properties(
|
242
245
|
properties: dict,
|
243
246
|
set_on_current_span: bool = True,
|
@@ -296,18 +299,21 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor
|
|
296
299
|
# TODO: check if it's safer to use the default tracer provider obtained from
|
297
300
|
# get_tracer_provider()
|
298
301
|
def init_tracer_provider(resource: Resource) -> TracerProvider:
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
302
|
+
provider: TracerProvider = None
|
303
|
+
default_provider: TracerProvider = get_tracer_provider()
|
304
|
+
|
305
|
+
if isinstance(default_provider, ProxyTracerProvider):
|
306
|
+
provider = TracerProvider(resource=resource)
|
307
|
+
trace.set_tracer_provider(provider)
|
308
|
+
elif not hasattr(default_provider, "add_span_processor"):
|
309
|
+
module_logger.error(
|
310
|
+
"Cannot add span processor to the default provider since it doesn't support it"
|
311
|
+
)
|
312
|
+
return
|
313
|
+
else:
|
314
|
+
provider = default_provider
|
309
315
|
|
310
|
-
return
|
316
|
+
return provider
|
311
317
|
|
312
318
|
|
313
319
|
def init_instrumentations(
|
@@ -428,7 +434,10 @@ def init_instrumentations(
|
|
428
434
|
if init_weaviate_instrumentor():
|
429
435
|
instrument_set = True
|
430
436
|
elif instrument == Instruments.PLAYWRIGHT:
|
431
|
-
if
|
437
|
+
if init_playwright_instrumentor():
|
438
|
+
instrument_set = True
|
439
|
+
elif instrument == Instruments.BROWSER_USE:
|
440
|
+
if init_browser_use_instrumentor():
|
432
441
|
instrument_set = True
|
433
442
|
else:
|
434
443
|
module_logger.warning(
|
@@ -443,6 +452,32 @@ def init_instrumentations(
|
|
443
452
|
return instrument_set
|
444
453
|
|
445
454
|
|
455
|
+
def init_browser_use_instrumentor():
|
456
|
+
try:
|
457
|
+
if is_package_installed("browser-use"):
|
458
|
+
from lmnr.sdk.browser.browser_use_otel import BrowserUseInstrumentor
|
459
|
+
|
460
|
+
instrumentor = BrowserUseInstrumentor()
|
461
|
+
instrumentor.instrument()
|
462
|
+
return True
|
463
|
+
except Exception as e:
|
464
|
+
module_logger.error(f"Error initializing BrowserUse instrumentor: {e}")
|
465
|
+
return False
|
466
|
+
|
467
|
+
|
468
|
+
def init_playwright_instrumentor():
|
469
|
+
try:
|
470
|
+
if is_package_installed("playwright"):
|
471
|
+
from lmnr.sdk.browser.playwright_otel import PlaywrightInstrumentor
|
472
|
+
|
473
|
+
instrumentor = PlaywrightInstrumentor()
|
474
|
+
instrumentor.instrument()
|
475
|
+
return True
|
476
|
+
except Exception as e:
|
477
|
+
module_logger.error(f"Error initializing Playwright instrumentor: {e}")
|
478
|
+
return False
|
479
|
+
|
480
|
+
|
446
481
|
def init_openai_instrumentor(should_enrich_metrics: bool):
|
447
482
|
try:
|
448
483
|
if is_package_installed("openai") and is_package_installed(
|
lmnr/sdk/browser/__init__.py
CHANGED
@@ -1,9 +0,0 @@
|
|
1
|
-
from lmnr.openllmetry_sdk.utils.package_check import is_package_installed
|
2
|
-
|
3
|
-
|
4
|
-
def init_browser_tracing(http_url: str, project_api_key: str):
|
5
|
-
if is_package_installed("playwright"):
|
6
|
-
from .playwright_patch import init_playwright_tracing
|
7
|
-
|
8
|
-
init_playwright_tracing(http_url, project_api_key)
|
9
|
-
# Other browsers can be added here
|
@@ -0,0 +1,117 @@
|
|
1
|
+
from lmnr.openllmetry_sdk.decorators.base import json_dumps
|
2
|
+
from lmnr.sdk.browser.utils import _with_tracer_wrapper
|
3
|
+
from lmnr.sdk.utils import get_input_from_func_args
|
4
|
+
from lmnr.version import SDK_VERSION
|
5
|
+
|
6
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
7
|
+
from opentelemetry.instrumentation.utils import unwrap
|
8
|
+
from opentelemetry.trace import get_tracer, Tracer
|
9
|
+
from typing import Collection
|
10
|
+
from wrapt import wrap_function_wrapper
|
11
|
+
|
12
|
+
_instruments = ("browser-use >= 0.1.0",)
|
13
|
+
|
14
|
+
WRAPPED_METHODS = [
|
15
|
+
{
|
16
|
+
"package": "browser_use.agent.service",
|
17
|
+
"object": "Agent",
|
18
|
+
"method": "run",
|
19
|
+
"span_name": "agent.run",
|
20
|
+
"ignore_input": False,
|
21
|
+
"ignore_output": True,
|
22
|
+
"span_type": "DEFAULT",
|
23
|
+
},
|
24
|
+
{
|
25
|
+
"package": "browser_use.agent.service",
|
26
|
+
"object": "Agent",
|
27
|
+
"method": "step",
|
28
|
+
"span_name": "agent.step",
|
29
|
+
"ignore_input": True,
|
30
|
+
"ignore_output": True,
|
31
|
+
"span_type": "DEFAULT",
|
32
|
+
},
|
33
|
+
{
|
34
|
+
"package": "browser_use.controller.service",
|
35
|
+
"object": "Controller",
|
36
|
+
"method": "act",
|
37
|
+
"span_name": "controller.act",
|
38
|
+
"ignore_input": True,
|
39
|
+
"ignore_output": False,
|
40
|
+
"span_type": "DEFAULT",
|
41
|
+
},
|
42
|
+
{
|
43
|
+
"package": "browser_use.controller.registry.service",
|
44
|
+
"object": "Registry",
|
45
|
+
"method": "execute_action",
|
46
|
+
"ignore_input": False,
|
47
|
+
"ignore_output": False,
|
48
|
+
"span_type": "TOOL",
|
49
|
+
},
|
50
|
+
]
|
51
|
+
|
52
|
+
|
53
|
+
@_with_tracer_wrapper
|
54
|
+
async def _wrap(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs):
|
55
|
+
span_name = to_wrap.get("span_name")
|
56
|
+
attributes = {
|
57
|
+
"lmnr.span.type": to_wrap.get("span_type"),
|
58
|
+
}
|
59
|
+
if to_wrap.get("method") == "execute_action":
|
60
|
+
span_name = args[0] if len(args) > 0 else kwargs.get("action_name", "action")
|
61
|
+
attributes["lmnr.span.input"] = json_dumps(
|
62
|
+
{
|
63
|
+
"action": span_name,
|
64
|
+
"params": args[1] if len(args) > 1 else kwargs.get("params", {}),
|
65
|
+
}
|
66
|
+
)
|
67
|
+
else:
|
68
|
+
if not to_wrap.get("ignore_input"):
|
69
|
+
attributes["lmnr.span.input"] = json_dumps(
|
70
|
+
get_input_from_func_args(wrapped, True, args, kwargs)
|
71
|
+
)
|
72
|
+
with tracer.start_as_current_span(span_name, attributes=attributes) as span:
|
73
|
+
span.set_attributes(attributes)
|
74
|
+
result = await wrapped(*args, **kwargs)
|
75
|
+
if not to_wrap.get("ignore_output"):
|
76
|
+
span.set_attribute("lmnr.span.output", json_dumps(result))
|
77
|
+
return result
|
78
|
+
|
79
|
+
|
80
|
+
class BrowserUseInstrumentor(BaseInstrumentor):
|
81
|
+
def __init__(self):
|
82
|
+
super().__init__()
|
83
|
+
|
84
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
85
|
+
return _instruments
|
86
|
+
|
87
|
+
def _instrument(self, **kwargs):
|
88
|
+
tracer_provider = kwargs.get("tracer_provider")
|
89
|
+
tracer = get_tracer(__name__, SDK_VERSION, tracer_provider)
|
90
|
+
|
91
|
+
for wrapped_method in WRAPPED_METHODS:
|
92
|
+
wrap_package = wrapped_method.get("package")
|
93
|
+
wrap_object = wrapped_method.get("object")
|
94
|
+
wrap_method = wrapped_method.get("method")
|
95
|
+
|
96
|
+
try:
|
97
|
+
wrap_function_wrapper(
|
98
|
+
wrap_package,
|
99
|
+
f"{wrap_object}.{wrap_method}",
|
100
|
+
_wrap(
|
101
|
+
tracer,
|
102
|
+
wrapped_method,
|
103
|
+
),
|
104
|
+
)
|
105
|
+
except ModuleNotFoundError:
|
106
|
+
pass # that's ok, we're not instrumenting everything
|
107
|
+
|
108
|
+
def _uninstrument(self, **kwargs):
|
109
|
+
for wrapped_method in WRAPPED_METHODS:
|
110
|
+
wrap_package = wrapped_method.get("package")
|
111
|
+
wrap_object = wrapped_method.get("object")
|
112
|
+
wrap_method = wrapped_method.get("method")
|
113
|
+
|
114
|
+
unwrap(
|
115
|
+
f"{wrap_package}.{wrap_object}" if wrap_object else wrap_package,
|
116
|
+
wrap_method,
|
117
|
+
)
|
@@ -0,0 +1,310 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
import threading
|
5
|
+
import time
|
6
|
+
import uuid
|
7
|
+
|
8
|
+
from lmnr.sdk.browser.utils import (
|
9
|
+
INJECT_PLACEHOLDER,
|
10
|
+
_with_tracer_wrapper,
|
11
|
+
retry_sync,
|
12
|
+
retry_async,
|
13
|
+
)
|
14
|
+
from lmnr.sdk.client import LaminarClient
|
15
|
+
from lmnr.version import PYTHON_VERSION, SDK_VERSION
|
16
|
+
|
17
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
18
|
+
from opentelemetry.instrumentation.utils import unwrap
|
19
|
+
from opentelemetry.trace import get_tracer, Tracer, get_current_span
|
20
|
+
from typing import Collection
|
21
|
+
from wrapt import wrap_function_wrapper
|
22
|
+
|
23
|
+
try:
|
24
|
+
from playwright.async_api import Page
|
25
|
+
from playwright.sync_api import Page as SyncPage
|
26
|
+
except ImportError as e:
|
27
|
+
raise ImportError(
|
28
|
+
f"Attempted to import {__file__}, but it is designed "
|
29
|
+
"to patch Playwright, which is not installed. Use `pip install playwright` "
|
30
|
+
"to install Playwright or remove this import."
|
31
|
+
) from e
|
32
|
+
|
33
|
+
# all available versions at https://pypi.org/project/playwright/#history
|
34
|
+
_instruments = ("playwright >= 1.9.0",)
|
35
|
+
logger = logging.getLogger(__name__)
|
36
|
+
|
37
|
+
WRAPPED_METHODS = [
|
38
|
+
{
|
39
|
+
"package": "playwright.sync_api",
|
40
|
+
"object": "BrowserContext",
|
41
|
+
"method": "new_page",
|
42
|
+
}
|
43
|
+
]
|
44
|
+
|
45
|
+
WRAPPED_METHODS_ASYNC = [
|
46
|
+
{
|
47
|
+
"package": "playwright.async_api",
|
48
|
+
"object": "BrowserContext",
|
49
|
+
"method": "new_page",
|
50
|
+
}
|
51
|
+
]
|
52
|
+
|
53
|
+
_original_new_page = None
|
54
|
+
_original_new_page_async = None
|
55
|
+
|
56
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
57
|
+
with open(os.path.join(current_dir, "rrweb", "rrweb.min.js"), "r") as f:
|
58
|
+
RRWEB_CONTENT = f"() => {{ {f.read()} }}"
|
59
|
+
|
60
|
+
|
61
|
+
async def send_events_async(page: Page, session_id: str, trace_id: str):
|
62
|
+
"""Fetch events from the page and send them to the server"""
|
63
|
+
try:
|
64
|
+
# Check if function exists first
|
65
|
+
has_function = await page.evaluate(
|
66
|
+
"""
|
67
|
+
() => typeof window.lmnrGetAndClearEvents === 'function'
|
68
|
+
"""
|
69
|
+
)
|
70
|
+
if not has_function:
|
71
|
+
return
|
72
|
+
|
73
|
+
events = await page.evaluate("window.lmnrGetAndClearEvents()")
|
74
|
+
if not events or len(events) == 0:
|
75
|
+
return
|
76
|
+
|
77
|
+
await LaminarClient.send_browser_events(
|
78
|
+
session_id, trace_id, events, f"python@{PYTHON_VERSION}"
|
79
|
+
)
|
80
|
+
|
81
|
+
except Exception as e:
|
82
|
+
logger.error(f"Error sending events: {e}")
|
83
|
+
|
84
|
+
|
85
|
+
def send_events_sync(page: SyncPage, session_id: str, trace_id: str):
|
86
|
+
"""Synchronous version of send_events"""
|
87
|
+
try:
|
88
|
+
# Check if function exists first
|
89
|
+
has_function = page.evaluate(
|
90
|
+
"""
|
91
|
+
() => typeof window.lmnrGetAndClearEvents === 'function'
|
92
|
+
"""
|
93
|
+
)
|
94
|
+
if not has_function:
|
95
|
+
return
|
96
|
+
|
97
|
+
events = page.evaluate("window.lmnrGetAndClearEvents()")
|
98
|
+
if not events or len(events) == 0:
|
99
|
+
return
|
100
|
+
|
101
|
+
LaminarClient.send_browser_events_sync(
|
102
|
+
session_id, trace_id, events, f"python@{PYTHON_VERSION}"
|
103
|
+
)
|
104
|
+
|
105
|
+
except Exception as e:
|
106
|
+
logger.error(f"Error sending events: {e}")
|
107
|
+
|
108
|
+
|
109
|
+
def inject_rrweb(page: SyncPage):
|
110
|
+
try:
|
111
|
+
page.wait_for_load_state("domcontentloaded")
|
112
|
+
|
113
|
+
# Wrap the evaluate call in a try-catch
|
114
|
+
try:
|
115
|
+
is_loaded = page.evaluate(
|
116
|
+
"""() => typeof window.lmnrRrweb !== 'undefined'"""
|
117
|
+
)
|
118
|
+
except Exception as e:
|
119
|
+
logger.debug(f"Failed to check if rrweb is loaded: {e}")
|
120
|
+
is_loaded = False
|
121
|
+
|
122
|
+
if not is_loaded:
|
123
|
+
|
124
|
+
def load_rrweb():
|
125
|
+
try:
|
126
|
+
page.evaluate(RRWEB_CONTENT)
|
127
|
+
page.wait_for_function(
|
128
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
129
|
+
timeout=5000,
|
130
|
+
)
|
131
|
+
return True
|
132
|
+
except Exception as e:
|
133
|
+
logger.debug(f"Failed to load rrweb: {e}")
|
134
|
+
return False
|
135
|
+
|
136
|
+
if not retry_sync(
|
137
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
138
|
+
):
|
139
|
+
return
|
140
|
+
|
141
|
+
try:
|
142
|
+
page.evaluate(INJECT_PLACEHOLDER)
|
143
|
+
except Exception as e:
|
144
|
+
logger.debug(f"Failed to inject rrweb placeholder: {e}")
|
145
|
+
|
146
|
+
except Exception as e:
|
147
|
+
logger.error(f"Error during rrweb injection: {e}")
|
148
|
+
|
149
|
+
|
150
|
+
async def inject_rrweb_async(page: Page):
|
151
|
+
try:
|
152
|
+
await page.wait_for_load_state("domcontentloaded")
|
153
|
+
|
154
|
+
# Wrap the evaluate call in a try-catch
|
155
|
+
try:
|
156
|
+
is_loaded = await page.evaluate(
|
157
|
+
"""() => typeof window.lmnrRrweb !== 'undefined'"""
|
158
|
+
)
|
159
|
+
except Exception as e:
|
160
|
+
logger.debug(f"Failed to check if rrweb is loaded: {e}")
|
161
|
+
is_loaded = False
|
162
|
+
|
163
|
+
if not is_loaded:
|
164
|
+
|
165
|
+
async def load_rrweb():
|
166
|
+
try:
|
167
|
+
await page.evaluate(RRWEB_CONTENT)
|
168
|
+
await page.wait_for_function(
|
169
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
170
|
+
timeout=5000,
|
171
|
+
)
|
172
|
+
return True
|
173
|
+
except Exception as e:
|
174
|
+
logger.debug(f"Failed to load rrweb: {e}")
|
175
|
+
return False
|
176
|
+
|
177
|
+
if not await retry_async(
|
178
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
179
|
+
):
|
180
|
+
return
|
181
|
+
|
182
|
+
try:
|
183
|
+
await page.evaluate(INJECT_PLACEHOLDER)
|
184
|
+
except Exception as e:
|
185
|
+
logger.debug(f"Failed to inject rrweb placeholder: {e}")
|
186
|
+
|
187
|
+
except Exception as e:
|
188
|
+
logger.error(f"Error during rrweb injection: {e}")
|
189
|
+
|
190
|
+
|
191
|
+
def handle_navigation(page: SyncPage, session_id: str, trace_id: str):
|
192
|
+
def on_load():
|
193
|
+
try:
|
194
|
+
inject_rrweb(page)
|
195
|
+
except Exception as e:
|
196
|
+
logger.error(f"Error in on_load handler: {e}")
|
197
|
+
|
198
|
+
page.on("load", on_load)
|
199
|
+
inject_rrweb(page)
|
200
|
+
|
201
|
+
def collection_loop():
|
202
|
+
while not page.is_closed(): # Stop when page closes
|
203
|
+
send_events_sync(page, session_id, trace_id)
|
204
|
+
time.sleep(2)
|
205
|
+
|
206
|
+
thread = threading.Thread(target=collection_loop, daemon=True)
|
207
|
+
thread.start()
|
208
|
+
|
209
|
+
|
210
|
+
async def handle_navigation_async(page: Page, session_id: str, trace_id: str):
|
211
|
+
async def on_load():
|
212
|
+
try:
|
213
|
+
await inject_rrweb_async(page)
|
214
|
+
except Exception as e:
|
215
|
+
logger.error(f"Error in on_load handler: {e}")
|
216
|
+
|
217
|
+
page.on("load", lambda: asyncio.create_task(on_load()))
|
218
|
+
await inject_rrweb_async(page)
|
219
|
+
|
220
|
+
async def collection_loop():
|
221
|
+
try:
|
222
|
+
while not page.is_closed(): # Stop when page closes
|
223
|
+
await send_events_async(page, session_id, trace_id)
|
224
|
+
await asyncio.sleep(2)
|
225
|
+
logger.info("Event collection stopped")
|
226
|
+
except Exception as e:
|
227
|
+
logger.error(f"Event collection stopped: {e}")
|
228
|
+
|
229
|
+
# Create and store task
|
230
|
+
task = asyncio.create_task(collection_loop())
|
231
|
+
|
232
|
+
# Clean up task when page closes
|
233
|
+
page.on("close", lambda: task.cancel())
|
234
|
+
|
235
|
+
|
236
|
+
@_with_tracer_wrapper
|
237
|
+
def _wrap(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs):
|
238
|
+
with tracer.start_as_current_span(
|
239
|
+
f"browser_context.{to_wrap.get('method')}"
|
240
|
+
) as span:
|
241
|
+
page = wrapped(*args, **kwargs)
|
242
|
+
session_id = str(uuid.uuid4().hex)
|
243
|
+
trace_id = format(get_current_span().get_span_context().trace_id, "032x")
|
244
|
+
span.set_attribute("lmnr.internal.has_browser_session", True)
|
245
|
+
handle_navigation(page, session_id, trace_id)
|
246
|
+
return page
|
247
|
+
|
248
|
+
|
249
|
+
@_with_tracer_wrapper
|
250
|
+
async def _wrap_async(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs):
|
251
|
+
with tracer.start_as_current_span(
|
252
|
+
f"browser_context.{to_wrap.get('method')}"
|
253
|
+
) as span:
|
254
|
+
page = await wrapped(*args, **kwargs)
|
255
|
+
session_id = str(uuid.uuid4().hex)
|
256
|
+
trace_id = format(get_current_span().get_span_context().trace_id, "032x")
|
257
|
+
span.set_attribute("lmnr.internal.has_browser_session", True)
|
258
|
+
await handle_navigation_async(page, session_id, trace_id)
|
259
|
+
return page
|
260
|
+
|
261
|
+
|
262
|
+
class PlaywrightInstrumentor(BaseInstrumentor):
|
263
|
+
def __init__(self):
|
264
|
+
super().__init__()
|
265
|
+
|
266
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
267
|
+
return _instruments
|
268
|
+
|
269
|
+
def _instrument(self, **kwargs):
|
270
|
+
tracer_provider = kwargs.get("tracer_provider")
|
271
|
+
tracer = get_tracer(__name__, SDK_VERSION, tracer_provider)
|
272
|
+
|
273
|
+
for wrapped_method in WRAPPED_METHODS:
|
274
|
+
wrap_package = wrapped_method.get("package")
|
275
|
+
wrap_object = wrapped_method.get("object")
|
276
|
+
wrap_method = wrapped_method.get("method")
|
277
|
+
try:
|
278
|
+
wrap_function_wrapper(
|
279
|
+
wrap_package,
|
280
|
+
f"{wrap_object}.{wrap_method}",
|
281
|
+
_wrap(
|
282
|
+
tracer,
|
283
|
+
wrapped_method,
|
284
|
+
),
|
285
|
+
)
|
286
|
+
except ModuleNotFoundError:
|
287
|
+
pass # that's ok, we're not instrumenting everything
|
288
|
+
|
289
|
+
for wrapped_method in WRAPPED_METHODS_ASYNC:
|
290
|
+
wrap_package = wrapped_method.get("package")
|
291
|
+
wrap_object = wrapped_method.get("object")
|
292
|
+
wrap_method = wrapped_method.get("method")
|
293
|
+
try:
|
294
|
+
wrap_function_wrapper(
|
295
|
+
wrap_package,
|
296
|
+
f"{wrap_object}.{wrap_method}",
|
297
|
+
_wrap_async(
|
298
|
+
tracer,
|
299
|
+
wrapped_method,
|
300
|
+
),
|
301
|
+
)
|
302
|
+
except ModuleNotFoundError:
|
303
|
+
pass # that's ok, we're not instrumenting everything
|
304
|
+
|
305
|
+
def _uninstrument(self, **kwargs):
|
306
|
+
for wrapped_method in [*WRAPPED_METHODS, *WRAPPED_METHODS_ASYNC]:
|
307
|
+
wrap_package = wrapped_method.get("package")
|
308
|
+
wrap_object = wrapped_method.get("object")
|
309
|
+
wrap_method = wrapped_method.get("method")
|
310
|
+
unwrap(wrap_package, f"{wrap_object}.{wrap_method}")
|
@@ -0,0 +1,104 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import time
|
4
|
+
|
5
|
+
logger = logging.getLogger(__name__)
|
6
|
+
|
7
|
+
INJECT_PLACEHOLDER = """
|
8
|
+
() => {
|
9
|
+
const BATCH_SIZE = 1000; // Maximum events to store in memory
|
10
|
+
|
11
|
+
window.lmnrRrwebEventsBatch = [];
|
12
|
+
|
13
|
+
// Utility function to compress individual event data
|
14
|
+
async function compressEventData(data) {
|
15
|
+
const jsonString = JSON.stringify(data);
|
16
|
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
17
|
+
const compressedStream = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
18
|
+
const compressedResponse = new Response(compressedStream);
|
19
|
+
const compressedData = await compressedResponse.arrayBuffer();
|
20
|
+
return Array.from(new Uint8Array(compressedData));
|
21
|
+
}
|
22
|
+
|
23
|
+
window.lmnrGetAndClearEvents = () => {
|
24
|
+
const events = window.lmnrRrwebEventsBatch;
|
25
|
+
window.lmnrRrwebEventsBatch = [];
|
26
|
+
return events;
|
27
|
+
};
|
28
|
+
|
29
|
+
// Add heartbeat events
|
30
|
+
setInterval(async () => {
|
31
|
+
const heartbeat = {
|
32
|
+
type: 6,
|
33
|
+
data: await compressEventData({ source: 'heartbeat' }),
|
34
|
+
timestamp: Date.now()
|
35
|
+
};
|
36
|
+
|
37
|
+
window.lmnrRrwebEventsBatch.push(heartbeat);
|
38
|
+
|
39
|
+
// Prevent memory issues by limiting batch size
|
40
|
+
if (window.lmnrRrwebEventsBatch.length > BATCH_SIZE) {
|
41
|
+
window.lmnrRrwebEventsBatch = window.lmnrRrwebEventsBatch.slice(-BATCH_SIZE);
|
42
|
+
}
|
43
|
+
}, 1000);
|
44
|
+
|
45
|
+
window.lmnrRrweb.record({
|
46
|
+
async emit(event) {
|
47
|
+
// Compress the data field
|
48
|
+
const compressedEvent = {
|
49
|
+
...event,
|
50
|
+
data: await compressEventData(event.data)
|
51
|
+
};
|
52
|
+
window.lmnrRrwebEventsBatch.push(compressedEvent);
|
53
|
+
}
|
54
|
+
});
|
55
|
+
}
|
56
|
+
"""
|
57
|
+
|
58
|
+
|
59
|
+
def _with_tracer_wrapper(func):
|
60
|
+
"""Helper for providing tracer for wrapper functions."""
|
61
|
+
|
62
|
+
def _with_tracer(tracer, to_wrap):
|
63
|
+
def wrapper(wrapped, instance, args, kwargs):
|
64
|
+
return func(tracer, to_wrap, wrapped, instance, args, kwargs)
|
65
|
+
|
66
|
+
return wrapper
|
67
|
+
|
68
|
+
return _with_tracer
|
69
|
+
|
70
|
+
|
71
|
+
def retry_sync(func, retries=5, delay=0.5, error_message="Operation failed"):
|
72
|
+
"""Utility function for retry logic in synchronous operations"""
|
73
|
+
for attempt in range(retries):
|
74
|
+
try:
|
75
|
+
result = func()
|
76
|
+
if result: # If function returns truthy value, consider it successful
|
77
|
+
return result
|
78
|
+
if attempt == retries - 1: # Last attempt
|
79
|
+
logger.error(f"{error_message} after all retries")
|
80
|
+
return None
|
81
|
+
except Exception as e:
|
82
|
+
if attempt == retries - 1: # Last attempt
|
83
|
+
logger.error(f"{error_message}: {e}")
|
84
|
+
return None
|
85
|
+
time.sleep(delay)
|
86
|
+
return None
|
87
|
+
|
88
|
+
|
89
|
+
async def retry_async(func, retries=5, delay=0.5, error_message="Operation failed"):
|
90
|
+
"""Utility function for retry logic in asynchronous operations"""
|
91
|
+
for attempt in range(retries):
|
92
|
+
try:
|
93
|
+
result = await func()
|
94
|
+
if result: # If function returns truthy value, consider it successful
|
95
|
+
return result
|
96
|
+
if attempt == retries - 1: # Last attempt
|
97
|
+
logger.error(f"{error_message} after all retries")
|
98
|
+
return None
|
99
|
+
except Exception as e:
|
100
|
+
if attempt == retries - 1: # Last attempt
|
101
|
+
logger.error(f"{error_message}: {e}")
|
102
|
+
return None
|
103
|
+
await asyncio.sleep(delay)
|
104
|
+
return None
|