From aaff525ddae53056d09cec815d46b2acd1b570a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:28:02 +0000 Subject: [PATCH 1/2] feat(client): support organization API keys with project_id Organization API keys can write to any project in their organization, so each request has to name its target project in the x-langfuse-project-id header. Add a project_id argument (env LANGFUSE_PROJECT_ID) to Langfuse() and identify a client by (public_key, project_id) everywhere: - LangfuseResourceManager instances are keyed by the pair, so a second client on the same org key for another project no longer silently reuses the first client's setup. - Tracer scope attributes carry project_id and LangfuseSpanProcessor filters on both, so two clients sharing an org key never export each other's spans. - get_client() and @observe accept project_id / langfuse_project_id; an org key shared by several clients without a project_id resolves to a disabled client. The header is sent on OTLP exports and on the generated API clients (so a caller-provided httpx_client sends it too). A conflicting project header in additional_headers or httpx_client.headers raises ValueError at init. A configured project_id is used for trace and experiment URLs without calling projects.get(). A custom span_exporter with project_id warns that the exporter must set the header, and a 403 span export logs a one-time hint to set LANGFUSE_PROJECT_ID. Project API keys leave project_id unset, so their behavior is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PExPf2jnm8CPvdxKYmfqt --- .env.template | 2 + langfuse/__init__.py | 3 +- langfuse/_client/client.py | 41 +++- langfuse/_client/constants.py | 6 + langfuse/_client/environment_variables.py | 10 + langfuse/_client/get_client.py | 98 ++++++--- langfuse/_client/observe.py | 19 +- langfuse/_client/resource_manager.py | 58 +++-- langfuse/_client/span_processor.py | 85 ++++++-- tests/conftest.py | 1 + tests/e2e/test_decorators.py | 5 +- tests/unit/test_additional_headers_simple.py | 218 ++++++++++++++++++- tests/unit/test_otel.py | 46 ++++ tests/unit/test_resource_manager.py | 163 ++++++++++++++ 14 files changed, 686 insertions(+), 69 deletions(-) diff --git a/.env.template b/.env.template index 77541addf..982b5d245 100644 --- a/.env.template +++ b/.env.template @@ -2,6 +2,8 @@ LANGFUSE_BASE_URL=http://localhost:3000 LANGFUSE_PUBLIC_KEY=pk-lf-1234567890 LANGFUSE_SECRET_KEY=sk-lf-1234567890 +# Only for organization API keys: the project to write to +# LANGFUSE_PROJECT_ID= # Optionally for E2E tests OPENAI_API_KEY= diff --git a/langfuse/__init__.py b/langfuse/__init__.py index 222d01079..decd2064b 100644 --- a/langfuse/__init__.py +++ b/langfuse/__init__.py @@ -41,7 +41,8 @@ ``` Configuration is via constructor args or environment variables: `LANGFUSE_PUBLIC_KEY`, -`LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` (defaults to https://cloud.langfuse.com). See `langfuse._client.environment_variables` +`LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` (defaults to https://cloud.langfuse.com), and +`LANGFUSE_PROJECT_ID` (required with organization API keys). See `langfuse._client.environment_variables` for the full list. Docs: https://langfuse.com/docs — machine-readable index: https://langfuse.com/llms.txt diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index e926e4571..6a6c9af05 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -19,6 +19,7 @@ Dict, List, Literal, + Mapping, Optional, Tuple, Type, @@ -46,6 +47,7 @@ ) from langfuse._client.constants import ( LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT, + PROJECT_ID_HEADER_NAMES, ObservationTypeGenerationLike, ObservationTypeLiteralNoEvent, ObservationTypeSpanLike, @@ -56,6 +58,7 @@ LANGFUSE_BASE_URL, LANGFUSE_DEBUG, LANGFUSE_HOST, + LANGFUSE_PROJECT_ID, LANGFUSE_PUBLIC_KEY, LANGFUSE_RELEASE, LANGFUSE_SAMPLE_RATE, @@ -173,6 +176,34 @@ def _serialize_evaluations(evaluations: List[Evaluation]) -> List[Dict[str, Any] ] +def _validate_project_id_headers( + project_id: Optional[str], + additional_headers: Optional[Dict[str, str]], + httpx_client: Optional[httpx.Client], +) -> None: + """Raise if a user-supplied project header names a different project.""" + if project_id is None: + return + + sources: List[Tuple[str, Mapping[str, str]]] = [] + if additional_headers: + sources.append(("additional_headers", additional_headers)) + + client_headers = getattr(httpx_client, "headers", None) + if isinstance(client_headers, Mapping): + sources.append(("httpx_client.headers", client_headers)) + + for source, headers in sources: + for name, value in headers.items(): + if name.lower() in PROJECT_ID_HEADER_NAMES and value != project_id: + message = ( + f"Conflicting project IDs: project_id is '{project_id}' but " + f"{source} sets '{name}: {value}'. Remove the header or make " + "the values match." + ) + raise ValueError(message) + + class Langfuse: """Main client for Langfuse tracing and platform features. @@ -195,6 +226,7 @@ class Langfuse: Parameters: public_key (Optional[str]): Your Langfuse public API key. Can also be set via LANGFUSE_PUBLIC_KEY environment variable. secret_key (Optional[str]): Your Langfuse secret API key. Can also be set via LANGFUSE_SECRET_KEY environment variable. + project_id (Optional[str]): ID of the Langfuse project this client sends data to. Required with organization API keys, which can write to any project in their organization; leave it unset for project API keys. Can also be set via LANGFUSE_PROJECT_ID environment variable; the argument takes precedence. When set, the client sends it in the `x-langfuse-project-id` header on span exports and API requests, and uses it for trace and experiment URLs. Each client is bound to one project: create one client per project, and look them up with `get_client(public_key=..., project_id=...)`. Raises `ValueError` if `additional_headers` or `httpx_client.headers` set `x-langfuse-project-id` or `langfuse-project-id` to a different value. base_url (Optional[str]): The Langfuse API base URL. Defaults to "https://cloud.langfuse.com". Can also be set via LANGFUSE_BASE_URL environment variable. host (Optional[str]): Deprecated. Use base_url instead. The Langfuse API host URL. Defaults to "https://cloud.langfuse.com". timeout (Optional[int]): Timeout in seconds for API requests. Defaults to 5 seconds. @@ -305,6 +337,7 @@ def __init__( secret_key: Optional[str] = None, base_url: Optional[str] = None, host: Optional[str] = None, + project_id: Optional[str] = None, timeout: Optional[int] = None, httpx_client: Optional[httpx.Client] = None, debug: bool = False, @@ -339,7 +372,10 @@ def __init__( or os.environ.get(LANGFUSE_RELEASE, None) or get_common_release_envs() ) - self._project_id: Optional[str] = None + project_id = project_id or os.environ.get(LANGFUSE_PROJECT_ID) or None + _validate_project_id_headers(project_id, additional_headers, httpx_client) + # Cache read by _get_project_id(); a configured project needs no lookup + self._project_id: Optional[str] = project_id if sample_rate is None: sample_rate = float(os.environ.get(LANGFUSE_SAMPLE_RATE, 1.0)) if not 0.0 <= sample_rate <= 1.0: @@ -395,6 +431,7 @@ def __init__( public_key=public_key, secret_key=secret_key, base_url=self._base_url, + project_id=project_id, timeout=timeout, environment=self._environment, release=release, @@ -2373,7 +2410,7 @@ def get_current_observation_id(self) -> Optional[str]: return self._get_otel_span_id(current_otel_span) if current_otel_span else None def _get_project_id(self) -> Optional[str]: - """Fetch and return the current project id. Persisted across requests. Returns None if no project id is found for api keys.""" + """Return the configured project id, or fetch and cache it for project-bound api keys. Returns None if no project id is found.""" if not self._project_id: proj = self.api.projects.get() if not proj.data or not proj.data[0].id: diff --git a/langfuse/_client/constants.py b/langfuse/_client/constants.py index 95968507a..472eed119 100644 --- a/langfuse/_client/constants.py +++ b/langfuse/_client/constants.py @@ -9,6 +9,12 @@ LANGFUSE_TRACER_NAME = "langfuse-sdk" +PROJECT_ID_HEADER = "x-langfuse-project-id" +"""Header naming the target project. Required with organization API keys.""" + +# Lowercase header names the server reads as the target project +PROJECT_ID_HEADER_NAMES = (PROJECT_ID_HEADER, "langfuse-project-id") + LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT = "sdk-experiment" MASK_FALLBACK_VALUE = "" diff --git a/langfuse/_client/environment_variables.py b/langfuse/_client/environment_variables.py index 3d2c84a92..7e27dca4b 100644 --- a/langfuse/_client/environment_variables.py +++ b/langfuse/_client/environment_variables.py @@ -35,6 +35,16 @@ Secret API key of Langfuse project """ +LANGFUSE_PROJECT_ID = "LANGFUSE_PROJECT_ID" +""" +.. envvar:: LANGFUSE_PROJECT_ID + +ID of the Langfuse project the client sends data to. Required with organization API +keys, which can write to any project in their organization. When set, the SDK sends +it on every request in the ``x-langfuse-project-id`` header. Leave it unset for +project API keys. +""" + LANGFUSE_BASE_URL = "LANGFUSE_BASE_URL" """ .. envvar:: LANGFUSE_BASE_URL diff --git a/langfuse/_client/get_client.py b/langfuse/_client/get_client.py index c1355872f..2008aeb55 100644 --- a/langfuse/_client/get_client.py +++ b/langfuse/_client/get_client.py @@ -1,32 +1,37 @@ from contextlib import contextmanager from contextvars import ContextVar -from typing import Iterator, Optional +from typing import Iterator, List, Optional, Tuple from langfuse._client.client import Langfuse from langfuse._client.resource_manager import LangfuseResourceManager from langfuse.logger import langfuse_logger -# Context variable to track the current langfuse_public_key in execution context -_current_public_key: ContextVar[Optional[str]] = ContextVar( - "langfuse_public_key", default=None +# Context variable to track the current (langfuse_public_key, langfuse_project_id) +# in execution context +_current_public_key: ContextVar[Optional[Tuple[Optional[str], Optional[str]]]] = ( + ContextVar("langfuse_public_key", default=None) ) @contextmanager -def _set_current_public_key(public_key: Optional[str]) -> Iterator[None]: - """Context manager to set and restore the current public key in execution context. +def _set_current_public_key( + public_key: Optional[str], project_id: Optional[str] = None +) -> Iterator[None]: + """Context manager to set and restore the current client identity in execution context. Args: - public_key: The public key to set in context. If None, context is not modified. + public_key: The public key to set in context. + project_id: The project ID to set in context. + If both are None, context is not modified. Yields: None """ - if public_key is None: + if public_key is None and project_id is None: yield # Don't modify context if no key provided return - token = _current_public_key.set(public_key) + token = _current_public_key.set((public_key, project_id)) try: yield finally: @@ -41,6 +46,7 @@ def _create_client_from_instance( public_key=public_key or instance.public_key, secret_key=instance.secret_key, base_url=instance.base_url, + project_id=instance.project_id, tracing_enabled=instance.tracing_enabled, environment=instance.environment, timeout=instance.timeout, @@ -62,29 +68,39 @@ def _create_client_from_instance( ) -def get_client(*, public_key: Optional[str] = None) -> Langfuse: +def get_client( + *, public_key: Optional[str] = None, project_id: Optional[str] = None +) -> Langfuse: """Get or create a Langfuse client instance. Returns an existing Langfuse client or creates a new one if none exists. In multi-project setups, providing a public_key is required. Multi-project support is experimental - see Langfuse docs. + A client is identified by its public key and its project ID. Project API keys leave + the project ID unset. An organization API key can back several clients, one per + project, so pass the project_id as well to pick one of them. + Behavior: - Single project: Returns existing client or creates new one - - Multi-project: Requires public_key to return specific client - - No public_key in multi-project: Returns disabled client to prevent data leakage + - Multi-project: Requires public_key (and project_id for an organization key shared + by several clients) to return a specific client + - No public_key in multi-project, or a public_key shared by several clients without + a project_id: Returns disabled client to prevent data leakage - The function uses a singleton pattern per public_key to conserve resources and maintain state. + The function uses a singleton pattern per (public_key, project_id) to conserve resources and maintain state. Args: public_key (Optional[str]): Project identifier - With key: Returns client for that project - Without key: Returns single client or disabled client if multiple exist + project_id (Optional[str]): Project ID the client was created with. Only needed + to choose between several clients that share an organization API key. Returns: Langfuse: Client instance in one of three states: - 1. Client for specified public_key + 1. Client for specified public_key (and project_id) 2. Default client for single-project setup - 3. Disabled client when multiple projects exist without key + 3. Disabled client when the lookup matches no client or more than one Security: Disables tracing when multiple projects exist without explicit key to prevent @@ -101,16 +117,24 @@ def get_client(*, public_key: Optional[str] = None) -> Langfuse: # Without specific key in multi-project setup: client = get_client() # Returns disabled client for safety + + # One organization API key writing to two projects: + Langfuse(public_key="org_key", secret_key="...", project_id="project_a_id") + Langfuse(public_key="org_key", secret_key="...", project_id="project_b_id") + + client_a = get_client(public_key="org_key", project_id="project_a_id") + client_b = get_client(public_key="org_key", project_id="project_b_id") + client = get_client(public_key="org_key") # Ambiguous: disabled client ``` """ with LangfuseResourceManager._lock: active_instances = LangfuseResourceManager._instances - # If no explicit public_key provided, check execution context - if not public_key: - public_key = _current_public_key.get(None) + # If no explicit client identity provided, check execution context + if not public_key and not project_id: + public_key, project_id = _current_public_key.get(None) or (None, None) - if not public_key: + if not public_key and not project_id: if len(active_instances) == 0: # No clients initialized yet, create default instance return Langfuse() @@ -135,21 +159,39 @@ def get_client(*, public_key: Optional[str] = None) -> Langfuse: ) else: - # Specific key provided, look up existing instance - target_instance: Optional[LangfuseResourceManager] = active_instances.get( - public_key, None - ) + # Specific identity provided, look up matching instances + matching_instances: List[LangfuseResourceManager] = [ + instance + for (instance_public_key, instance_project_id), instance in ( + active_instances.items() + ) + if (not public_key or instance_public_key == public_key) + and (not project_id or instance_project_id == project_id) + ] - if target_instance is None: + if len(matching_instances) == 0: # No instance found with this key - client not initialized properly langfuse_logger.warning( - "No Langfuse client with public key %s has been initialized. Skipping " - "tracing for decorated function.", + "No Langfuse client with public key %s and project id %s has been " + "initialized. Skipping tracing for decorated function.", + public_key, + project_id, + ) + return Langfuse( + tracing_enabled=False, public_key="fake", secret_key="fake" + ) + + if len(matching_instances) > 1: + # Several projects share this organization key - disable tracing + # to prevent cross-project data leakage + langfuse_logger.warning( + "Multiple Langfuse clients with public key %s are instantiated for " + "different projects, but no 'langfuse_project_id' was passed. " + "Skipping tracing to avoid cross-project leakage.", public_key, ) return Langfuse( tracing_enabled=False, public_key="fake", secret_key="fake" ) - # target_instance is guaranteed to be not None at this point - return _create_client_from_instance(target_instance, public_key) + return _create_client_from_instance(matching_instances[0], public_key) diff --git a/langfuse/_client/observe.py b/langfuse/_client/observe.py index 5aa88ce2e..cfca0b9e4 100644 --- a/langfuse/_client/observe.py +++ b/langfuse/_client/observe.py @@ -189,6 +189,7 @@ def sub_process(): - langfuse_trace_id: Explicitly set the trace ID for this function call - langfuse_parent_observation_id: Explicitly set the parent span ID - langfuse_public_key: Use a specific Langfuse project (when multiple clients exist) + - langfuse_project_id: Use the client for this project (when several clients share an organization API key) - For async functions, the decorator returns an async function wrapper. - For sync functions, the decorator returns a synchronous wrapper. """ @@ -286,10 +287,13 @@ async def async_wrapper(*args: Tuple[Any], **kwargs: Dict[str, Any]) -> Any: else None ) public_key = cast(str, kwargs.pop("langfuse_public_key", None)) + project_id = cast(Optional[str], kwargs.pop("langfuse_project_id", None)) - # Set public key in execution context for nested decorated functions - with _set_current_public_key(public_key): - langfuse_client = get_client(public_key=public_key) + # Set client identity in execution context for nested decorated functions + with _set_current_public_key(public_key, project_id): + langfuse_client = get_client( + public_key=public_key, project_id=project_id + ) context_manager: Optional[ Union[ _AgnosticContextManager[LangfuseGeneration], @@ -377,10 +381,13 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: else None ) public_key = kwargs.pop("langfuse_public_key", None) + project_id = kwargs.pop("langfuse_project_id", None) - # Set public key in execution context for nested decorated functions - with _set_current_public_key(public_key): - langfuse_client = get_client(public_key=public_key) + # Set client identity in execution context for nested decorated functions + with _set_current_public_key(public_key, project_id): + langfuse_client = get_client( + public_key=public_key, project_id=project_id + ) context_manager: Optional[ Union[ _AgnosticContextManager[LangfuseGeneration], diff --git a/langfuse/_client/resource_manager.py b/langfuse/_client/resource_manager.py index 1c1afa424..d7386345d 100644 --- a/langfuse/_client/resource_manager.py +++ b/langfuse/_client/resource_manager.py @@ -21,7 +21,7 @@ import urllib.request import weakref from queue import Full, Queue -from typing import Any, Callable, Dict, List, Literal, Optional, cast +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, cast import httpx from opentelemetry import trace as otel_trace_api @@ -33,7 +33,7 @@ from opentelemetry.trace import Tracer from langfuse._client.attributes import LangfuseOtelSpanAttributes -from langfuse._client.constants import LANGFUSE_TRACER_NAME +from langfuse._client.constants import LANGFUSE_TRACER_NAME, PROJECT_ID_HEADER from langfuse._client.environment_variables import ( LANGFUSE_MEDIA_UPLOAD_ENABLED, LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT, @@ -53,11 +53,24 @@ from .._version import __version__ as langfuse_version +def _get_tracer_attributes( + public_key: str, project_id: Optional[str] +) -> Dict[str, str]: + """Return the instrumentation-scope attributes that identify a client's spans.""" + attributes = {"public_key": public_key} + if project_id is not None: + attributes["project_id"] = project_id + + return attributes + + class LangfuseResourceManager: """Thread-safe singleton that provides access to the OpenTelemetry tracer and processors. - This class implements a thread-safe singleton pattern keyed by the public API key, - ensuring that only one tracer instance exists per API key combination. It manages + This class implements a thread-safe singleton pattern keyed by the public API key + and the configured project ID, ensuring that only one tracer instance exists per + combination. Organization API keys can write to many projects, so clients sharing a + public key but targeting different projects get separate instances. It manages the lifecycle of the OpenTelemetry tracer provider, span processors, and resource attributes, as well as background threads for media uploads and score delivery. @@ -79,7 +92,7 @@ class LangfuseResourceManager: making this implementation suitable for multi-threaded and asyncio applications. """ - _instances: Dict[str, "LangfuseResourceManager"] = {} + _instances: Dict[Tuple[str, Optional[str]], "LangfuseResourceManager"] = {} _lock = threading.RLock() _otel_tracer: Tracer _media_manager: MediaManager @@ -115,6 +128,7 @@ def __new__( public_key: str, secret_key: str, base_url: str, + project_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, timeout: Optional[int] = None, @@ -134,24 +148,27 @@ def __new__( otel_compression: Optional[Literal["gzip", "none"]] = None, skills: Optional[SkillsConfig] = None, ) -> "LangfuseResourceManager": - if public_key in cls._instances: - return cls._instances[public_key] + instance_key = (public_key, project_id) + + if instance_key in cls._instances: + return cls._instances[instance_key] with cls._lock: - if public_key not in cls._instances: + if instance_key not in cls._instances: instance = super(LangfuseResourceManager, cls).__new__(cls) # Initialize tracer (will be noop until init instance) instance._otel_tracer = otel_trace_api.get_tracer( LANGFUSE_TRACER_NAME, langfuse_version, - attributes={"public_key": public_key}, + attributes=_get_tracer_attributes(public_key, project_id), ) instance._initialize_instance( public_key=public_key, secret_key=secret_key, base_url=base_url, + project_id=project_id, timeout=timeout, environment=environment, release=release, @@ -174,9 +191,9 @@ def __new__( skills=skills, ) - cls._instances[public_key] = instance + cls._instances[instance_key] = instance - return cls._instances[public_key] + return cls._instances[instance_key] def _initialize_instance( self, @@ -184,6 +201,7 @@ def _initialize_instance( public_key: str, secret_key: str, base_url: str, + project_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, timeout: Optional[int] = None, @@ -204,6 +222,7 @@ def _initialize_instance( skills: Optional[SkillsConfig] = None, ) -> None: self.public_key = public_key + self.project_id = project_id self.secret_key = secret_key self.tracing_enabled = tracing_enabled self.base_url = base_url @@ -255,6 +274,7 @@ def _initialize_instance( public_key=self.public_key, secret_key=secret_key, base_url=base_url, + project_id=self.project_id, timeout=timeout, flush_at=flush_at, flush_interval=flush_interval, @@ -271,7 +291,7 @@ def _initialize_instance( self._otel_tracer = tracer_provider.get_tracer( LANGFUSE_TRACER_NAME, langfuse_version, - attributes={"public_key": self.public_key}, + attributes=_get_tracer_attributes(self.public_key, self.project_id), ) self._init_consumer_threads() @@ -307,8 +327,10 @@ def _initialize_instance( langfuse_logger.info( "Startup: Langfuse tracer successfully initialized | public_key=%s | " - "base_url=%s | environment=%s | sample_rate=%s | media_threads=%s", + "project_id=%s | base_url=%s | environment=%s | sample_rate=%s | " + "media_threads=%s", self.public_key, + self.project_id, base_url, environment or "default", sample_rate if sample_rate is not None else 1.0, @@ -349,6 +371,14 @@ def _init_api_clients(self) -> None: timeout=self.timeout, headers=client_headers ) + # Set on the generated clients rather than the httpx client, so a + # caller-provided httpx_client sends it too. + api_headers = ( + {PROJECT_ID_HEADER: self.project_id} + if self.project_id is not None + else None + ) + self.api = LangfuseAPI( base_url=self.base_url, username=self.public_key, @@ -356,6 +386,7 @@ def _init_api_clients(self) -> None: x_langfuse_sdk_name="python", x_langfuse_sdk_version=langfuse_version, x_langfuse_public_key=self.public_key, + headers=api_headers, httpx_client=self.httpx_client, timeout=self.timeout, ) @@ -366,6 +397,7 @@ def _init_api_clients(self) -> None: x_langfuse_sdk_name="python", x_langfuse_sdk_version=langfuse_version, x_langfuse_public_key=self.public_key, + headers=api_headers, timeout=self.timeout, ) diff --git a/langfuse/_client/span_processor.py b/langfuse/_client/span_processor.py index b6ebbbce6..0b4f20587 100644 --- a/langfuse/_client/span_processor.py +++ b/langfuse/_client/span_processor.py @@ -30,6 +30,7 @@ from opentelemetry.trace import format_span_id, format_trace_id from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.constants import PROJECT_ID_HEADER from langfuse._client.environment_variables import ( LANGFUSE_FLUSH_AT, LANGFUSE_FLUSH_INTERVAL, @@ -127,6 +128,38 @@ def _resolve_compression(otel_compression: Optional[str]) -> Compression: return Compression.Gzip +class _ForbiddenExportHintFilter(logging.Filter): + """Log a one-time hint when the OTLP exporter reports a 403 export failure. + + The exporter logs failures but does not return the status code, so this filter + watches its log records. It never suppresses a record. + """ + + def __init__(self) -> None: + super().__init__() + self._hint_logged = False + + def filter(self, record: logging.LogRecord) -> bool: + if ( + not self._hint_logged + and record.levelno >= logging.ERROR + and str(record.msg).startswith("Failed to export") + and isinstance(record.args, tuple) + and 403 in record.args + ): + self._hint_logged = True + langfuse_logger.error( + "Span export was rejected with 403 Forbidden. If you are using an " + "organization API key, set LANGFUSE_PROJECT_ID or pass project_id to " + "Langfuse() to name the project to write to." + ) + + return True + + +_FORBIDDEN_EXPORT_HINT_FILTER = _ForbiddenExportHintFilter() + + class LangfuseSpanProcessor(BatchSpanProcessor): """OpenTelemetry span processor that exports spans to the Langfuse API. @@ -150,6 +183,7 @@ def __init__( public_key: str, secret_key: str, base_url: str, + project_id: Optional[str] = None, timeout: Optional[int] = None, flush_at: Optional[int] = None, flush_interval: Optional[float] = None, @@ -167,6 +201,7 @@ def __init__( else None ) self.public_key = public_key + self.project_id = project_id self._should_export_span = should_export_span or is_default_export_span self._app_root_lock = threading.Lock() @@ -193,6 +228,8 @@ def __init__( "x-langfuse-public-key": public_key, "x-langfuse-ingestion-version": "4", } + if project_id is not None: + default_headers[PROJECT_ID_HEADER] = project_id # Merge additional headers if provided headers = {**default_headers, **(additional_headers or {})} @@ -212,11 +249,23 @@ def __init__( compression=_resolve_compression(otel_compression), max_request_size=_resolve_max_batch_size_bytes(), ) - elif otel_compression is not None: - langfuse_logger.warning( - "otel_compression is ignored because a custom span_exporter was " - "provided. Configure compression on the exporter instead." + # addFilter ignores a filter that is already installed + logging.getLogger(OTLPSpanExporter.__module__).addFilter( + _FORBIDDEN_EXPORT_HINT_FILTER ) + else: + if otel_compression is not None: + langfuse_logger.warning( + "otel_compression is ignored because a custom span_exporter was " + "provided. Configure compression on the exporter instead." + ) + if project_id is not None: + langfuse_logger.warning( + "project_id is not sent by a custom span_exporter. Set the '%s: " + "%s' header on the exporter if it sends spans to Langfuse.", + PROJECT_ID_HEADER, + project_id, + ) if media_manager is not None or mask_otel_spans is not None: span_exporter = LangfuseTransformingSpanExporter( @@ -293,15 +342,20 @@ def on_end(self, span: ReadableSpan) -> None: # Only export spans that belong to the scoped project # This is important to not send spans to wrong project in multi-project setups if is_langfuse_span(span) and not self._is_langfuse_project_span(span): - langfuse_logger.debug( - "Security: Span rejected - belongs to project '%s' but processor is for " - "'%s'. This prevents cross-project data leakage in multi-project " - "environments.", - span.instrumentation_scope.attributes.get("public_key") + scope_attributes = ( + span.instrumentation_scope.attributes if span.instrumentation_scope and span.instrumentation_scope.attributes - else None, + else {} + ) + langfuse_logger.debug( + "Security: Span rejected - belongs to public_key '%s' / project_id " + "'%s' but processor is for '%s' / '%s'. This prevents cross-project " + "data leakage in multi-project environments.", + scope_attributes.get("public_key"), + scope_attributes.get("project_id"), self.public_key, + self.project_id, ) return @@ -400,13 +454,12 @@ def _is_langfuse_project_span(self, span: ReadableSpan) -> bool: return False if span.instrumentation_scope is not None: - public_key_on_span = ( - span.instrumentation_scope.attributes.get("public_key", None) - if span.instrumentation_scope.attributes - else None - ) + scope_attributes = span.instrumentation_scope.attributes or {} - return public_key_on_span == self.public_key + return ( + scope_attributes.get("public_key", None) == self.public_key + and scope_attributes.get("project_id", None) == self.project_id + ) return False diff --git a/tests/conftest.py b/tests/conftest.py index ae437faf4..85c7f3fd2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,6 +126,7 @@ def mock_init(self: Any, **kwargs: Any) -> None: from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-public-key") + self.project_id = kwargs.get("project_id") self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) diff --git a/tests/e2e/test_decorators.py b/tests/e2e/test_decorators.py index 24617e54d..5ebf32ee1 100644 --- a/tests/e2e/test_decorators.py +++ b/tests/e2e/test_decorators.py @@ -27,9 +27,10 @@ def removeMockResourceManagerInstances(): with LangfuseResourceManager._lock: - for public_key in list(LangfuseResourceManager._instances.keys()): + for instance_key in list(LangfuseResourceManager._instances.keys()): + public_key, _project_id = instance_key if public_key != os.getenv(LANGFUSE_PUBLIC_KEY): - LangfuseResourceManager._instances.pop(public_key) + LangfuseResourceManager._instances.pop(instance_key) def _get_observation_by_name(trace_data, name): diff --git a/tests/unit/test_additional_headers_simple.py b/tests/unit/test_additional_headers_simple.py index d05475a3c..c067ad7eb 100644 --- a/tests/unit/test_additional_headers_simple.py +++ b/tests/unit/test_additional_headers_simple.py @@ -6,7 +6,8 @@ from typing import Sequence import httpx -from opentelemetry.sdk.trace import ReadableSpan +import pytest +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from langfuse._client.client import Langfuse @@ -261,3 +262,218 @@ def test_span_processor_uses_custom_span_exporter_when_provided(self): ) assert processor.span_exporter is custom_exporter + + +def _langfuse_otlp_exporter(langfuse: Langfuse): + """Return the OTLP exporter behind a client's Langfuse span processor.""" + from langfuse._client.span_processor import LangfuseSpanProcessor + + processor = next( + p + for p in langfuse._resources.tracer_provider._active_span_processor._span_processors + if isinstance(p, LangfuseSpanProcessor) + ) + exporter = processor.span_exporter + + # Langfuse wraps the OTLP exporter to handle media + return getattr(exporter, "_exporter", exporter) + + +class TestProjectIdHeader: + """The configured project ID is sent in the x-langfuse-project-id header.""" + + @pytest.fixture(autouse=True) + def _clean_state(self, monkeypatch): + from langfuse._client.resource_manager import LangfuseResourceManager + + monkeypatch.delenv("LANGFUSE_PROJECT_ID", raising=False) + LangfuseResourceManager.reset() + yield + LangfuseResourceManager.reset() + + def test_header_on_otlp_exporter_and_api_clients(self): + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + tracer_provider=TracerProvider(), + ) + + exporter = _langfuse_otlp_exporter(langfuse) + assert exporter._client._headers["x-langfuse-project-id"] == "project-a" + assert ( + langfuse.api._client_wrapper.get_headers()["x-langfuse-project-id"] + == "project-a" + ) + assert ( + langfuse.async_api._client_wrapper.get_headers()["x-langfuse-project-id"] + == "project-a" + ) + + def test_no_header_without_project_id(self): + langfuse = Langfuse( + public_key="pk-lf-project", + secret_key="sk-lf-project", + base_url="https://mock-host.com", + tracer_provider=TracerProvider(), + ) + + exporter = _langfuse_otlp_exporter(langfuse) + assert "x-langfuse-project-id" not in exporter._client._headers + assert "x-langfuse-project-id" not in ( + langfuse.api._client_wrapper.get_headers() + ) + + def test_env_var_sets_project_id(self, monkeypatch): + monkeypatch.setenv("LANGFUSE_PROJECT_ID", "project-from-env") + + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + tracing_enabled=False, + ) + + assert langfuse._resources.project_id == "project-from-env" + assert ( + langfuse.api._client_wrapper.get_headers()["x-langfuse-project-id"] + == "project-from-env" + ) + + def test_argument_wins_over_env_var(self, monkeypatch): + monkeypatch.setenv("LANGFUSE_PROJECT_ID", "project-from-env") + + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-from-arg", + tracer_provider=TracerProvider(), + ) + + assert langfuse._resources.project_id == "project-from-arg" + exporter = _langfuse_otlp_exporter(langfuse) + assert exporter._client._headers["x-langfuse-project-id"] == "project-from-arg" + assert ( + langfuse.api._client_wrapper.get_headers()["x-langfuse-project-id"] + == "project-from-arg" + ) + + def test_custom_httpx_client_requests_carry_header(self): + captured_requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response(200, json={"data": []}) + + custom_client = httpx.Client( + transport=httpx.MockTransport(handler), + headers={"X-Existing-Header": "existing-value"}, + ) + + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + httpx_client=custom_client, + tracing_enabled=False, + ) + langfuse.api.projects.get() + + assert len(captured_requests) == 1 + assert captured_requests[0].headers["x-langfuse-project-id"] == "project-a" + assert captured_requests[0].headers["x-existing-header"] == "existing-value" + + @pytest.mark.parametrize( + "header_name", + ["x-langfuse-project-id", "X-Langfuse-Project-Id", "langfuse-project-id"], + ) + def test_conflicting_additional_header_raises(self, header_name): + with pytest.raises(ValueError, match="Conflicting project IDs"): + Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + additional_headers={header_name: "project-b"}, + tracing_enabled=False, + ) + + @pytest.mark.parametrize( + "header_name", ["X-LANGFUSE-PROJECT-ID", "Langfuse-Project-Id"] + ) + def test_conflicting_httpx_client_header_raises(self, header_name): + custom_client = httpx.Client(headers={header_name: "project-b"}) + + with pytest.raises(ValueError, match="httpx_client.headers"): + Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + httpx_client=custom_client, + tracing_enabled=False, + ) + + def test_conflicting_header_raises_with_project_id_from_env(self, monkeypatch): + monkeypatch.setenv("LANGFUSE_PROJECT_ID", "project-a") + + with pytest.raises(ValueError, match="Conflicting project IDs"): + Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + additional_headers={"x-langfuse-project-id": "project-b"}, + tracing_enabled=False, + ) + + def test_matching_headers_are_accepted(self): + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + additional_headers={"X-Langfuse-Project-Id": "project-a"}, + httpx_client=httpx.Client(headers={"langfuse-project-id": "project-a"}), + tracing_enabled=False, + ) + + assert langfuse._resources.project_id == "project-a" + + def test_header_without_project_id_is_left_alone(self): + langfuse = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + additional_headers={"x-langfuse-project-id": "project-b"}, + tracer_provider=TracerProvider(), + ) + + assert langfuse._resources.project_id is None + assert ( + langfuse._resources.httpx_client.headers["x-langfuse-project-id"] + == "project-b" + ) + exporter = _langfuse_otlp_exporter(langfuse) + assert exporter._client._headers["x-langfuse-project-id"] == "project-b" + + def test_custom_span_exporter_warns_about_project_id(self, caplog): + with caplog.at_level("WARNING", logger="langfuse"): + Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="https://mock-host.com", + project_id="project-a", + tracer_provider=TracerProvider(), + span_exporter=NoOpSpanExporter(), + ) + + assert ( + sum( + "project_id is not sent by a custom span_exporter" in r.message + for r in caplog.records + ) + == 1 + ) diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index f5757fa15..5926b7e1f 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -192,6 +192,7 @@ def mock_init(self, **kwargs): from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-key") + self.project_id = kwargs.get("project_id") self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) @@ -2452,6 +2453,7 @@ def mock_processor_init(self, **kwargs): from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-key") + self.project_id = kwargs.get("project_id") self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) @@ -2572,6 +2574,49 @@ def test_spans_routed_to_correct_exporters(self, multi_project_setup): assert "trace-project2" in spans_project2 assert "trace-project1" not in spans_project2 + def test_org_key_spans_routed_to_correct_project(self, monkeypatch): + """Two clients on one organization key export only their own project's spans. + + Uses the real LangfuseSpanProcessor on a shared TracerProvider, so the + instrumentation-scope filter is what keeps the projects apart. + """ + monkeypatch.setattr(LangfuseResourceManager, "_instances", {}) + tracer_provider = TracerProvider() + exporter_a = InMemorySpanExporter() + exporter_b = InMemorySpanExporter() + + client_a = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="http://test-host", + project_id="project-a", + tracer_provider=tracer_provider, + span_exporter=exporter_a, + ) + client_b = Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="http://test-host", + project_id="project-b", + tracer_provider=tracer_provider, + span_exporter=exporter_b, + ) + + try: + client_a.start_observation(name="span-project-a").end() + client_b.start_observation(name="span-project-b").end() + tracer_provider.force_flush() + + assert [s.name for s in exporter_a.get_finished_spans()] == [ + "span-project-a" + ] + assert [s.name for s in exporter_b.get_finished_spans()] == [ + "span-project-b" + ] + finally: + LangfuseResourceManager.reset() + tracer_provider.shutdown() + def test_concurrent_operations_in_multiple_projects(self, multi_project_setup): """Test concurrent span operations in multiple projects.""" import threading @@ -2832,6 +2877,7 @@ def mock_processor_init(self, **kwargs): from langfuse._client.span_filter import is_default_export_span self.public_key = kwargs.get("public_key", "test-key") + self.project_id = kwargs.get("project_id") self._should_export_span = ( kwargs.get("should_export_span") or is_default_export_span ) diff --git a/tests/unit/test_resource_manager.py b/tests/unit/test_resource_manager.py index 788dc2ced..c776486d2 100644 --- a/tests/unit/test_resource_manager.py +++ b/tests/unit/test_resource_manager.py @@ -508,3 +508,166 @@ def test_flush_returns_after_repeated_shutdown(concurrent): media_queue.task_done() assert not hung + + +class TestOrganizationKeyClients: + """Clients are identified by (public_key, project_id) for organization keys.""" + + @pytest.fixture(autouse=True) + def _clean_state(self, monkeypatch): + monkeypatch.delenv("LANGFUSE_PROJECT_ID", raising=False) + LangfuseResourceManager.reset() + yield + LangfuseResourceManager.reset() + + @staticmethod + def _org_client(project_id, **kwargs): + return Langfuse( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="http://localhost:3000", + project_id=project_id, + tracing_enabled=False, + **kwargs, + ) + + def test_different_project_id_creates_new_instance(self): + client_a = self._org_client("project-a") + client_b = self._org_client("project-b") + + assert client_a._resources is not client_b._resources + assert client_a._resources.project_id == "project-a" + assert client_b._resources.project_id == "project-b" + assert set(LangfuseResourceManager._instances) == { + ("pk-lf-org", "project-a"), + ("pk-lf-org", "project-b"), + } + + def test_same_project_id_reuses_instance(self): + client_a = self._org_client("project-a") + client_a_again = self._org_client("project-a") + + assert client_a._resources is client_a_again._resources + + def test_get_client_returns_client_for_project(self): + self._org_client("project-a") + self._org_client("project-b") + + client_a = get_client(public_key="pk-lf-org", project_id="project-a") + client_b = get_client(public_key="pk-lf-org", project_id="project-b") + + assert client_a._resources.project_id == "project-a" + assert client_b._resources.project_id == "project-b" + assert len(LangfuseResourceManager._instances) == 2 + + def test_get_client_returns_only_client_for_public_key(self): + self._org_client("project-a") + Langfuse( + public_key="pk-lf-other", + secret_key="sk-lf-other", + base_url="http://localhost:3000", + tracing_enabled=False, + ) + + client = get_client(public_key="pk-lf-org") + + assert client._resources.public_key == "pk-lf-org" + assert client._resources.project_id == "project-a" + + def test_ambiguous_get_client_returns_disabled_client(self, caplog): + self._org_client("project-a") + self._org_client("project-b") + + with caplog.at_level(logging.WARNING, logger="langfuse"): + client = get_client(public_key="pk-lf-org") + + assert client._resources.public_key == "fake" + assert client._tracing_enabled is False + assert any( + "no 'langfuse_project_id' was passed" in r.message for r in caplog.records + ) + + def test_get_client_with_unknown_project_returns_disabled_client(self): + self._org_client("project-a") + + client = get_client(public_key="pk-lf-org", project_id="project-c") + + assert client._resources.public_key == "fake" + assert client._tracing_enabled is False + + def test_observe_routes_by_project_id(self): + from langfuse import observe + + self._org_client("project-a") + self._org_client("project-b") + + @observe() + def nested(): + return get_client()._resources.project_id + + @observe() + def outer(): + return get_client()._resources.project_id, nested() + + assert outer( + langfuse_public_key="pk-lf-org", langfuse_project_id="project-b" + ) == ("project-b", "project-b") + assert outer( + langfuse_public_key="pk-lf-org", langfuse_project_id="project-a" + ) == ("project-a", "project-a") + + def test_get_project_id_uses_configured_value(self): + client = self._org_client("project-a") + client.api.projects.get = Mock(side_effect=AssertionError("not expected")) + + assert client._get_project_id() == "project-a" + assert client.get_trace_url(trace_id="a" * 32) == ( + "http://localhost:3000/project/project-a/traces/" + "a" * 32 + ) + client.api.projects.get.assert_not_called() + + +def test_forbidden_export_logs_project_id_hint_once(monkeypatch, caplog): + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + + from langfuse._client import span_processor + from langfuse._client.span_processor import ( + LangfuseSpanProcessor, + _ForbiddenExportHintFilter, + ) + + hint_filter = _ForbiddenExportHintFilter() + monkeypatch.setattr(span_processor, "_FORBIDDEN_EXPORT_HINT_FILTER", hint_filter) + otlp_logger = logging.getLogger(OTLPSpanExporter.__module__) + + processor = LangfuseSpanProcessor( + public_key="pk-lf-org", + secret_key="sk-lf-org", + base_url="http://localhost:3000", + ) + + try: + assert hint_filter in otlp_logger.filters + + with caplog.at_level(logging.ERROR): + for status_code in (500, 403, 403): + otlp_logger.error( + "Failed to export %s batch code: %s, reason: %s", + "spans", + status_code, + "Forbidden", + ) + + hints = [r for r in caplog.records if "LANGFUSE_PROJECT_ID" in r.getMessage()] + assert len(hints) == 1 + assert hints[0].name == "langfuse" + # The exporter's own error records are kept + assert ( + sum(r.getMessage().startswith("Failed to export") for r in caplog.records) + == 3 + ) + finally: + otlp_logger.removeFilter(hint_filter) + processor.shutdown() From e3230b6444de89182f4c602fe2cdb30dc71e1815 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 16:04:01 +0000 Subject: [PATCH 2/2] docs(client): tighten project_id docstrings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PExPf2jnm8CPvdxKYmfqt --- langfuse/_client/client.py | 2 +- langfuse/_client/environment_variables.py | 6 ++---- langfuse/_client/get_client.py | 20 +++++++------------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 6a6c9af05..8a4ab5e50 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -226,7 +226,7 @@ class Langfuse: Parameters: public_key (Optional[str]): Your Langfuse public API key. Can also be set via LANGFUSE_PUBLIC_KEY environment variable. secret_key (Optional[str]): Your Langfuse secret API key. Can also be set via LANGFUSE_SECRET_KEY environment variable. - project_id (Optional[str]): ID of the Langfuse project this client sends data to. Required with organization API keys, which can write to any project in their organization; leave it unset for project API keys. Can also be set via LANGFUSE_PROJECT_ID environment variable; the argument takes precedence. When set, the client sends it in the `x-langfuse-project-id` header on span exports and API requests, and uses it for trace and experiment URLs. Each client is bound to one project: create one client per project, and look them up with `get_client(public_key=..., project_id=...)`. Raises `ValueError` if `additional_headers` or `httpx_client.headers` set `x-langfuse-project-id` or `langfuse-project-id` to a different value. + project_id (Optional[str]): Langfuse project that receives this client's data. Required for organization API keys; omit for project API keys. Can also be set via LANGFUSE_PROJECT_ID environment variable. Use one client per project. Raises `ValueError` if a project header in `additional_headers` or `httpx_client` names a different project. base_url (Optional[str]): The Langfuse API base URL. Defaults to "https://cloud.langfuse.com". Can also be set via LANGFUSE_BASE_URL environment variable. host (Optional[str]): Deprecated. Use base_url instead. The Langfuse API host URL. Defaults to "https://cloud.langfuse.com". timeout (Optional[int]): Timeout in seconds for API requests. Defaults to 5 seconds. diff --git a/langfuse/_client/environment_variables.py b/langfuse/_client/environment_variables.py index 7e27dca4b..97b022b1c 100644 --- a/langfuse/_client/environment_variables.py +++ b/langfuse/_client/environment_variables.py @@ -39,10 +39,8 @@ """ .. envvar:: LANGFUSE_PROJECT_ID -ID of the Langfuse project the client sends data to. Required with organization API -keys, which can write to any project in their organization. When set, the SDK sends -it on every request in the ``x-langfuse-project-id`` header. Leave it unset for -project API keys. +Langfuse project that receives the client's data. Required for organization API keys; +leave unset for project API keys. """ LANGFUSE_BASE_URL = "LANGFUSE_BASE_URL" diff --git a/langfuse/_client/get_client.py b/langfuse/_client/get_client.py index 2008aeb55..8e2cc174e 100644 --- a/langfuse/_client/get_client.py +++ b/langfuse/_client/get_client.py @@ -76,31 +76,25 @@ def get_client( Returns an existing Langfuse client or creates a new one if none exists. In multi-project setups, providing a public_key is required. Multi-project support is experimental - see Langfuse docs. - A client is identified by its public key and its project ID. Project API keys leave - the project ID unset. An organization API key can back several clients, one per - project, so pass the project_id as well to pick one of them. - Behavior: - Single project: Returns existing client or creates new one - - Multi-project: Requires public_key (and project_id for an organization key shared - by several clients) to return a specific client - - No public_key in multi-project, or a public_key shared by several clients without - a project_id: Returns disabled client to prevent data leakage + - Multi-project: Requires public_key to return specific client, plus project_id when one organization key serves several projects + - No public_key in multi-project: Returns disabled client to prevent data leakage - The function uses a singleton pattern per (public_key, project_id) to conserve resources and maintain state. + The function uses a singleton pattern per public_key and project_id to conserve resources and maintain state. Args: public_key (Optional[str]): Project identifier - With key: Returns client for that project - Without key: Returns single client or disabled client if multiple exist - project_id (Optional[str]): Project ID the client was created with. Only needed - to choose between several clients that share an organization API key. + project_id (Optional[str]): Selects the client for this project when one + organization API key serves several projects. Returns: Langfuse: Client instance in one of three states: - 1. Client for specified public_key (and project_id) + 1. Client for specified public_key and project_id 2. Default client for single-project setup - 3. Disabled client when the lookup matches no client or more than one + 3. Disabled client when no client or several clients match Security: Disables tracing when multiple projects exist without explicit key to prevent