Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reference/advanced/images/variables-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,427 changes: 1,427 additions & 0 deletions docs/reference/advanced/managed-variables.md

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions logfire-api/logfire_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ def instrument_mcp(self, *args, **kwargs) -> None: ...

def shutdown(self, *args, **kwargs) -> None: ...

def var(self, *args, **kwargs):
return MagicMock()

def variables_clear(self, *args, **kwargs) -> None:
pass

def variables_get(self, *args, **kwargs) -> list[Any]:
return []

def variables_push(self, *args, **kwargs) -> bool:
return False

def variables_push_types(self, *args, **kwargs) -> bool:
return False

def variables_validate(self, *args, **kwargs) -> Any:
return MagicMock()

def variables_push_config(self, *args, **kwargs) -> bool:
return False

def variables_pull_config(self, *args, **kwargs) -> Any:
return MagicMock()

def variables_build_config(self, *args, **kwargs) -> Any:
return MagicMock()

DEFAULT_LOGFIRE_INSTANCE = Logfire()
span = DEFAULT_LOGFIRE_INSTANCE.span
log = DEFAULT_LOGFIRE_INSTANCE.log
Expand Down Expand Up @@ -254,6 +281,22 @@ def shutdown(self, *args, **kwargs) -> None: ...
instrument_mcp = DEFAULT_LOGFIRE_INSTANCE.instrument_mcp
shutdown = DEFAULT_LOGFIRE_INSTANCE.shutdown
suppress_scopes = DEFAULT_LOGFIRE_INSTANCE.suppress_scopes
var = DEFAULT_LOGFIRE_INSTANCE.var
variables_clear = DEFAULT_LOGFIRE_INSTANCE.variables_clear
variables_get = DEFAULT_LOGFIRE_INSTANCE.variables_get
variables_push = DEFAULT_LOGFIRE_INSTANCE.variables_push
variables_push_types = DEFAULT_LOGFIRE_INSTANCE.variables_push_types
variables_validate = DEFAULT_LOGFIRE_INSTANCE.variables_validate
variables_push_config = DEFAULT_LOGFIRE_INSTANCE.variables_push_config
variables_pull_config = DEFAULT_LOGFIRE_INSTANCE.variables_pull_config
variables_build_config = DEFAULT_LOGFIRE_INSTANCE.variables_build_config

# Stub module for variables submodule
class _VariablesModule:
"""Stub for logfire.variables submodule."""
pass

variables = _VariablesModule()

def loguru_handler() -> dict[str, Any]:
return {}
Expand Down Expand Up @@ -285,6 +328,9 @@ def __init__(self, *args, **kwargs) -> None: ...
class MetricsOptions:
def __init__(self, *args, **kwargs) -> None: ...

class VariablesOptions:
def __init__(self, *args, **kwargs) -> None: ...

class PydanticPlugin:
def __init__(self, *args, **kwargs) -> None: ...

Expand Down
33 changes: 32 additions & 1 deletion logfire/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@
from logfire.propagate import attach_context, get_context
from logfire.sampling import SamplingOptions

from . import variables as variables
from ._internal.auto_trace import AutoTraceModule
from ._internal.auto_trace.rewrite_ast import no_auto_trace
from ._internal.baggage import get_baggage, set_baggage
from ._internal.cli import logfire_info
from ._internal.config import AdvancedOptions, CodeSource, ConsoleOptions, MetricsOptions, PydanticPlugin, configure
from ._internal.config import (
AdvancedOptions,
CodeSource,
ConsoleOptions,
MetricsOptions,
PydanticPlugin,
VariablesOptions,
configure,
)
from ._internal.constants import LevelName
from ._internal.main import Logfire, LogfireSpan
from ._internal.scrubbing import ScrubbingOptions, ScrubMatch
Expand Down Expand Up @@ -85,6 +94,17 @@
metric_gauge_callback = DEFAULT_LOGFIRE_INSTANCE.metric_gauge_callback
metric_up_down_counter_callback = DEFAULT_LOGFIRE_INSTANCE.metric_up_down_counter_callback

# Variables
var = DEFAULT_LOGFIRE_INSTANCE.var
variables_clear = DEFAULT_LOGFIRE_INSTANCE.variables_clear
variables_get = DEFAULT_LOGFIRE_INSTANCE.variables_get
variables_push = DEFAULT_LOGFIRE_INSTANCE.variables_push
variables_push_types = DEFAULT_LOGFIRE_INSTANCE.variables_push_types
variables_validate = DEFAULT_LOGFIRE_INSTANCE.variables_validate
variables_push_config = DEFAULT_LOGFIRE_INSTANCE.variables_push_config
variables_pull_config = DEFAULT_LOGFIRE_INSTANCE.variables_pull_config
variables_build_config = DEFAULT_LOGFIRE_INSTANCE.variables_build_config


def loguru_handler() -> Any:
"""Create a **Logfire** handler for Loguru.
Expand Down Expand Up @@ -171,6 +191,17 @@ def loguru_handler() -> Any:
'loguru_handler',
'SamplingOptions',
'MetricsOptions',
'VariablesOptions',
'variables',
'var',
'variables_clear',
'variables_get',
'variables_push',
'variables_push_types',
'variables_validate',
'variables_push_config',
'variables_pull_config',
'variables_build_config',
'logfire_info',
'get_baggage',
'set_baggage',
Expand Down
11 changes: 11 additions & 0 deletions logfire/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ def _post_raw(self, endpoint: str, body: Any | None = None) -> Response:
UnexpectedResponse.raise_for_status(response)
return response

def _put_raw(self, endpoint: str, body: Any | None = None) -> Response: # pragma: no cover
response = self._session.put(urljoin(self.base_url, endpoint), json=body)
UnexpectedResponse.raise_for_status(response)
return response

def _put(self, endpoint: str, *, body: Any | None = None, error_message: str) -> Any: # pragma: no cover
try:
return self._put_raw(endpoint, body).json()
except UnexpectedResponse as e:
raise LogfireConfigError(error_message) from e

def _post(self, endpoint: str, *, body: Any | None = None, error_message: str) -> Any:
try:
return self._post_raw(endpoint, body).json()
Expand Down
119 changes: 115 additions & 4 deletions logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from collections.abc import Sequence
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import timedelta
from pathlib import Path
from threading import RLock, Thread
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypedDict
Expand Down Expand Up @@ -57,13 +58,14 @@
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio, Sampler
from rich.console import Console
from rich.prompt import Confirm, Prompt
from typing_extensions import Self, Unpack
from typing_extensions import Self, Unpack, assert_type

from logfire._internal.auth import PYDANTIC_LOGFIRE_TOKEN_PATTERN, REGIONS
from logfire._internal.baggage import DirectBaggageAttributesSpanProcessor
from logfire.exceptions import LogfireConfigError
from logfire.sampling import SamplingOptions
from logfire.sampling._tail_sampling import TailSamplingProcessor
from logfire.variables.abstract import NoOpVariableProvider, VariableProvider
from logfire.version import VERSION

from ..propagate import NoExtractTraceContextPropagator, WarnOnExtractTraceContextPropagator
Expand Down Expand Up @@ -116,6 +118,8 @@
if TYPE_CHECKING:
from typing import TextIO

from logfire.variables import VariablesConfig

from .main import Logfire


Expand Down Expand Up @@ -302,6 +306,36 @@ class CodeSource:
"""


@dataclass
class RemoteVariablesConfig:
block_before_first_resolve: bool = True
"""Whether the remote variables should be fetched before first resolving a value."""
polling_interval: timedelta | float = timedelta(seconds=30)
"""The time interval for polling for updates to the variables config."""
api_key: str | None = None
"""API key for accessing the variables endpoint.

If not provided, will be loaded from LOGFIRE_API_KEY environment variable.
This key should have at least the 'project:read_variables' scope.
"""
timeout: tuple[float, float] = (10, 10)
"""Timeout for HTTP requests to the variables API as (connect_timeout, read_timeout) in seconds."""


@dataclass
class VariablesOptions:
"""Configuration of managed variables."""

config: VariablesConfig | RemoteVariablesConfig | VariableProvider | None = None
"""A local or remote variables config, or an arbitrary variable provider."""
include_resource_attributes_in_context: bool = True
"""Whether to include OpenTelemetry resource attributes when resolving variables."""
include_baggage_in_context: bool = True
"""Whether to include OpenTelemetry baggage when resolving variables."""
instrument: bool = True
"""Whether to create spans when resolving variables."""


class DeprecatedKwargs(TypedDict):
# Empty so that passing any additional kwargs makes static type checkers complain.
pass
Expand All @@ -326,6 +360,7 @@ def configure(
min_level: int | LevelName | None = None,
add_baggage_to_attributes: bool = True,
code_source: CodeSource | None = None,
variables: VariablesOptions | None = None,
distributed_tracing: bool | None = None,
advanced: AdvancedOptions | None = None,
**deprecated_kwargs: Unpack[DeprecatedKwargs],
Expand Down Expand Up @@ -391,6 +426,7 @@ def configure(
add_baggage_to_attributes: Set to `False` to prevent OpenTelemetry Baggage from being added to spans as attributes.
See the [Baggage documentation](https://logfire.pydantic.dev/docs/reference/advanced/baggage/) for more details.
code_source: Settings for the source code of the project.
variables: Options related to managed variables.
distributed_tracing: By default, incoming trace context is extracted, but generates a warning.
Set to `True` to disable the warning.
Set to `False` to suppress extraction of incoming trace context.
Expand Down Expand Up @@ -527,14 +563,27 @@ def configure(
sampling=sampling,
add_baggage_to_attributes=add_baggage_to_attributes,
code_source=code_source,
variables=variables,
distributed_tracing=distributed_tracing,
advanced=advanced,
)

if local:
return Logfire(config=config)
logfire_instance = Logfire(config=config)
else:
return DEFAULT_LOGFIRE_INSTANCE
logfire_instance = DEFAULT_LOGFIRE_INSTANCE
logfire_instance._change_notifications_setup = False # pyright: ignore[reportPrivateUsage]

# Start the variable provider now that we have the logfire instance
# Pass None if instrumentation is disabled to avoid logging errors via logfire
config.get_variable_provider().start(logfire_instance if config.variables.instrument else None)

# Re-wire change notifications if there are existing variables from a previous configure() call
if logfire_instance._variables and not logfire_instance._change_notifications_setup: # pyright: ignore[reportPrivateUsage]
logfire_instance._setup_variable_change_notifications() # pyright: ignore[reportPrivateUsage]
logfire_instance._change_notifications_setup = True # pyright: ignore[reportPrivateUsage]

return logfire_instance


@dataclasses.dataclass
Expand Down Expand Up @@ -591,6 +640,9 @@ class _LogfireConfigData:
code_source: CodeSource | None
"""Settings for the source code of the project."""

variables: VariablesOptions
"""Settings related to managed variables."""

distributed_tracing: bool | None
"""Whether to extract incoming trace context."""

Expand Down Expand Up @@ -618,6 +670,7 @@ def _load_configuration(
min_level: int | LevelName | None,
add_baggage_to_attributes: bool,
code_source: CodeSource | None,
variables: VariablesOptions | None,
distributed_tracing: bool | None,
advanced: AdvancedOptions | None,
) -> None:
Expand Down Expand Up @@ -685,6 +738,20 @@ def _load_configuration(
code_source = CodeSource(**code_source) # type: ignore
self.code_source = code_source

if isinstance(variables, dict):
# This is particularly for deserializing from a dict as in executors.py
config = variables.pop('config', None) # type: ignore
if isinstance(config, dict): # pragma: no branch
if 'variables' in config:
config = VariablesConfig(**config) # type: ignore # pragma: no cover
else:
config = RemoteVariablesConfig(**config) # type: ignore
variables = VariablesOptions(config=config, **variables) # type: ignore

elif variables is None:
variables = VariablesOptions()
self.variables = variables

if isinstance(advanced, dict):
# This is particularly for deserializing from a dict as in executors.py
advanced = AdvancedOptions(**advanced) # type: ignore
Expand Down Expand Up @@ -728,6 +795,7 @@ def __init__(
sampling: SamplingOptions | None = None,
min_level: int | LevelName | None = None,
add_baggage_to_attributes: bool = True,
variables: VariablesOptions | None = None,
code_source: CodeSource | None = None,
distributed_tracing: bool | None = None,
advanced: AdvancedOptions | None = None,
Expand Down Expand Up @@ -757,6 +825,7 @@ def __init__(
min_level=min_level,
add_baggage_to_attributes=add_baggage_to_attributes,
code_source=code_source,
variables=variables,
distributed_tracing=distributed_tracing,
advanced=advanced,
)
Expand All @@ -766,6 +835,7 @@ def __init__(
# note: this reference is important because the MeterProvider runs things in background threads
# thus it "shuts down" when it's gc'ed
self._meter_provider = ProxyMeterProvider(NoOpMeterProvider())
self._variable_provider: VariableProvider = NoOpVariableProvider()
self._logger_provider = ProxyLoggerProvider(NoOpLoggerProvider())
# This ensures that we only call OTEL's global set_tracer_provider once to avoid warnings.
self._has_set_providers = False
Expand All @@ -790,6 +860,7 @@ def configure(
min_level: int | LevelName | None,
add_baggage_to_attributes: bool,
code_source: CodeSource | None,
variables: VariablesOptions | None,
distributed_tracing: bool | None,
advanced: AdvancedOptions | None,
) -> None:
Expand All @@ -812,6 +883,7 @@ def configure(
min_level,
add_baggage_to_attributes,
code_source,
variables,
distributed_tracing,
advanced,
)
Expand Down Expand Up @@ -1134,6 +1206,35 @@ def fix_pid(): # pragma: no cover
) # note: this may raise an Exception if it times out, call `logfire.shutdown` first
self._meter_provider.set_meter_provider(meter_provider)

self._variable_provider.shutdown()
if self.variables.config is None:
self._variable_provider = NoOpVariableProvider()
else:
# Need to move the imports here to prevent errors if pydantic is not installed
from logfire.variables import LocalVariableProvider, LogfireRemoteVariableProvider, VariablesConfig

if isinstance(self.variables.config, VariableProvider):
self._variable_provider = self.variables.config
elif isinstance(self.variables.config, VariablesConfig):
self._variable_provider = LocalVariableProvider(self.variables.config)
else:
assert_type(self.variables.config, RemoteVariablesConfig)
remote_config = self.variables.config
# Load api_key from config or environment variable
# Only API keys can be used for the variables API (not write tokens)
api_key = remote_config.api_key or self.param_manager.load_param('api_key')
if not api_key:
raise LogfireConfigError( # pragma: no cover
'Remote variables require an API key. '
'Set the LOGFIRE_API_KEY environment variable or pass api_key to RemoteVariablesConfig.'
)
# Determine base URL: prefer config, then advanced settings, then infer from token
base_url = self.advanced.base_url or get_base_url_from_token(api_key)
self._variable_provider = LogfireRemoteVariableProvider(
base_url=base_url,
token=api_key,
config=remote_config,
)
multi_log_processor = SynchronousMultiLogRecordProcessor()
for processor in log_record_processors:
multi_log_processor.add_log_record_processor(processor)
Expand Down Expand Up @@ -1217,6 +1318,16 @@ def get_logger_provider(self) -> ProxyLoggerProvider:
"""
return self._logger_provider

def get_variable_provider(self) -> VariableProvider:
"""Get a variable provider from this `LogfireConfig`.

This is used internally and should not be called by users of the SDK.

Returns:
The variable provider.
"""
return self._variable_provider

def warn_if_not_initialized(self, message: str):
ignore_no_config_env = os.getenv('LOGFIRE_IGNORE_NO_CONFIG', '')
ignore_no_config = ignore_no_config_env.lower() in ('1', 'true', 't') or self.ignore_no_config
Expand Down Expand Up @@ -1340,7 +1451,7 @@ class LogfireCredentials:
"""Credentials for logfire.dev."""

token: str
"""The Logfire API token to use."""
"""The Logfire write token to use."""
project_name: str
"""The name of the project."""
project_url: str
Expand Down
Loading
Loading