diff --git a/pyproject.toml b/pyproject.toml index 4f1f218d..aec8e50a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "lmnr" -version = "0.7.10" +version = "0.7.11" description = "Python SDK for Laminar" authors = [ { name = "lmnr.ai", email = "founders@lmnr.ai" } diff --git a/src/lmnr/opentelemetry_lib/tracing/_instrument_initializers.py b/src/lmnr/opentelemetry_lib/tracing/_instrument_initializers.py index 201a9eb8..bed77114 100644 --- a/src/lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +++ b/src/lmnr/opentelemetry_lib/tracing/_instrument_initializers.py @@ -51,9 +51,19 @@ def init_instrumentor(self, *args, **kwargs) -> BaseInstrumentor | None: class BrowserUseInstrumentorInitializer(InstrumentorInitializer): - def init_instrumentor( - self, client, async_client, *args, **kwargs - ) -> BaseInstrumentor | None: + """Instruments for different versions of browser-use: + + - browser-use < 0.5: BrowserUseLegacyInstrumentor to track agent_step and + other structure spans. Session instrumentation is controlled by + Instruments.PLAYWRIGHT (or Instruments.PATCHRIGHT for several versions + in 0.4.* that used patchright) + - browser-use ~= 0.5: Structure spans live in browser_use package itself. + Session instrumentation is controlled by Instruments.PLAYWRIGHT + - browser-use >= 0.6.0rc1: BubusInstrumentor to keep spans structure. + Session instrumentation is controlled by Instruments.BROWSER_USE_SESSION + """ + + def init_instrumentor(self, *args, **kwargs) -> BaseInstrumentor | None: if not is_package_installed("browser-use"): return None @@ -65,6 +75,19 @@ def init_instrumentor( return BrowserUseLegacyInstrumentor() + return None + + +class BrowserUseSessionInstrumentorInitializer(InstrumentorInitializer): + def init_instrumentor( + self, client, async_client, *args, **kwargs + ) -> BaseInstrumentor | None: + if not is_package_installed("browser-use"): + return None + + version = get_package_version("browser-use") + from packaging.version import parse + if version and parse(version) >= parse("0.6.0rc1"): from lmnr.sdk.browser.browser_use_cdp_otel import BrowserUseInstrumentor @@ -73,6 +96,16 @@ def init_instrumentor( return None +class BubusInstrumentorInitializer(InstrumentorInitializer): + def init_instrumentor(self, *args, **kwargs) -> BaseInstrumentor | None: + if not is_package_installed("bubus"): + return None + + from lmnr.sdk.browser.bubus_otel import BubusInstrumentor + + return BubusInstrumentor() + + class ChromaInstrumentorInitializer(InstrumentorInitializer): def init_instrumentor(self, *args, **kwargs) -> BaseInstrumentor | None: if not is_package_installed("chromadb"): diff --git a/src/lmnr/opentelemetry_lib/tracing/instruments.py b/src/lmnr/opentelemetry_lib/tracing/instruments.py index 909a132f..416057d3 100644 --- a/src/lmnr/opentelemetry_lib/tracing/instruments.py +++ b/src/lmnr/opentelemetry_lib/tracing/instruments.py @@ -17,6 +17,8 @@ class Instruments(Enum): ANTHROPIC = "anthropic" BEDROCK = "bedrock" BROWSER_USE = "browser_use" + BROWSER_USE_SESSION = "browser_use_session" + BUBUS = "bubus" CHROMA = "chroma" COHERE = "cohere" CREWAI = "crewai" @@ -60,6 +62,8 @@ class Instruments(Enum): Instruments.ANTHROPIC: initializers.AnthropicInstrumentorInitializer(), Instruments.BEDROCK: initializers.BedrockInstrumentorInitializer(), Instruments.BROWSER_USE: initializers.BrowserUseInstrumentorInitializer(), + Instruments.BROWSER_USE_SESSION: initializers.BrowserUseSessionInstrumentorInitializer(), + Instruments.BUBUS: initializers.BubusInstrumentorInitializer(), Instruments.CHROMA: initializers.ChromaInstrumentorInitializer(), Instruments.COHERE: initializers.CohereInstrumentorInitializer(), Instruments.CREWAI: initializers.CrewAIInstrumentorInitializer(), diff --git a/src/lmnr/sdk/browser/browser_use_cdp_otel.py b/src/lmnr/sdk/browser/browser_use_cdp_otel.py index 910d63a6..ced3d5cd 100644 --- a/src/lmnr/sdk/browser/browser_use_cdp_otel.py +++ b/src/lmnr/sdk/browser/browser_use_cdp_otel.py @@ -1,3 +1,6 @@ +import asyncio +import uuid + from lmnr.sdk.client.asynchronous.async_client import AsyncLaminarClient from lmnr.sdk.browser.utils import with_tracer_and_client_wrapper from lmnr.version import __version__ @@ -12,7 +15,6 @@ from opentelemetry.trace import get_tracer, Tracer from typing import Collection from wrapt import wrap_function_wrapper -import uuid # Stable versions, e.g. 0.6.0, satisfy this condition too _instruments = ("browser-use >= 0.6.0rc1",) @@ -33,12 +35,7 @@ ] -@with_tracer_and_client_wrapper -async def _wrap( - tracer: Tracer, client: AsyncLaminarClient, to_wrap, wrapped, instance, args, kwargs -): - result = await wrapped(*args, **kwargs) - +async def process_wrapped_result(result, instance, client, to_wrap): if to_wrap.get("action") == "inject_session_recorder": is_registered = await is_recorder_present(result) if not is_registered: @@ -50,6 +47,14 @@ async def _wrap( cdp_session = await instance.get_or_create_cdp_session(target_id) await take_full_snapshot(cdp_session) + +@with_tracer_and_client_wrapper +async def _wrap( + tracer: Tracer, client: AsyncLaminarClient, to_wrap, wrapped, instance, args, kwargs +): + result = await wrapped(*args, **kwargs) + asyncio.create_task(process_wrapped_result(result, instance, client, to_wrap)) + return result diff --git a/src/lmnr/sdk/browser/bubus_otel.py b/src/lmnr/sdk/browser/bubus_otel.py new file mode 100644 index 00000000..55155c18 --- /dev/null +++ b/src/lmnr/sdk/browser/bubus_otel.py @@ -0,0 +1,71 @@ +from typing import Collection + +from lmnr import Laminar +from lmnr.opentelemetry_lib.tracing.context import get_current_context +from lmnr.sdk.log import get_default_logger + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.trace import NonRecordingSpan, get_current_span +from wrapt import wrap_function_wrapper + + +_instruments = ("bubus >= 1.3.0",) +event_id_to_span_context = {} +logger = get_default_logger(__name__) + + +def wrap_dispatch(wrapped, instance, args, kwargs): + event = args[0] if args and len(args) > 0 else kwargs.get("event", None) + if event and hasattr(event, "event_id"): + event_id = event.event_id + if event_id: + span = get_current_span(get_current_context()) + event_id_to_span_context[event_id] = span.get_span_context() + return wrapped(*args, **kwargs) + + +async def wrap_process_event(wrapped, instance, args, kwargs): + event = args[0] if args and len(args) > 0 else kwargs.get("event", None) + span_context = None + if event and hasattr(event, "event_id"): + event_id = event.event_id + if event_id: + span_context = event_id_to_span_context.get(event_id) + if not span_context: + return await wrapped(*args, **kwargs) + if not Laminar.is_initialized(): + return await wrapped(*args, **kwargs) + with Laminar.use_span(NonRecordingSpan(span_context)): + return await wrapped(*args, **kwargs) + + +class BubusInstrumentor(BaseInstrumentor): + def __init__(self): + super().__init__() + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + try: + wrap_function_wrapper("bubus.service", "EventBus.dispatch", wrap_dispatch) + except (ModuleNotFoundError, ImportError): + pass + try: + wrap_function_wrapper( + "bubus.service", "EventBus.process_event", wrap_process_event + ) + except (ModuleNotFoundError, ImportError): + pass + + def _uninstrument(self, **kwargs): + try: + unwrap("bubus.service", "EventBus.dispatch") + except (ModuleNotFoundError, ImportError): + pass + try: + unwrap("bubus.service", "EventBus.process_event") + except (ModuleNotFoundError, ImportError): + pass + event_id_to_span_context.clear() diff --git a/src/lmnr/sdk/browser/cdp_utils.py b/src/lmnr/sdk/browser/cdp_utils.py index a8136b67..eae4e274 100644 --- a/src/lmnr/sdk/browser/cdp_utils.py +++ b/src/lmnr/sdk/browser/cdp_utils.py @@ -1,8 +1,8 @@ -import orjson +import asyncio import logging +import orjson import os import time -import asyncio from opentelemetry import trace @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) OLD_BUFFER_TIMEOUT = 60 +CDP_OPERATION_TIMEOUT_SECONDS = 10 + +# CDP ContextId is int +frame_to_isolated_context_id: dict[str, int] = {} +lock = asyncio.Lock() current_dir = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(current_dir, "recorder", "record.umd.min.cjs"), "r") as f: @@ -448,8 +453,6 @@ } } - setInterval(sendBatchIfReady, BATCH_TIMEOUT); - async function bufferToBase64(buffer) { const base64url = await new Promise(r => { const reader = new FileReader() @@ -458,55 +461,152 @@ }); return base64url.slice(base64url.indexOf(',') + 1); } - - window.lmnrRrweb.record({ - async emit(event) { - try { - const isLarge = isLargeEvent(event.type); - const compressedResult = isLarge ? - await compressLargeObject(event.data) : - await compressSmallObject(event.data); - - const base64Data = await bufferToBase64(compressedResult); - const eventToSend = { - ...event, - data: base64Data, - }; - window.lmnrRrwebEventsBatch.push(eventToSend); - } catch (error) { - console.warn('Failed to push event to batch', error); + + if (!window.lmnrStartedRecordingEvents) { + setInterval(sendBatchIfReady, BATCH_TIMEOUT); + + window.lmnrRrweb.record({ + async emit(event) { + try { + const isLarge = isLargeEvent(event.type); + const compressedResult = isLarge ? + await compressLargeObject(event.data) : + await compressSmallObject(event.data); + + const base64Data = await bufferToBase64(compressedResult); + const eventToSend = { + ...event, + data: base64Data, + }; + window.lmnrRrwebEventsBatch.push(eventToSend); + } catch (error) { + console.warn('Failed to push event to batch', error); + } + }, + recordCanvas: true, + collectFonts: true, + recordCrossOriginIframes: true, + maskInputOptions: { + password: true, + textarea: maskInputOptions.textarea || false, + text: maskInputOptions.text || false, + number: maskInputOptions.number || false, + select: maskInputOptions.select || false, + email: maskInputOptions.email || false, + tel: maskInputOptions.tel || false, } - }, - recordCanvas: true, - collectFonts: true, - recordCrossOriginIframes: true, - maskInputOptions: { - password: true, - textarea: maskInputOptions.textarea || false, - text: maskInputOptions.text || false, - number: maskInputOptions.number || false, - select: maskInputOptions.select || false, - email: maskInputOptions.email || false, - tel: maskInputOptions.tel || false, + }); + + function heartbeat() { + // Add heartbeat events + setInterval(() => { + window.lmnrRrweb.record.addCustomEvent('heartbeat', { + title: document.title, + url: document.URL, + }) + }, HEARTBEAT_INTERVAL + ); } - }); - - function heartbeat() { - // Add heartbeat events - setInterval(() => { - window.lmnrRrweb.record.addCustomEvent('heartbeat', { - title: document.title, - url: document.URL, - }) - }, HEARTBEAT_INTERVAL - ); - } - heartbeat(); + heartbeat(); + window.lmnrStartedRecordingEvents = true; + } } """ +async def should_skip_page(cdp_session): + """Checks if the page url is an error page or an empty page. + This function returns True in case of any error in our code, because + it is safer to not record events than to try to inject the recorder + into something that is already broken. + """ + cdp_client = cdp_session.cdp_client + + try: + # Get the current page URL + result = await asyncio.wait_for( + cdp_client.send.Runtime.evaluate( + { + "expression": "window.location.href", + "returnByValue": True, + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, + ) + + url = result.get("result", {}).get("value", "") + + # Comprehensive list of browser error URLs + error_url_patterns = [ + "about:blank", + # Chrome error pages + "chrome-error://", + "chrome://network-error/", + "chrome://network-errors/", + # Chrome crash and debugging pages + "chrome://crash/", + "chrome://crashdump/", + "chrome://kill/", + "chrome://hang/", + "chrome://shorthang/", + "chrome://gpuclean/", + "chrome://gpucrash/", + "chrome://gpuhang/", + "chrome://memory-exhaust/", + "chrome://memory-pressure-critical/", + "chrome://memory-pressure-moderate/", + "chrome://inducebrowsercrashforrealz/", + "chrome://inducebrowserdcheckforrealz/", + "chrome://inducebrowserheapcorruption/", + "chrome://heapcorruptioncrash/", + "chrome://badcastcrash/", + "chrome://ppapiflashcrash/", + "chrome://ppapiflashhang/", + "chrome://quit/", + "chrome://restart/", + # Firefox error pages + "about:neterror", + "about:certerror", + "about:blocked", + # Firefox crash and debugging pages + "about:crashcontent", + "about:crashparent", + "about:crashes", + "about:tabcrashed", + # Edge error pages (similar to Chrome) + "edge-error://", + "edge://crash/", + "edge://kill/", + "edge://hang/", + # Safari/WebKit error indicators (data URLs with error content) + "webkit-error://", + ] + + # Check if current URL matches any error pattern + if any(url.startswith(pattern) for pattern in error_url_patterns): + logger.debug(f"Detected browser error page from URL: {url}") + return True + + # Additional check for data URLs that might contain error pages + if url.startswith("data:") and any( + error_term in url.lower() + for error_term in ["error", "crash", "failed", "unavailable", "not found"] + ): + logger.debug(f"Detected error page from data URL: {url[:100]}...") + return True + + return False + + except asyncio.TimeoutError: + logger.debug("Timeout error when checking if error page") + return True + except Exception as e: + logger.debug(f"Error during checking if error page: {e}") + return True + + def get_mask_input_setting() -> MaskInputOptions: """Get the mask_input setting from session recording configuration.""" try: @@ -534,12 +634,78 @@ def get_mask_input_setting() -> MaskInputOptions: ) -# browser_use.browser.session.CDPSession (browser-use >= 1.0.0) -async def inject_session_recorder(cdp_session): +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) +async def get_isolated_context_id(cdp_session) -> int | None: + async with lock: + tree = {} + try: + tree = await asyncio.wait_for( + cdp_session.cdp_client.send.Page.getFrameTree( + session_id=cdp_session.session_id + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.debug("Timeout error when getting frame tree") + return None + except Exception as e: + logger.debug(f"Failed to get frame tree: {e}") + return None + frame = tree.get("frameTree", {}).get("frame", {}) + frame_id = frame.get("id") + loader_id = frame.get("loaderId") + + if frame_id is None or loader_id is None: + logger.debug("Failed to get frame id or loader id") + return None + key = f"{frame_id}_{loader_id}" + + if key in frame_to_isolated_context_id: + return frame_to_isolated_context_id[key] + + try: + result = await asyncio.wait_for( + cdp_session.cdp_client.send.Page.createIsolatedWorld( + { + "frameId": frame_id, + "worldName": "laminar-isolated-context", + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.debug("Timeout error when getting isolated context id") + return None + except Exception as e: + logger.debug(f"Failed to get isolated context id: {e}") + return None + isolated_context_id = result["executionContextId"] + frame_to_isolated_context_id[key] = isolated_context_id + return isolated_context_id + + +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) +async def inject_session_recorder(cdp_session) -> int | None: + """Injects the session recorder base as well as the recorder itself. + Returns the isolated context id if successful. + """ + isolated_context_id = None cdp_client = cdp_session.cdp_client try: + should_skip = True try: - is_loaded = await is_recorder_present(cdp_session) + should_skip = await should_skip_page(cdp_session) + except Exception as e: + logger.debug(f"Failed to check if error page: {e}") + + if should_skip: + logger.debug("Empty page detected, skipping session recorder injection") + return + + isolated_context_id = await get_isolated_context_id(cdp_session) + try: + is_loaded = await is_recorder_present(cdp_session, isolated_context_id) except Exception as e: logger.debug(f"Failed to check if session recorder is loaded: {e}") is_loaded = False @@ -547,42 +713,60 @@ async def inject_session_recorder(cdp_session): if is_loaded: return + if isolated_context_id is None: + logger.debug("Failed to get isolated context id") + return + async def load_session_recorder(): try: - await cdp_client.send.Runtime.evaluate( - { - "expression": f"({RRWEB_CONTENT})()", - "awaitPromise": True, - }, - session_id=cdp_session.session_id, + await asyncio.wait_for( + cdp_client.send.Runtime.evaluate( + { + "expression": f"({RRWEB_CONTENT})()", + "contextId": isolated_context_id, + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, ) return True + except asyncio.TimeoutError: + logger.debug("Timeout error when loading session recorder base") + return False except Exception as e: - logger.error(f"Failed to load session recorder: {e}") + logger.debug(f"Failed to load session recorder base: {e}") return False if not await retry_async( load_session_recorder, + retries=3, delay=1, error_message="Failed to load session recorder", ): return try: - await cdp_client.send.Runtime.evaluate( - { - "expression": f"({INJECT_PLACEHOLDER})({orjson.dumps(get_mask_input_setting()).decode("utf-8")})", - }, - session_id=cdp_session.session_id, + await asyncio.wait_for( + cdp_client.send.Runtime.evaluate( + { + "expression": f"({INJECT_PLACEHOLDER})({orjson.dumps(get_mask_input_setting()).decode('utf-8')})", + "contextId": isolated_context_id, + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, ) + return isolated_context_id + except asyncio.TimeoutError: + logger.debug("Timeout error when injecting session recorder") except Exception as e: - logger.debug(f"Failed to inject session recorder placeholder: {e}") + logger.debug(f"Failed to inject recorder: {e}") except Exception as e: - logger.error(f"Error during session recorder injection: {e}") + logger.debug(f"Error during session recorder injection: {e}") -# browser_use.browser.session.CDPSession (browser-use >= 1.0.0) +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) @observe(name="cdp_use.session", ignore_input=True, ignore_output=True) async def start_recording_events( cdp_session, @@ -596,7 +780,10 @@ async def start_recording_events( trace_id = format(span.get_span_context().trace_id, "032x") span.set_attribute("lmnr.internal.has_browser_session", True) - await inject_session_recorder(cdp_session) + isolated_context_id = await inject_session_recorder(cdp_session) + if isolated_context_id is None: + logger.debug("Failed to inject session recorder, not registering bindings") + return # Buffer for reassembling chunks chunk_buffers = {} @@ -654,11 +841,14 @@ async def send_events_from_browser(chunk): async def send_events_callback(event, cdp_session_id: str | None = None): if event["name"] != "lmnrSendEvents": return + if event["executionContextId"] != isolated_context_id: + return await send_events_from_browser(orjson.loads(event["payload"])) await cdp_client.send.Runtime.addBinding( { "name": "lmnrSendEvents", + "executionContextId": isolated_context_id, }, session_id=cdp_session.session_id, ) @@ -668,7 +858,7 @@ async def send_events_callback(event, cdp_session_id: str | None = None): register_on_target_created(cdp_session, lmnr_session_id, client) -# browser_use.browser.session.CDPSession (browser-use >= 1.0.0) +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) async def enable_target_discovery(cdp_session): cdp_client = cdp_session.cdp_client await cdp_client.send.Target.setDiscoverTargets( @@ -679,7 +869,7 @@ async def enable_target_discovery(cdp_session): ) -# browser_use.browser.session.CDPSession (browser-use >= 1.0.0) +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) def register_on_target_created( cdp_session, lmnr_session_id: str, client: AsyncLaminarClient ): @@ -689,31 +879,63 @@ def on_target_created(event, cdp_session_id: str | None = None): if target_info["type"] == "page": asyncio.create_task(inject_session_recorder(cdp_session=cdp_session)) - cdp_session.cdp_client.register.Target.targetCreated(on_target_created) + try: + cdp_session.cdp_client.register.Target.targetCreated(on_target_created) + except Exception as e: + logger.debug(f"Failed to register on target created: {e}") -# browser_use.browser.session.CDPSession (browser-use >= 1.0.0) -async def is_recorder_present(cdp_session) -> bool: +# browser_use.browser.session.CDPSession (browser-use >= 0.6.0) +async def is_recorder_present( + cdp_session, isolated_context_id: int | None = None +) -> bool: + # This function returns True on any error, because it is safer to not record + # events than to try to inject the recorder into a broken context. cdp_client = cdp_session.cdp_client + if isolated_context_id is None: + isolated_context_id = await get_isolated_context_id(cdp_session) + if isolated_context_id is None: + logger.debug("Failed to get isolated context id") + return True - result = await cdp_client.send.Runtime.evaluate( - { - "expression": """(()=>{ - return typeof window.lmnrRrweb !== 'undefined'; - })()""", - }, - session_id=cdp_session.session_id, - ) - if result and "result" in result and "value" in result["result"]: - return result["result"]["value"] - return False + try: + result = await asyncio.wait_for( + cdp_client.send.Runtime.evaluate( + { + "expression": "typeof window.lmnrRrweb !== 'undefined'", + "contextId": isolated_context_id, + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, + ) + if result and "result" in result and "value" in result["result"]: + return result["result"]["value"] + return False + except asyncio.TimeoutError: + logger.debug("Timeout error when checking if session recorder is present") + return True + except Exception: + logger.debug("Exception when checking if session recorder is present") + return True async def take_full_snapshot(cdp_session): cdp_client = cdp_session.cdp_client - result = await cdp_client.send.Runtime.evaluate( - { - "expression": """(() => { + isolated_context_id = await get_isolated_context_id(cdp_session) + if isolated_context_id is None: + logger.debug("Failed to get isolated context id") + return False + + if await should_skip_page(cdp_session): + logger.debug("Skipping full snapshot") + return False + + try: + result = await asyncio.wait_for( + cdp_client.send.Runtime.evaluate( + { + "expression": """(() => { if (window.lmnrRrweb) { try { window.lmnrRrweb.record.takeFullSnapshot(); @@ -725,9 +947,18 @@ async def take_full_snapshot(cdp_session): } return false; })()""", - }, - session_id=cdp_session.session_id, - ) + "contextId": isolated_context_id, + }, + session_id=cdp_session.session_id, + ), + timeout=CDP_OPERATION_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.debug("Timeout error when taking full snapshot") + return False + except Exception as e: + logger.debug(f"Error when taking full snapshot: {e}") + return False if result and "result" in result and "value" in result["result"]: return result["result"]["value"] return False diff --git a/src/lmnr/sdk/evaluations.py b/src/lmnr/sdk/evaluations.py index 142880a1..6c916783 100644 --- a/src/lmnr/sdk/evaluations.py +++ b/src/lmnr/sdk/evaluations.py @@ -112,7 +112,12 @@ def __init__( base_http_url: str | None = None, http_port: int | None = None, grpc_port: int | None = None, - instruments: set[Instruments] | None = None, + instruments: ( + set[Instruments] | list[Instruments] | tuple[Instruments] | None + ) = None, + disabled_instruments: ( + set[Instruments] | list[Instruments] | tuple[Instruments] | None + ) = None, max_export_batch_size: int | None = MAX_EXPORT_BATCH_SIZE, trace_export_timeout_seconds: int | None = None, ): @@ -172,6 +177,10 @@ def __init__( used. See https://docs.lmnr.ai/tracing/automatic-instrumentation Defaults to None. + disabled_instruments (set[Instruments] | None, optional): Set of modules\ + to disable auto-instrumentations. If None, only modules passed\ + as `instruments` will be disabled. + Defaults to None. """ if not evaluators: @@ -234,6 +243,7 @@ def __init__( http_port=http_port, grpc_port=grpc_port, instruments=instruments, + disabled_instruments=disabled_instruments, max_export_batch_size=max_export_batch_size, export_timeout_seconds=trace_export_timeout_seconds, ) @@ -432,7 +442,12 @@ def evaluate( base_http_url: str | None = None, http_port: int | None = None, grpc_port: int | None = None, - instruments: set[Instruments] | None = None, + instruments: ( + set[Instruments] | list[Instruments] | tuple[Instruments] | None + ) = None, + disabled_instruments: ( + set[Instruments] | list[Instruments] | tuple[Instruments] | None + ) = None, max_export_batch_size: int | None = MAX_EXPORT_BATCH_SIZE, trace_export_timeout_seconds: int | None = None, ) -> Awaitable[None] | None: @@ -493,6 +508,10 @@ def evaluate( auto-instrument. If None, all available instruments\ will be used. Defaults to None. + disabled_instruments (set[Instruments] | None, optional): Set of modules\ + to disable auto-instrumentations. If None, no\ + If None, only modules passed as `instruments` will be disabled. + Defaults to None. trace_export_timeout_seconds (int | None, optional): The timeout for\ trace export on OpenTelemetry exporter. Defaults to None. """ @@ -510,6 +529,7 @@ def evaluate( http_port=http_port, grpc_port=grpc_port, instruments=instruments, + disabled_instruments=disabled_instruments, max_export_batch_size=max_export_batch_size, trace_export_timeout_seconds=trace_export_timeout_seconds, ) diff --git a/src/lmnr/sdk/laminar.py b/src/lmnr/sdk/laminar.py index 49dc657d..9af3999a 100644 --- a/src/lmnr/sdk/laminar.py +++ b/src/lmnr/sdk/laminar.py @@ -421,14 +421,14 @@ def start_span( Usage example: ```python - from src.lmnr import Laminar, use_span + from src.lmnr import Laminar def foo(span): - with use_span(span): + with Laminar.use_span(span): with Laminar.start_as_current_span("foo_inner"): some_function() def bar(): - with use_span(span): + with Laminar.use_span(span): openai_client.chat.completions.create() span = Laminar.start_span("outer") diff --git a/src/lmnr/version.py b/src/lmnr/version.py index 98bdc785..88145e64 100644 --- a/src/lmnr/version.py +++ b/src/lmnr/version.py @@ -3,7 +3,7 @@ from packaging import version -__version__ = "0.7.10" +__version__ = "0.7.11" PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"