lmnr 0.4.61__tar.gz → 0.4.63b0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {lmnr-0.4.61 → lmnr-0.4.63b0}/PKG-INFO +1 -1
  2. {lmnr-0.4.61 → lmnr-0.4.63b0}/pyproject.toml +1 -1
  3. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/__init__.py +1 -0
  4. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/tracing/tracing.py +16 -15
  5. lmnr-0.4.63b0/src/lmnr/sdk/browser/playwright_patch.py +377 -0
  6. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/laminar.py +155 -38
  7. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/types.py +86 -0
  8. lmnr-0.4.63b0/src/lmnr/version.py +46 -0
  9. lmnr-0.4.61/src/lmnr/sdk/browser/playwright_patch.py +0 -349
  10. lmnr-0.4.61/src/lmnr/version.py +0 -5
  11. {lmnr-0.4.61 → lmnr-0.4.63b0}/LICENSE +0 -0
  12. {lmnr-0.4.61 → lmnr-0.4.63b0}/README.md +0 -0
  13. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/cli.py +0 -0
  14. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/.flake8 +0 -0
  15. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/__init__.py +0 -0
  16. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/config/__init__.py +0 -0
  17. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/decorators/__init__.py +0 -0
  18. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/decorators/base.py +0 -0
  19. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/instruments.py +0 -0
  20. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/tracing/__init__.py +0 -0
  21. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/tracing/attributes.py +0 -0
  22. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -0
  23. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/tracing/context_manager.py +0 -0
  24. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/utils/__init__.py +0 -0
  25. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -0
  26. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/utils/json_encoder.py +0 -0
  27. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/openllmetry_sdk/utils/package_check.py +0 -0
  28. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/py.typed +0 -0
  29. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/__init__.py +0 -0
  30. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/browser/__init__.py +0 -0
  31. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/browser/rrweb/rrweb.min.js +0 -0
  32. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/datasets.py +0 -0
  33. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/decorators.py +0 -0
  34. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/eval_control.py +0 -0
  35. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/evaluations.py +0 -0
  36. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/log.py +0 -0
  37. {lmnr-0.4.61 → lmnr-0.4.63b0}/src/lmnr/sdk/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: lmnr
3
- Version: 0.4.61
3
+ Version: 0.4.63b0
4
4
  Summary: Python SDK for Laminar
5
5
  License: Apache-2.0
6
6
  Author: lmnr.ai
@@ -6,7 +6,7 @@
6
6
 
7
7
  [project]
8
8
  name = "lmnr"
9
- version = "0.4.61"
9
+ version = "0.4.63b0"
10
10
  description = "Python SDK for Laminar"
11
11
  authors = [
12
12
  { name = "lmnr.ai", email = "founders@lmnr.ai" }
@@ -10,6 +10,7 @@ from .sdk.types import (
10
10
  TracingLevel,
11
11
  )
12
12
  from .sdk.decorators import observe
13
+ from .sdk.types import LaminarSpanContext
13
14
  from .openllmetry_sdk import Instruments
14
15
  from .openllmetry_sdk.tracing.attributes import Attributes
15
16
  from opentelemetry.trace import use_span
@@ -38,7 +38,7 @@ from opentelemetry.sdk.trace.export import (
38
38
  SimpleSpanProcessor,
39
39
  BatchSpanProcessor,
40
40
  )
41
- from opentelemetry.trace import get_tracer_provider, ProxyTracerProvider
41
+ from opentelemetry.sdk.trace import SpanLimits
42
42
 
43
43
  from typing import Dict, Optional, Set
44
44
 
@@ -69,6 +69,8 @@ EXCLUDED_URLS = """
69
69
  githubusercontent.com,
70
70
  openaipublic.blob.core.windows.net"""
71
71
 
72
+ MAX_EVENTS_OR_ATTRIBUTES_PER_SPAN = 5000
73
+
72
74
 
73
75
  class TracerWrapper(object):
74
76
  resource_attributes: dict = {}
@@ -291,22 +293,21 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor
291
293
  )
292
294
 
293
295
 
296
+ # TODO: check if it's safer to use the default tracer provider obtained from
297
+ # get_tracer_provider()
294
298
  def init_tracer_provider(resource: Resource) -> TracerProvider:
295
- provider: TracerProvider = None
296
- default_provider: TracerProvider = get_tracer_provider()
297
-
298
- if isinstance(default_provider, ProxyTracerProvider):
299
- provider = TracerProvider(resource=resource)
300
- trace.set_tracer_provider(provider)
301
- elif not hasattr(default_provider, "add_span_processor"):
302
- module_logger.error(
303
- "Cannot add span processor to the default provider since it doesn't support it"
304
- )
305
- return
306
- else:
307
- provider = default_provider
299
+ tracer_provider = TracerProvider(
300
+ resource=resource,
301
+ span_limits=SpanLimits(
302
+ # this defaults to 128, which causes us to drop messages
303
+ max_attributes=MAX_EVENTS_OR_ATTRIBUTES_PER_SPAN,
304
+ max_span_attributes=MAX_EVENTS_OR_ATTRIBUTES_PER_SPAN,
305
+ max_event_attributes=MAX_EVENTS_OR_ATTRIBUTES_PER_SPAN,
306
+ max_events=MAX_EVENTS_OR_ATTRIBUTES_PER_SPAN,
307
+ ),
308
+ )
308
309
 
309
- return provider
310
+ return tracer_provider
310
311
 
311
312
 
312
313
  def init_instrumentations(
@@ -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()
@@ -35,6 +35,7 @@ import requests
35
35
  import re
36
36
  import urllib.parse
37
37
  import uuid
38
+ import warnings
38
39
 
39
40
  from lmnr.openllmetry_sdk.tracing.attributes import (
40
41
  SESSION_ID,
@@ -54,6 +55,7 @@ from .types import (
54
55
  InitEvaluationResponse,
55
56
  EvaluationResultDatapoint,
56
57
  GetDatapointsResponse,
58
+ LaminarSpanContext,
57
59
  PipelineRunError,
58
60
  PipelineRunResponse,
59
61
  NodeInput,
@@ -151,6 +153,14 @@ class Laminar:
151
153
  cls.__initialized = True
152
154
  cls._initialize_logger()
153
155
 
156
+ # if not is_latest_version():
157
+ # cls.__logger.warning(
158
+ # "You are using an older version of the Laminar SDK. "
159
+ # f"Latest version: {get_latest_pypi_version()}, current version: {SDK_VERSION}.\n"
160
+ # "Please update to the latest version by running "
161
+ # "`pip install --upgrade lmnr`."
162
+ # )
163
+
154
164
  Traceloop.init(
155
165
  base_http_url=cls.__base_http_url,
156
166
  project_api_key=cls.__project_api_key,
@@ -338,8 +348,10 @@ class Laminar:
338
348
  Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]
339
349
  ] = "DEFAULT",
340
350
  context: Optional[Context] = None,
341
- trace_id: Optional[uuid.UUID] = None,
342
351
  labels: Optional[dict[str, str]] = None,
352
+ parent_span_context: Optional[LaminarSpanContext] = None,
353
+ # deprecated, use parent_span_context instead
354
+ trace_id: Optional[uuid.UUID] = None,
343
355
  ):
344
356
  """Start a new span as the current span. Useful for manual
345
357
  instrumentation. If `span_type` is set to `"LLM"`, you should report
@@ -362,11 +374,21 @@ class Laminar:
362
374
  and response attributes manually. Defaults to "DEFAULT".
363
375
  context (Optional[Context], optional): raw OpenTelemetry context\
364
376
  to attach the span to. Defaults to None.
365
- trace_id (Optional[uuid.UUID], optional): [EXPERIMENTAL] override\
366
- the trace id for the span. If not provided, use the current\
367
- trace id. Defaults to None.
377
+ parent_span_context (Optional[LaminarSpanContext], optional): parent\
378
+ span context to use for the span. Useful for continuing traces\
379
+ across services. If parent_span_context is a\
380
+ raw OpenTelemetry span context, or if it is a dictionary or string\
381
+ obtained from `Laminar.get_laminar_span_context_dict()` or\
382
+ `Laminar.get_laminar_span_context_str()` respectively, it will be\
383
+ converted to a `LaminarSpanContext` if possible. See also\
384
+ `Laminar.get_span_context`, `Laminar.get_span_context_dict` and\
385
+ `Laminar.get_span_context_str` for more information.
386
+ Defaults to None.
368
387
  labels (Optional[dict[str, str]], optional): labels to set for the\
369
388
  span. Defaults to None.
389
+ trace_id (Optional[uuid.UUID], optional): [Deprecated] override\
390
+ the trace id for the span. If not provided, use the current\
391
+ trace id. Defaults to None.
370
392
  """
371
393
 
372
394
  if not cls.is_initialized():
@@ -376,21 +398,29 @@ class Laminar:
376
398
  with get_tracer() as tracer:
377
399
  ctx = context or context_api.get_current()
378
400
  if trace_id is not None:
379
- if isinstance(trace_id, uuid.UUID):
380
- span_context = trace.SpanContext(
381
- trace_id=int(trace_id),
382
- span_id=random.getrandbits(64),
383
- is_remote=False,
384
- trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
385
- )
386
- ctx = trace.set_span_in_context(
387
- trace.NonRecordingSpan(span_context), ctx
388
- )
389
- else:
390
- cls.__logger.warning(
391
- "trace_id provided to `Laminar.start_as_current_span`"
392
- " is not a valid UUID"
393
- )
401
+ warnings.warn(
402
+ "trace_id provided to `Laminar.start_as_current_span`"
403
+ " is deprecated, use parent_span_context instead",
404
+ DeprecationWarning,
405
+ stacklevel=2,
406
+ )
407
+ if parent_span_context is not None:
408
+ span_context = LaminarSpanContext.try_to_otel_span_context(
409
+ parent_span_context, cls.__logger
410
+ )
411
+ ctx = trace.set_span_in_context(
412
+ trace.NonRecordingSpan(span_context), ctx
413
+ )
414
+ elif trace_id is not None and isinstance(trace_id, uuid.UUID):
415
+ span_context = trace.SpanContext(
416
+ trace_id=int(trace_id),
417
+ span_id=random.getrandbits(64),
418
+ is_remote=False,
419
+ trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
420
+ )
421
+ ctx = trace.set_span_in_context(
422
+ trace.NonRecordingSpan(span_context), ctx
423
+ )
394
424
  ctx_token = attach(ctx)
395
425
  label_props = {}
396
426
  try:
@@ -478,8 +508,10 @@ class Laminar:
478
508
  Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]
479
509
  ] = "DEFAULT",
480
510
  context: Optional[Context] = None,
481
- trace_id: Optional[uuid.UUID] = None,
511
+ parent_span_context: Optional[LaminarSpanContext] = None,
482
512
  labels: Optional[dict[str, str]] = None,
513
+ # deprecated, use parent_span_context instead
514
+ trace_id: Optional[uuid.UUID] = None,
483
515
  ):
484
516
  """Start a new span. Useful for manual instrumentation.
485
517
  If `span_type` is set to `"LLM"`, you should report usage and response
@@ -521,30 +553,48 @@ class Laminar:
521
553
  and response attributes manually. Defaults to "DEFAULT".
522
554
  context (Optional[Context], optional): raw OpenTelemetry context\
523
555
  to attach the span to. Defaults to None.
524
- trace_id (Optional[uuid.UUID], optional): [EXPERIMENTAL] override\
525
- the trace id for the span. If not provided, use the current\
526
- trace id. Defaults to None.
556
+ parent_span_context (Optional[LaminarSpanContext], optional): parent\
557
+ span context to use for the span. Useful for continuing traces\
558
+ across services. If parent_span_context is a\
559
+ raw OpenTelemetry span context, or if it is a dictionary or string\
560
+ obtained from `Laminar.get_laminar_span_context_dict()` or\
561
+ `Laminar.get_laminar_span_context_str()` respectively, it will be\
562
+ converted to a `LaminarSpanContext` if possible. See also\
563
+ `Laminar.get_span_context`, `Laminar.get_span_context_dict` and\
564
+ `Laminar.get_span_context_str` for more information.
565
+ Defaults to None.
527
566
  labels (Optional[dict[str, str]], optional): labels to set for the\
528
567
  span. Defaults to None.
568
+ trace_id (Optional[uuid.UUID], optional): Deprecated, use\
569
+ `parent_span_context` instead. If provided, it will be used to\
570
+ set the trace id for the span.
529
571
  """
530
572
  with get_tracer() as tracer:
531
573
  ctx = context or context_api.get_current()
532
574
  if trace_id is not None:
533
- if isinstance(trace_id, uuid.UUID):
534
- span_context = trace.SpanContext(
535
- trace_id=int(trace_id),
536
- span_id=random.getrandbits(64),
537
- is_remote=False,
538
- trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
539
- )
540
- ctx = trace.set_span_in_context(
541
- trace.NonRecordingSpan(span_context), ctx
542
- )
543
- else:
544
- cls.__logger.warning(
545
- "trace_id provided to `Laminar.start_span`"
546
- " is not a valid UUID"
547
- )
575
+ warnings.warn(
576
+ "trace_id provided to `Laminar.start_span`"
577
+ " is deprecated, use parent_span_context instead",
578
+ DeprecationWarning,
579
+ stacklevel=2,
580
+ )
581
+ if parent_span_context is not None:
582
+ span_context = LaminarSpanContext.try_to_otel_span_context(
583
+ parent_span_context, cls.__logger
584
+ )
585
+ ctx = trace.set_span_in_context(
586
+ trace.NonRecordingSpan(span_context), ctx
587
+ )
588
+ elif trace_id is not None and isinstance(trace_id, uuid.UUID):
589
+ span_context = trace.SpanContext(
590
+ trace_id=int(trace_id),
591
+ span_id=random.getrandbits(64),
592
+ is_remote=False,
593
+ trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
594
+ )
595
+ ctx = trace.set_span_in_context(
596
+ trace.NonRecordingSpan(span_context), ctx
597
+ )
548
598
  label_props = {}
549
599
  try:
550
600
  if labels:
@@ -679,6 +729,73 @@ class Laminar:
679
729
  else:
680
730
  span.set_attribute(key.value, value)
681
731
 
732
+ @classmethod
733
+ def get_laminar_span_context(
734
+ cls, span: Optional[trace.Span] = None
735
+ ) -> Optional[LaminarSpanContext]:
736
+ """Get the laminar span context for a given span.
737
+ If no span is provided, the current active span will be used.
738
+ """
739
+ span = span or trace.get_current_span()
740
+ if span == trace.INVALID_SPAN:
741
+ return None
742
+ return LaminarSpanContext(
743
+ trace_id=uuid.UUID(int=span.get_span_context().trace_id),
744
+ span_id=uuid.UUID(int=span.get_span_context().span_id),
745
+ is_remote=span.get_span_context().is_remote,
746
+ )
747
+
748
+ @classmethod
749
+ def get_laminar_span_context_dict(
750
+ cls, span: Optional[trace.Span] = None
751
+ ) -> Optional[dict]:
752
+ """Get the laminar span context for a given span as a dictionary.
753
+ If no span is provided, the current active span will be used.
754
+
755
+ This is useful for continuing a trace across services.
756
+
757
+ Example:
758
+ ```python
759
+ # service A:
760
+ with Laminar.start_as_current_span("service_a"):
761
+ span_context = Laminar.get_laminar_span_context_dict()
762
+ # send span_context to service B
763
+ call_service_b(request, headers={"laminar-span-context": span_context})
764
+
765
+ # service B:
766
+ def call_service_b(request, headers):
767
+ span_context = LaminarSpanContext.from_dict(headers["laminar-span-context"])
768
+ with Laminar.start_as_current_span("service_b", parent_span_context=span_context):
769
+ # rest of the function
770
+ pass
771
+ ```
772
+
773
+ This will result in a trace like:
774
+ ```
775
+ service_a
776
+ service_b
777
+ ```
778
+ """
779
+ span_context = cls.get_laminar_span_context(span)
780
+ if span_context is None:
781
+ return None
782
+ return span_context.to_dict()
783
+
784
+ @classmethod
785
+ def get_laminar_span_context_str(
786
+ cls, span: Optional[trace.Span] = None
787
+ ) -> Optional[str]:
788
+ span_context = cls.get_laminar_span_context(span)
789
+ if span_context is None:
790
+ return None
791
+ return json.dumps(span_context.to_dict())
792
+
793
+ @classmethod
794
+ def deserialize_laminar_span_context(
795
+ cls, span_context: Union[dict, str]
796
+ ) -> LaminarSpanContext:
797
+ return LaminarSpanContext.deserialize(span_context)
798
+
682
799
  @classmethod
683
800
  def set_session(
684
801
  cls,
@@ -1,6 +1,9 @@
1
+ import logging
1
2
  import aiohttp
2
3
  import datetime
3
4
  from enum import Enum
5
+ import json
6
+ from opentelemetry.trace import SpanContext, TraceFlags
4
7
  import pydantic
5
8
  from typing import Any, Awaitable, Callable, Optional, Union
6
9
  import uuid
@@ -210,3 +213,86 @@ class TracingLevel(Enum):
210
213
  OFF = 0
211
214
  META_ONLY = 1
212
215
  ALL = 2
216
+
217
+
218
+ class LaminarSpanContext(pydantic.BaseModel):
219
+ """
220
+ A span context that can be used to continue a trace across services. This
221
+ is a slightly modified version of the OpenTelemetry span context. For
222
+ usage examples, see `Laminar.get_laminar_span_context_dict`,
223
+ `Laminar.get_laminar_span_context_str`, `Laminar.get_span_context`, and
224
+ `Laminar.deserialize_laminar_span_context`.
225
+
226
+ The difference between this and the OpenTelemetry span context is that
227
+ the `trace_id` and `span_id` are stored as UUIDs instead of integers for
228
+ easier debugging, and the separate trace flags are not currently stored.
229
+ """
230
+
231
+ trace_id: uuid.UUID
232
+ span_id: uuid.UUID
233
+ is_remote: bool = pydantic.Field(default=False)
234
+
235
+ # uuid is not serializable by default, so we need to convert it to a string
236
+ def to_dict(self):
237
+ return {
238
+ "traceId": str(self.trace_id),
239
+ "spanId": str(self.span_id),
240
+ "isRemote": self.is_remote,
241
+ }
242
+
243
+ @classmethod
244
+ def from_dict(cls, data: dict[str, Any]) -> "LaminarSpanContext":
245
+ return cls(
246
+ trace_id=uuid.UUID(data["traceId"]),
247
+ span_id=uuid.UUID(data["spanId"]),
248
+ is_remote=data["isRemote"],
249
+ )
250
+
251
+ @classmethod
252
+ def try_to_otel_span_context(
253
+ cls,
254
+ span_context: Union["LaminarSpanContext", dict[str, Any], str, SpanContext],
255
+ logger: Optional[logging.Logger] = None,
256
+ ) -> SpanContext:
257
+ if logger is None:
258
+ logger = logging.getLogger(__name__)
259
+
260
+ if isinstance(span_context, LaminarSpanContext):
261
+ return SpanContext(
262
+ trace_id=span_context.trace_id.int,
263
+ span_id=span_context.span_id.int,
264
+ is_remote=span_context.is_remote,
265
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
266
+ )
267
+ elif isinstance(span_context, SpanContext) or (
268
+ isinstance(getattr(span_context, "trace_id", None), int)
269
+ and isinstance(getattr(span_context, "span_id", None), int)
270
+ ):
271
+ logger.warning(
272
+ "span_context provided"
273
+ " is likely a raw OpenTelemetry span context. Will try to use it. "
274
+ "Please use `LaminarSpanContext` instead."
275
+ )
276
+ return span_context
277
+ elif isinstance(span_context, dict) or isinstance(span_context, str):
278
+ try:
279
+ laminar_span_context = cls.deserialize(span_context)
280
+ return SpanContext(
281
+ trace_id=laminar_span_context.trace_id.int,
282
+ span_id=laminar_span_context.span_id.int,
283
+ is_remote=laminar_span_context.is_remote,
284
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
285
+ )
286
+ except Exception:
287
+ raise ValueError("Invalid span_context provided")
288
+ else:
289
+ raise ValueError("Invalid span_context provided")
290
+
291
+ @classmethod
292
+ def deserialize(cls, data: Union[dict[str, Any], str]) -> "LaminarSpanContext":
293
+ if isinstance(data, dict):
294
+ return cls.from_dict(data)
295
+ elif isinstance(data, str):
296
+ return cls.from_dict(json.loads(data))
297
+ else:
298
+ raise ValueError("Invalid span_context provided")
@@ -0,0 +1,46 @@
1
+ import sys
2
+ import requests
3
+ from packaging import version
4
+
5
+
6
+ SDK_VERSION = "0.4.63b0"
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()
@@ -1,5 +0,0 @@
1
- import sys
2
-
3
-
4
- SDK_VERSION = "0.4.61"
5
- PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes