lmnr 0.4.61__tar.gz → 0.4.62__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.
- {lmnr-0.4.61 → lmnr-0.4.62}/PKG-INFO +1 -1
- {lmnr-0.4.61 → lmnr-0.4.62}/pyproject.toml +1 -1
- lmnr-0.4.62/src/lmnr/sdk/browser/playwright_patch.py +377 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/laminar.py +9 -0
- lmnr-0.4.62/src/lmnr/version.py +46 -0
- lmnr-0.4.61/src/lmnr/sdk/browser/playwright_patch.py +0 -349
- lmnr-0.4.61/src/lmnr/version.py +0 -5
- {lmnr-0.4.61 → lmnr-0.4.62}/LICENSE +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/README.md +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/cli.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/.flake8 +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/config/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/decorators/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/decorators/base.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/instruments.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/tracing/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/tracing/attributes.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/tracing/context_manager.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/tracing/tracing.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/utils/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/utils/json_encoder.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/openllmetry_sdk/utils/package_check.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/py.typed +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/browser/__init__.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/browser/rrweb/rrweb.min.js +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/datasets.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/decorators.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/eval_control.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/evaluations.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/log.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/types.py +0 -0
- {lmnr-0.4.61 → lmnr-0.4.62}/src/lmnr/sdk/utils.py +0 -0
@@ -0,0 +1,377 @@
|
|
1
|
+
import uuid
|
2
|
+
import asyncio
|
3
|
+
import logging
|
4
|
+
import time
|
5
|
+
import os
|
6
|
+
import aiohttp
|
7
|
+
import requests
|
8
|
+
import threading
|
9
|
+
import gzip
|
10
|
+
import json
|
11
|
+
from lmnr.version import SDK_VERSION, PYTHON_VERSION
|
12
|
+
from lmnr import Laminar
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
try:
|
17
|
+
from playwright.async_api import BrowserContext, Page
|
18
|
+
from playwright.sync_api import (
|
19
|
+
BrowserContext as SyncBrowserContext,
|
20
|
+
Page as SyncPage,
|
21
|
+
)
|
22
|
+
except ImportError as e:
|
23
|
+
raise ImportError(
|
24
|
+
f"Attempted to import {__file__}, but it is designed "
|
25
|
+
"to patch Playwright, which is not installed. Use `pip install playwright` "
|
26
|
+
"to install Playwright or remove this import."
|
27
|
+
) from e
|
28
|
+
|
29
|
+
_original_new_page = None
|
30
|
+
_original_new_page_async = None
|
31
|
+
|
32
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
33
|
+
with open(os.path.join(current_dir, "rrweb", "rrweb.min.js"), "r") as f:
|
34
|
+
RRWEB_CONTENT = f"() => {{ {f.read()} }}"
|
35
|
+
|
36
|
+
INJECT_PLACEHOLDER = """
|
37
|
+
() => {
|
38
|
+
const BATCH_SIZE = 1000; // Maximum events to store in memory
|
39
|
+
|
40
|
+
window.lmnrRrwebEventsBatch = [];
|
41
|
+
|
42
|
+
// Utility function to compress individual event data
|
43
|
+
async function compressEventData(data) {
|
44
|
+
const jsonString = JSON.stringify(data);
|
45
|
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
46
|
+
const compressedStream = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
47
|
+
const compressedResponse = new Response(compressedStream);
|
48
|
+
const compressedData = await compressedResponse.arrayBuffer();
|
49
|
+
return Array.from(new Uint8Array(compressedData));
|
50
|
+
}
|
51
|
+
|
52
|
+
window.lmnrGetAndClearEvents = () => {
|
53
|
+
const events = window.lmnrRrwebEventsBatch;
|
54
|
+
window.lmnrRrwebEventsBatch = [];
|
55
|
+
return events;
|
56
|
+
};
|
57
|
+
|
58
|
+
// Add heartbeat events
|
59
|
+
setInterval(async () => {
|
60
|
+
const heartbeat = {
|
61
|
+
type: 6,
|
62
|
+
data: await compressEventData({ source: 'heartbeat' }),
|
63
|
+
timestamp: Date.now()
|
64
|
+
};
|
65
|
+
|
66
|
+
window.lmnrRrwebEventsBatch.push(heartbeat);
|
67
|
+
|
68
|
+
// Prevent memory issues by limiting batch size
|
69
|
+
if (window.lmnrRrwebEventsBatch.length > BATCH_SIZE) {
|
70
|
+
window.lmnrRrwebEventsBatch = window.lmnrRrwebEventsBatch.slice(-BATCH_SIZE);
|
71
|
+
}
|
72
|
+
}, 1000);
|
73
|
+
|
74
|
+
window.lmnrRrweb.record({
|
75
|
+
async emit(event) {
|
76
|
+
// Compress the data field
|
77
|
+
const compressedEvent = {
|
78
|
+
...event,
|
79
|
+
data: await compressEventData(event.data)
|
80
|
+
};
|
81
|
+
window.lmnrRrwebEventsBatch.push(compressedEvent);
|
82
|
+
}
|
83
|
+
});
|
84
|
+
}
|
85
|
+
"""
|
86
|
+
|
87
|
+
|
88
|
+
def retry_sync(func, retries=5, delay=0.5, error_message="Operation failed"):
|
89
|
+
"""Utility function for retry logic in synchronous operations"""
|
90
|
+
for attempt in range(retries):
|
91
|
+
try:
|
92
|
+
result = func()
|
93
|
+
if result: # If function returns truthy value, consider it successful
|
94
|
+
return result
|
95
|
+
if attempt == retries - 1: # Last attempt
|
96
|
+
logger.error(f"{error_message} after all retries")
|
97
|
+
return None
|
98
|
+
except Exception as e:
|
99
|
+
if attempt == retries - 1: # Last attempt
|
100
|
+
logger.error(f"{error_message}: {e}")
|
101
|
+
return None
|
102
|
+
time.sleep(delay)
|
103
|
+
return None
|
104
|
+
|
105
|
+
|
106
|
+
async def retry_async(func, retries=5, delay=0.5, error_message="Operation failed"):
|
107
|
+
"""Utility function for retry logic in asynchronous operations"""
|
108
|
+
for attempt in range(retries):
|
109
|
+
try:
|
110
|
+
result = await func()
|
111
|
+
if result: # If function returns truthy value, consider it successful
|
112
|
+
return result
|
113
|
+
if attempt == retries - 1: # Last attempt
|
114
|
+
logger.error(f"{error_message} after all retries")
|
115
|
+
return None
|
116
|
+
except Exception as e:
|
117
|
+
if attempt == retries - 1: # Last attempt
|
118
|
+
logger.error(f"{error_message}: {e}")
|
119
|
+
return None
|
120
|
+
await asyncio.sleep(delay)
|
121
|
+
return None
|
122
|
+
|
123
|
+
|
124
|
+
async def send_events_async(
|
125
|
+
page: Page, http_url: str, project_api_key: str, session_id: str, trace_id: str
|
126
|
+
):
|
127
|
+
"""Fetch events from the page and send them to the server"""
|
128
|
+
try:
|
129
|
+
# Check if function exists first
|
130
|
+
has_function = await page.evaluate(
|
131
|
+
"""
|
132
|
+
() => typeof window.lmnrGetAndClearEvents === 'function'
|
133
|
+
"""
|
134
|
+
)
|
135
|
+
if not has_function:
|
136
|
+
return
|
137
|
+
|
138
|
+
events = await page.evaluate("window.lmnrGetAndClearEvents()")
|
139
|
+
if not events or len(events) == 0:
|
140
|
+
return
|
141
|
+
|
142
|
+
payload = {
|
143
|
+
"sessionId": session_id,
|
144
|
+
"traceId": trace_id,
|
145
|
+
"events": events,
|
146
|
+
"source": f"python@{PYTHON_VERSION}",
|
147
|
+
"sdkVersion": SDK_VERSION,
|
148
|
+
}
|
149
|
+
|
150
|
+
headers = {
|
151
|
+
"Content-Type": "application/json",
|
152
|
+
"Authorization": f"Bearer {project_api_key}",
|
153
|
+
"Accept": "application/json",
|
154
|
+
}
|
155
|
+
|
156
|
+
async with aiohttp.ClientSession() as session:
|
157
|
+
async with session.post(
|
158
|
+
f"{http_url}/v1/browser-sessions/events",
|
159
|
+
json=payload,
|
160
|
+
headers=headers,
|
161
|
+
) as response:
|
162
|
+
if not response.ok:
|
163
|
+
logger.error(f"Failed to send events: {response.status}")
|
164
|
+
|
165
|
+
except Exception as e:
|
166
|
+
logger.error(f"Error sending events: {e}")
|
167
|
+
|
168
|
+
|
169
|
+
def send_events_sync(
|
170
|
+
page: SyncPage, http_url: str, project_api_key: str, session_id: str, trace_id: str
|
171
|
+
):
|
172
|
+
"""Synchronous version of send_events"""
|
173
|
+
try:
|
174
|
+
# Check if function exists first
|
175
|
+
has_function = page.evaluate(
|
176
|
+
"""
|
177
|
+
() => typeof window.lmnrGetAndClearEvents === 'function'
|
178
|
+
"""
|
179
|
+
)
|
180
|
+
if not has_function:
|
181
|
+
return
|
182
|
+
|
183
|
+
events = page.evaluate("window.lmnrGetAndClearEvents()")
|
184
|
+
if not events or len(events) == 0:
|
185
|
+
return
|
186
|
+
|
187
|
+
payload = {
|
188
|
+
"sessionId": session_id,
|
189
|
+
"traceId": trace_id,
|
190
|
+
"events": events,
|
191
|
+
"source": f"python@{PYTHON_VERSION}",
|
192
|
+
"sdkVersion": SDK_VERSION,
|
193
|
+
}
|
194
|
+
|
195
|
+
headers = {
|
196
|
+
"Content-Type": "application/json",
|
197
|
+
"Authorization": f"Bearer {project_api_key}",
|
198
|
+
"Accept": "application/json",
|
199
|
+
"Content-Encoding": "gzip", # Add Content-Encoding header
|
200
|
+
}
|
201
|
+
|
202
|
+
# Compress the payload
|
203
|
+
compressed_payload = gzip.compress(json.dumps(payload).encode("utf-8"))
|
204
|
+
|
205
|
+
response = requests.post(
|
206
|
+
f"{http_url}/v1/browser-sessions/events",
|
207
|
+
data=compressed_payload, # Use data instead of json for raw bytes
|
208
|
+
headers=headers,
|
209
|
+
)
|
210
|
+
if not response.ok:
|
211
|
+
logger.error(f"Failed to send events: {response.status_code}")
|
212
|
+
|
213
|
+
except Exception as e:
|
214
|
+
logger.error(f"Error sending events: {e}")
|
215
|
+
|
216
|
+
|
217
|
+
def init_playwright_tracing(http_url: str, project_api_key: str):
|
218
|
+
|
219
|
+
def inject_rrweb(page: SyncPage):
|
220
|
+
try:
|
221
|
+
page.wait_for_load_state("domcontentloaded")
|
222
|
+
|
223
|
+
# Wrap the evaluate call in a try-catch
|
224
|
+
try:
|
225
|
+
is_loaded = page.evaluate(
|
226
|
+
"""() => typeof window.lmnrRrweb !== 'undefined'"""
|
227
|
+
)
|
228
|
+
except Exception as e:
|
229
|
+
logger.debug(f"Failed to check if rrweb is loaded: {e}")
|
230
|
+
is_loaded = False
|
231
|
+
|
232
|
+
if not is_loaded:
|
233
|
+
def load_rrweb():
|
234
|
+
try:
|
235
|
+
page.evaluate(RRWEB_CONTENT)
|
236
|
+
page.wait_for_function(
|
237
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
238
|
+
timeout=5000,
|
239
|
+
)
|
240
|
+
return True
|
241
|
+
except Exception as e:
|
242
|
+
logger.debug(f"Failed to load rrweb: {e}")
|
243
|
+
return False
|
244
|
+
|
245
|
+
if not retry_sync(
|
246
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
247
|
+
):
|
248
|
+
return
|
249
|
+
|
250
|
+
try:
|
251
|
+
page.evaluate(INJECT_PLACEHOLDER)
|
252
|
+
except Exception as e:
|
253
|
+
logger.debug(f"Failed to inject rrweb placeholder: {e}")
|
254
|
+
|
255
|
+
except Exception as e:
|
256
|
+
logger.error(f"Error during rrweb injection: {e}")
|
257
|
+
|
258
|
+
async def inject_rrweb_async(page: Page):
|
259
|
+
try:
|
260
|
+
await page.wait_for_load_state("domcontentloaded")
|
261
|
+
|
262
|
+
# Wrap the evaluate call in a try-catch
|
263
|
+
try:
|
264
|
+
is_loaded = await page.evaluate(
|
265
|
+
"""() => typeof window.lmnrRrweb !== 'undefined'"""
|
266
|
+
)
|
267
|
+
except Exception as e:
|
268
|
+
logger.debug(f"Failed to check if rrweb is loaded: {e}")
|
269
|
+
is_loaded = False
|
270
|
+
|
271
|
+
if not is_loaded:
|
272
|
+
async def load_rrweb():
|
273
|
+
try:
|
274
|
+
await page.evaluate(RRWEB_CONTENT)
|
275
|
+
await page.wait_for_function(
|
276
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
277
|
+
timeout=5000,
|
278
|
+
)
|
279
|
+
return True
|
280
|
+
except Exception as e:
|
281
|
+
logger.debug(f"Failed to load rrweb: {e}")
|
282
|
+
return False
|
283
|
+
|
284
|
+
if not await retry_async(
|
285
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
286
|
+
):
|
287
|
+
return
|
288
|
+
|
289
|
+
try:
|
290
|
+
await page.evaluate(INJECT_PLACEHOLDER)
|
291
|
+
except Exception as e:
|
292
|
+
logger.debug(f"Failed to inject rrweb placeholder: {e}")
|
293
|
+
|
294
|
+
except Exception as e:
|
295
|
+
logger.error(f"Error during rrweb injection: {e}")
|
296
|
+
|
297
|
+
def handle_navigation(page: SyncPage, session_id: str, trace_id: str):
|
298
|
+
def on_load():
|
299
|
+
try:
|
300
|
+
inject_rrweb(page)
|
301
|
+
except Exception as e:
|
302
|
+
logger.error(f"Error in on_load handler: {e}")
|
303
|
+
|
304
|
+
page.on("load", on_load)
|
305
|
+
inject_rrweb(page)
|
306
|
+
|
307
|
+
def collection_loop():
|
308
|
+
while not page.is_closed(): # Stop when page closes
|
309
|
+
send_events_sync(page, http_url, project_api_key, session_id, trace_id)
|
310
|
+
time.sleep(2)
|
311
|
+
|
312
|
+
thread = threading.Thread(target=collection_loop, daemon=True)
|
313
|
+
thread.start()
|
314
|
+
|
315
|
+
async def handle_navigation_async(page: Page, session_id: str, trace_id: str):
|
316
|
+
async def on_load():
|
317
|
+
try:
|
318
|
+
await inject_rrweb_async(page)
|
319
|
+
except Exception as e:
|
320
|
+
logger.error(f"Error in on_load handler: {e}")
|
321
|
+
|
322
|
+
page.on("load", lambda: asyncio.create_task(on_load()))
|
323
|
+
await inject_rrweb_async(page)
|
324
|
+
|
325
|
+
async def collection_loop():
|
326
|
+
try:
|
327
|
+
while not page.is_closed(): # Stop when page closes
|
328
|
+
await send_events_async(
|
329
|
+
page, http_url, project_api_key, session_id, trace_id
|
330
|
+
)
|
331
|
+
await asyncio.sleep(2)
|
332
|
+
logger.info("Event collection stopped")
|
333
|
+
except Exception as e:
|
334
|
+
logger.error(f"Event collection stopped: {e}")
|
335
|
+
|
336
|
+
# Create and store task
|
337
|
+
task = asyncio.create_task(collection_loop())
|
338
|
+
|
339
|
+
# Clean up task when page closes
|
340
|
+
page.on("close", lambda: task.cancel())
|
341
|
+
|
342
|
+
def patched_new_page(self: SyncBrowserContext, *args, **kwargs):
|
343
|
+
with Laminar.start_as_current_span(name="browser_context.new_page") as span:
|
344
|
+
page = _original_new_page(self, *args, **kwargs)
|
345
|
+
|
346
|
+
session_id = str(uuid.uuid4().hex)
|
347
|
+
span.set_attribute("lmnr.internal.has_browser_session", True)
|
348
|
+
|
349
|
+
trace_id = format(span.get_span_context().trace_id, "032x")
|
350
|
+
session_id = str(uuid.uuid4().hex)
|
351
|
+
|
352
|
+
handle_navigation(page, session_id, trace_id)
|
353
|
+
return page
|
354
|
+
|
355
|
+
async def patched_new_page_async(self: BrowserContext, *args, **kwargs):
|
356
|
+
with Laminar.start_as_current_span(name="browser_context.new_page") as span:
|
357
|
+
page = await _original_new_page_async(self, *args, **kwargs)
|
358
|
+
|
359
|
+
session_id = str(uuid.uuid4().hex)
|
360
|
+
|
361
|
+
span.set_attribute("lmnr.internal.has_browser_session", True)
|
362
|
+
trace_id = format(span.get_span_context().trace_id, "032x")
|
363
|
+
session_id = str(uuid.uuid4().hex)
|
364
|
+
await handle_navigation_async(page, session_id, trace_id)
|
365
|
+
return page
|
366
|
+
|
367
|
+
def patch_browser():
|
368
|
+
global _original_new_page, _original_new_page_async
|
369
|
+
if _original_new_page_async is None:
|
370
|
+
_original_new_page_async = BrowserContext.new_page
|
371
|
+
BrowserContext.new_page = patched_new_page_async
|
372
|
+
|
373
|
+
if _original_new_page is None:
|
374
|
+
_original_new_page = SyncBrowserContext.new_page
|
375
|
+
SyncBrowserContext.new_page = patched_new_page
|
376
|
+
|
377
|
+
patch_browser()
|
@@ -13,6 +13,7 @@ from lmnr.openllmetry_sdk.config import MAX_MANUAL_SPAN_PAYLOAD_SIZE
|
|
13
13
|
from lmnr.openllmetry_sdk.decorators.base import json_dumps
|
14
14
|
from opentelemetry import context as context_api, trace
|
15
15
|
from opentelemetry.context import attach, detach
|
16
|
+
from lmnr.version import SDK_VERSION, get_latest_pypi_version, is_latest_version
|
16
17
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
17
18
|
OTLPSpanExporter,
|
18
19
|
Compression,
|
@@ -151,6 +152,14 @@ class Laminar:
|
|
151
152
|
cls.__initialized = True
|
152
153
|
cls._initialize_logger()
|
153
154
|
|
155
|
+
# if not is_latest_version():
|
156
|
+
# cls.__logger.warning(
|
157
|
+
# "You are using an older version of the Laminar SDK. "
|
158
|
+
# f"Latest version: {get_latest_pypi_version()}, current version: {SDK_VERSION}.\n"
|
159
|
+
# "Please update to the latest version by running "
|
160
|
+
# "`pip install --upgrade lmnr`."
|
161
|
+
# )
|
162
|
+
|
154
163
|
Traceloop.init(
|
155
164
|
base_http_url=cls.__base_http_url,
|
156
165
|
project_api_key=cls.__project_api_key,
|
@@ -0,0 +1,46 @@
|
|
1
|
+
import sys
|
2
|
+
import requests
|
3
|
+
from packaging import version
|
4
|
+
|
5
|
+
|
6
|
+
SDK_VERSION = "0.4.62"
|
7
|
+
PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
|
8
|
+
|
9
|
+
|
10
|
+
def is_latest_version() -> bool:
|
11
|
+
try:
|
12
|
+
return version.parse(SDK_VERSION) >= version.parse(get_latest_pypi_version())
|
13
|
+
except Exception:
|
14
|
+
return True
|
15
|
+
|
16
|
+
|
17
|
+
def get_latest_pypi_version() -> str:
|
18
|
+
"""
|
19
|
+
Get the latest stable version of lmnr package from PyPI.
|
20
|
+
Returns the version string or raises an exception if unable to fetch.
|
21
|
+
"""
|
22
|
+
try:
|
23
|
+
response = requests.get("https://pypi.org/pypi/lmnr/json")
|
24
|
+
response.raise_for_status()
|
25
|
+
|
26
|
+
releases = response.json()["releases"]
|
27
|
+
stable_versions = [
|
28
|
+
ver
|
29
|
+
for ver in releases.keys()
|
30
|
+
if not version.parse(ver).is_prerelease
|
31
|
+
and not version.parse(ver).is_devrelease
|
32
|
+
and not any(release.get("yanked", False) for release in releases[ver])
|
33
|
+
]
|
34
|
+
|
35
|
+
if not stable_versions:
|
36
|
+
# do not scare the user, assume they are on
|
37
|
+
# latest version
|
38
|
+
return SDK_VERSION
|
39
|
+
|
40
|
+
latest_version = max(stable_versions, key=version.parse)
|
41
|
+
return latest_version
|
42
|
+
|
43
|
+
except Exception:
|
44
|
+
# do not scare the user, assume they are on
|
45
|
+
# latest version
|
46
|
+
return SDK_VERSION
|
@@ -1,349 +0,0 @@
|
|
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()
|
lmnr-0.4.61/src/lmnr/version.py
DELETED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|