Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
51d7e48
Initial work on managed variables
dmontagu Nov 26, 2025
ce01469
Add docstrings
dmontagu Nov 26, 2025
e30568d
Some renaming
dmontagu Nov 26, 2025
166e158
Add some documentation
dmontagu Nov 26, 2025
bd0de1c
Use pydantic-ai
dmontagu Nov 26, 2025
c8c1d22
Add more docstrings
dmontagu Nov 26, 2025
dddcb7c
Make importing work without pydantic installed
dmontagu Nov 27, 2025
10b7742
Address most recent feedback
dmontagu Dec 1, 2025
f7a31aa
Minor improvements
dmontagu Dec 1, 2025
889281a
Fix imports failure when pydantic is installed
dmontagu Dec 2, 2025
e67159b
Add tests
dmontagu Dec 2, 2025
d114903
Merge branch 'main' into managed-variables
dmontagu Dec 2, 2025
c7e2d11
Fix stubs
dmontagu Dec 3, 2025
565c587
test_config_serializable
alexmojaki Dec 3, 2025
636636f
test_config_serializable
alexmojaki Dec 3, 2025
40ae76e
Add scheduled rollouts
dmontagu Dec 4, 2025
d363e10
Add top-level var
dmontagu Dec 4, 2025
ffc636e
Add CLI for syncing variables
dmontagu Dec 8, 2025
2620c85
Add CLI for validating variables
dmontagu Dec 8, 2025
e4ce576
Merge main
dmontagu Dec 9, 2025
1b8c048
Set development version of pydantic to be less than main branch on gi…
dmontagu Dec 9, 2025
e57e195
Fix tests of stubs
dmontagu Dec 9, 2025
6fd4f1e
Fix type checking
dmontagu Dec 10, 2025
2e6acc6
Merge main
dmontagu Dec 12, 2025
7382346
Fix type checking
dmontagu Dec 12, 2025
0a4d0e6
Try getting test to pass
dmontagu Dec 12, 2025
95e4b54
Update a comment and remove new stubs per Alex's feedback
dmontagu Dec 12, 2025
8a37b15
Address more Alex feedback
dmontagu Dec 12, 2025
74cff95
Remove TODO comment
dmontagu Dec 12, 2025
5d3e20b
Comment out schedule-related stuff
dmontagu Dec 13, 2025
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
799 changes: 799 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 @@ -199,6 +199,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 @@ -248,6 +254,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 @@ -279,6 +293,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
20 changes: 19 additions & 1 deletion logfire/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@
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
from ._internal.stack_info import add_non_user_code_prefix
from ._internal.utils import suppress_instrumentation
from .integrations.logging import LogfireLoggingHandler
from .integrations.structlog import LogfireProcessor as StructlogProcessor
from .variables.push import push_variables, validate_variables
from .version import VERSION

DEFAULT_LOGFIRE_INSTANCE: Logfire = Logfire()
Expand Down Expand Up @@ -83,6 +92,10 @@
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


def loguru_handler() -> Any:
"""Create a **Logfire** handler for Loguru.
Expand Down Expand Up @@ -167,6 +180,11 @@ def loguru_handler() -> Any:
'loguru_handler',
'SamplingOptions',
'MetricsOptions',
'VariablesOptions',
'var',
'get_variables',
'push_variables',
'validate_variables',
'logfire_info',
'get_baggage',
'set_baggage',
Expand Down
45 changes: 45 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:
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:
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 Expand Up @@ -133,3 +144,37 @@ def get_prompt(self, organization: str, project_name: str, issue: str) -> dict[s
params={'issue': issue},
error_message='Error retrieving prompt',
)

# --- Variables API ---

def get_variables_config(self, organization: str, project_name: str) -> dict[str, Any]:
"""Get the variables configuration for a project."""
return self._get(
f'/api/organizations/{organization}/projects/{project_name}/variables/config/',
error_message='Error retrieving variables configuration',
)

def get_variable_by_name(self, organization: str, project_name: str, variable_name: str) -> dict[str, Any]:
"""Get a variable definition by name."""
return self._get(
f'/api/organizations/{organization}/projects/{project_name}/variables/by-name/{variable_name}/',
error_message=f'Error retrieving variable {variable_name!r}',
)

def create_variable(self, organization: str, project_name: str, body: dict[str, Any]) -> dict[str, Any]:
"""Create a new variable definition."""
return self._post(
f'/api/organizations/{organization}/projects/{project_name}/variables/',
body=body,
error_message='Error creating variable',
)

def update_variable(
self, organization: str, project_name: str, variable_id: str, body: dict[str, Any]
) -> dict[str, Any]:
"""Update an existing variable definition."""
return self._put(
f'/api/organizations/{organization}/projects/{project_name}/variables/{variable_id}/',
body=body,
error_message='Error updating variable',
)
78 changes: 78 additions & 0 deletions 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 @@ -63,6 +64,7 @@
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,28 @@ 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."""


@dataclass
class VariablesOptions:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having both VariablesOptions and VariablesConfig is worrying

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I'm assuming you understand the difference between them, do you have a suggestion for alternative naming?

"""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 +351,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 +416,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 +553,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 +618,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 +648,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 +715,13 @@ 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
variables = VariablesOptions(**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 +765,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 +795,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 +805,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 +830,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 +853,7 @@ def configure(
min_level,
add_baggage_to_attributes,
code_source,
variables,
distributed_tracing,
advanced,
)
Expand Down Expand Up @@ -1121,6 +1166,29 @@ 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)

from logfire.variables import LocalVariableProvider, LogfireRemoteVariableProvider, VariablesConfig

self._variable_provider.shutdown()
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)
elif isinstance(self.variables.config, RemoteVariablesConfig):
# TODO: Need to use a non-write-token
token = self.token
if token:
base_url = self.advanced.base_url or get_base_url_from_token(token)
self._variable_provider = LogfireRemoteVariableProvider(
base_url=base_url,
token=token,
config=self.variables.config,
)
else:
# No token, so can't use the remote variable provider
self._variable_provider = NoOpVariableProvider()
elif self.variables.config is None:
self._variable_provider = NoOpVariableProvider()

multi_log_processor = SynchronousMultiLogRecordProcessor()
for processor in log_record_processors:
multi_log_processor.add_log_record_processor(processor)
Expand Down Expand Up @@ -1231,6 +1299,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
Loading
Loading