Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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.
1,037 changes: 1,037 additions & 0 deletions docs/reference/advanced/managed-variables.md

Large diffs are not rendered by default.

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

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

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

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

DEFAULT_LOGFIRE_INSTANCE = Logfire()
span = DEFAULT_LOGFIRE_INSTANCE.span
log = DEFAULT_LOGFIRE_INSTANCE.log
Expand Down Expand Up @@ -251,6 +257,14 @@ 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
get_variables = DEFAULT_LOGFIRE_INSTANCE.get_variables

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

def validate_variables(*args, **kwargs) -> bool:
return True

def loguru_handler() -> dict[str, Any]:
return {}
Expand Down Expand Up @@ -282,6 +296,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
27 changes: 26 additions & 1 deletion logfire/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
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 @@ -84,6 +92,15 @@
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
get_variables = DEFAULT_LOGFIRE_INSTANCE.get_variables
push_variables = DEFAULT_LOGFIRE_INSTANCE.push_variables
validate_variables = DEFAULT_LOGFIRE_INSTANCE.validate_variables
sync_config = DEFAULT_LOGFIRE_INSTANCE.sync_config
pull_config = DEFAULT_LOGFIRE_INSTANCE.pull_config
generate_config = DEFAULT_LOGFIRE_INSTANCE.generate_config


def loguru_handler() -> Any:
"""Create a **Logfire** handler for Loguru.
Expand Down Expand Up @@ -169,6 +186,14 @@ def loguru_handler() -> Any:
'loguru_handler',
'SamplingOptions',
'MetricsOptions',
'VariablesOptions',
'var',
'get_variables',
'push_variables',
'validate_variables',
'sync_config',
'pull_config',
'generate_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
106 changes: 105 additions & 1 deletion logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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 @@ -56,13 +57,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 @@ -115,6 +117,8 @@
if TYPE_CHECKING:
from typing import TextIO

from logfire.variables import VariablesConfig

from .main import Logfire


Expand Down Expand Up @@ -301,6 +305,40 @@ 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_token: str | None = None
"""API token for accessing the variables endpoint.

If not provided, will be loaded from LOGFIRE_API_TOKEN environment variable.
This token should have the 'project:read_variables' scope.
"""
base_url: str | None = None
"""Base URL for the Logfire API.

If not provided, will be inferred from the API token.
"""
# TODO: Decide what the behavior should be if no API token is present — error? Or the same behavior as the NoOpVariableProvider?


@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."""

# TODO: Add OTel-related config here


class DeprecatedKwargs(TypedDict):
# Empty so that passing any additional kwargs makes static type checkers complain.
pass
Expand All @@ -325,6 +363,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 @@ -389,6 +428,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 @@ -525,6 +565,7 @@ def configure(
sampling=sampling,
add_baggage_to_attributes=add_baggage_to_attributes,
code_source=code_source,
variables=variables,
distributed_tracing=distributed_tracing,
advanced=advanced,
)
Expand Down Expand Up @@ -589,6 +630,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 @@ -616,6 +660,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 @@ -682,6 +727,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 @@ -725,6 +784,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 @@ -754,6 +814,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 @@ -763,6 +824,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 @@ -787,6 +849,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 @@ -809,6 +872,7 @@ def configure(
min_level,
add_baggage_to_attributes,
code_source,
variables,
distributed_tracing,
advanced,
)
Expand Down Expand Up @@ -1121,6 +1185,36 @@ 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_token from config or environment variable
# Only API tokens can be used for the variables API (not write tokens)
api_token = remote_config.api_token or self.param_manager.load_param('api_token')
if not api_token:
raise LogfireConfigError( # pragma: no cover
'Remote variables require an API token. '
'Set the LOGFIRE_API_TOKEN environment variable or pass api_token to RemoteVariablesConfig.'
)
# Determine base URL: prefer config, then advanced settings, then infer from token
base_url = remote_config.base_url or self.advanced.base_url or get_base_url_from_token(api_token)
self._variable_provider = LogfireRemoteVariableProvider(
base_url=base_url,
token=api_token,
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 @@ -1231,6 +1325,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
5 changes: 4 additions & 1 deletion logfire/_internal/config_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ class _DefaultCallback:
MIN_LEVEL = ConfigParam(env_vars=['LOGFIRE_MIN_LEVEL'], allow_file_config=True, default=None, tp=LevelName)
"""Minimum log level for logs and spans to be created. By default, all logs and spans are created."""
TOKEN = ConfigParam(env_vars=['LOGFIRE_TOKEN'])
"""Token for the Logfire API."""
"""Token for sending application telemetry data to Logfire, also known as a "write token"."""
API_TOKEN = ConfigParam(env_vars=['LOGFIRE_API_TOKEN'])
"""API token for Logfire API access (used for managed variables and other public APIs)."""
SERVICE_NAME = ConfigParam(env_vars=['LOGFIRE_SERVICE_NAME', OTEL_SERVICE_NAME], allow_file_config=True, default='')
"""Name of the service emitting spans. For further details, please refer to the [Service section](https://opentelemetry.io/docs/specs/semconv/resource/#service)."""
SERVICE_VERSION = ConfigParam(env_vars=['LOGFIRE_SERVICE_VERSION', 'OTEL_SERVICE_VERSION'], allow_file_config=True)
Expand Down Expand Up @@ -115,6 +117,7 @@ class _DefaultCallback:
'send_to_logfire': SEND_TO_LOGFIRE,
'min_level': MIN_LEVEL,
'token': TOKEN,
'api_token': API_TOKEN,
'service_name': SERVICE_NAME,
'service_version': SERVICE_VERSION,
'environment': ENVIRONMENT,
Expand Down
Loading
Loading