Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
daa4da5
Add managed variables
dmontagu Feb 5, 2026
871f113
Add on-change callbacks
dmontagu Feb 5, 2026
4e299ed
More improvements
dmontagu Feb 5, 2026
4c16ad7
Minor docs tweak
dmontagu Feb 6, 2026
cab12e4
fix failing tests, hopefully
dmontagu Feb 6, 2026
082d0bb
Add tests for managed variables coverage gaps
dmontagu Feb 6, 2026
7f20a68
Fix threading issue in deserialization cache
dmontagu Feb 6, 2026
0fe82fa
Add tests for remaining coverage gaps in var() and is_resolve_function
dmontagu Feb 6, 2026
c82374b
Update logfire/variables/remote.py
dmontagu Feb 6, 2026
ab7dfee
Add configurable HTTP timeout for remote variables and bounded shutdown
dmontagu Feb 6, 2026
a5048ee
Fix remaining time calculation
dmontagu Feb 6, 2026
39535fe
Rework implementation
dmontagu Feb 8, 2026
a99b490
Try to fix coverage
dmontagu Feb 8, 2026
d420f3b
Address TODO comment to support LabelRef's
dmontagu Feb 8, 2026
7fddbfb
Fix failing test
dmontagu Feb 8, 2026
1d563cb
Remove enabled field, add code_default ref support, make LabelRef.ver…
dmontagu Feb 8, 2026
b9dd937
Update managed-variables docs for LabelRef changes
dmontagu Feb 8, 2026
c69205e
Remove on_change/on_change_callbacks feature
dmontagu Feb 10, 2026
7ef8a0b
Merge branch 'main' into managed-variables
dmontagu Feb 10, 2026
4ea00cf
Address review feedback from Copilot and Devin on PR #1691
dmontagu Feb 10, 2026
f3cb969
Fix type_checking test to use valid variable name
dmontagu Feb 10, 2026
9511231
Fix coverage gaps in variables module
dmontagu Feb 10, 2026
c451cf5
Address review feedback on variables PR
dmontagu Feb 10, 2026
1c53f99
Address review feedback: fix VariablesConfig runtime import, remove v…
dmontagu Feb 10, 2026
ff83ea6
Fix coverage
dmontagu Feb 10, 2026
410da24
Refactor variables config API and add lazy provider initialization
dmontagu Feb 11, 2026
215e7ee
Add label compatibility checking to push_variable_types
dmontagu Feb 12, 2026
042c0a2
Document public/private variables and API key scopes
dmontagu Feb 12, 2026
36c5ea7
Update public/private variables docs to mention UI toggle
dmontagu Feb 12, 2026
d1e0dd4
Rename public/private → external/internal in managed variables docs
dmontagu Feb 12, 2026
72a1a63
Add client-side feature flags with OFREP how-to guide
dmontagu Feb 12, 2026
5d82df5
Merge branch 'main' into managed-variables
dmontagu Feb 12, 2026
a641ca7
Try getting tests to pass
dmontagu Feb 12, 2026
a2fb39b
Address PR review feedback and split managed variables docs
dmontagu Feb 12, 2026
1447c7c
Add tests for 100% coverage of managed variables code
dmontagu Feb 12, 2026
b031205
Cover remaining branch partials in abstract.py
dmontagu Feb 12, 2026
16f8fe3
Address PR review feedback: rollout fallback, timeouts, deserialization
dmontagu Feb 12, 2026
36ca03a
Fix resolve_value docstring and widen exception handling in remote wr…
dmontagu Feb 13, 2026
9e4f448
Merge branch 'main' into managed-variables
dmontagu Feb 13, 2026
8da70b5
Update some docs screenshots
dmontagu Feb 13, 2026
e925946
Remove custom VariableProvider from public configure() API
dmontagu Feb 13, 2026
1dee17f
Move external variables and OFREP docs to dedicated page
dmontagu Feb 13, 2026
97b4abb
Move api_key from VariablesOptions to logfire.configure() kwarg
dmontagu Feb 13, 2026
d3b19c6
Fix docs: rollout labels don't support 'latest' as a direct key
dmontagu Feb 13, 2026
43cf0bc
Fix get_serialized_value_for_label not blocking for first fetch + OFR…
dmontagu Feb 13, 2026
e825ad1
Fix coverage
dmontagu Feb 13, 2026
deb4842
Add test for get_serialized_value_for_label with block_before_first_r…
dmontagu Feb 13, 2026
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,403 changes: 1,403 additions & 0 deletions docs/reference/advanced/managed-variables.md

Large diffs are not rendered by default.

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

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


DEFAULT_LOGFIRE_INSTANCE = Logfire()
span = DEFAULT_LOGFIRE_INSTANCE.span
log = DEFAULT_LOGFIRE_INSTANCE.log
Expand Down Expand Up @@ -285,6 +286,7 @@ def __init__(self, *args, **kwargs) -> None: ...
class MetricsOptions:
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
131 changes: 127 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,52 @@ 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=60)
"""The time interval for polling for updates to the variables config.

Polling is only a fallback — all updates are delivered instantly via SSE
unless something goes wrong. Must be at least 10 seconds. Defaults to 60 seconds.
"""
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."""

def __post_init__(self):
interval_seconds = (
self.polling_interval.total_seconds()
if isinstance(self.polling_interval, timedelta)
else self.polling_interval
)
if interval_seconds < 10:
raise ValueError(
f'polling_interval must be at least 10 seconds, got {interval_seconds}s. '
'Polling is only a fallback — updates are delivered instantly via SSE.'
)


@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 +376,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 +442,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 +579,21 @@ 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

# 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)

return logfire_instance


@dataclasses.dataclass
Expand Down Expand Up @@ -591,6 +650,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 +680,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 +748,22 @@ 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:
from logfire.variables import VariablesConfig as _VariablesConfig

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 +807,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 +837,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 +847,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 +872,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 +895,7 @@ def configure(
min_level,
add_baggage_to_attributes,
code_source,
variables,
distributed_tracing,
advanced,
)
Expand Down Expand Up @@ -1134,6 +1218,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(timeout_millis=200)
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 +1330,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 +1463,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
5 changes: 4 additions & 1 deletion logfire/_internal/config_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,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'], tp=list[str])
"""Token for the Logfire API. Can be a comma-separated list for multi-project export."""
"""Token for sending application telemetry data to Logfire, also known as a "write token". Can be a comma-separated list for multi-project export."""
API_KEY = ConfigParam(env_vars=['LOGFIRE_API_KEY'])
"""API key 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 @@ -118,6 +120,7 @@ class _DefaultCallback:
'send_to_logfire': SEND_TO_LOGFIRE,
'min_level': MIN_LEVEL,
'token': TOKEN,
'api_key': API_KEY,
'service_name': SERVICE_NAME,
'service_version': SERVICE_VERSION,
'environment': ENVIRONMENT,
Expand Down
Loading
Loading