From bbb9f94cd91227bb66dfb170a42788a3c2ea3eb5 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 14:42:11 +0200 Subject: [PATCH 01/18] Expose ResolvedVariable reason --- logfire/variables/__init__.py | 2 + logfire/variables/abstract.py | 47 ++++++++----- logfire/variables/config.py | 4 +- logfire/variables/remote.py | 2 +- logfire/variables/variable.py | 54 ++++++++------- tests/test_variables.py | 121 ++++++++++++++++++++-------------- 6 files changed, 140 insertions(+), 90 deletions(-) diff --git a/logfire/variables/__init__.py b/logfire/variables/__init__.py index 74a3534e9..1c2135a33 100644 --- a/logfire/variables/__init__.py +++ b/logfire/variables/__init__.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from logfire.variables.abstract import ( + ResolutionReason, ResolvedVariable, SyncMode, ValidationReport, @@ -71,6 +72,7 @@ # Context managers and utilities 'targeting_context', # Types + 'ResolutionReason', 'SyncMode', 'ValidationReport', # Exceptions diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index 594a2af9e..b0bf06009 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -30,6 +30,7 @@ __all__ = ( 'ResolvedVariable', + 'ResolutionReason', 'SyncMode', 'ValidationReport', 'VariableProvider', @@ -42,6 +43,28 @@ T = TypeVar('T') T_co = TypeVar('T_co', covariant=True) +ResolutionReason = Literal[ + 'resolved', + 'context_override', + 'missing_config', + 'unrecognized_variable', + 'validation_error', + 'other_error', + 'no_provider', + 'code_default', +] +"""Why a variable (or a composed reference) resolved to its final value. + +- `resolved`: provider returned a value that was used as-is. +- `context_override`: a value set via `Variable.override(...)` was used. +- `missing_config`: the variable exists on the provider but the targeting/rollout produced no value. +- `unrecognized_variable`: the provider has no entry for the variable. +- `validation_error`: the serialized value failed deserialization. +- `other_error`: composition, rendering or other error during resolution. +- `no_provider`: no provider is configured. +- `code_default`: the variable's code-default was used because the provider had no value. +""" + if not TYPE_CHECKING: # pragma: no branch if sys.version_info < (3, 10): # pragma: no cover _dataclass = dataclass @@ -96,18 +119,10 @@ class ResolvedVariable(Generic[T_co]): """The name of the variable.""" value: T_co """The resolved value of the variable.""" - _reason: Literal[ - 'resolved', - 'context_override', - 'missing_config', - 'unrecognized_variable', - 'validation_error', - 'other_error', - 'no_provider', - ] # we might eventually make this public, but I didn't want to yet - """Internal field indicating how the value was resolved.""" - # Note: I had to put _reason before fields with defaults due to lack of kw_only - # Note: When we drop support for python 3.9, move _reason to the end + reason: ResolutionReason + """How the variable was resolved (see `ResolutionReason` for possible values).""" + # Note: `reason` is declared before fields with defaults because we don't use kw_only=True + # on Python<3.10; move it to the end when 3.9 support is dropped. label: str | None = None """The name of the selected label, if any.""" version: int | None = None @@ -680,11 +695,11 @@ def get_serialized_value_for_label( """ config = self.get_variable_config(variable_name) if config is None: - return ResolvedVariable(name=variable_name, value=None, _reason='unrecognized_variable') + return ResolvedVariable(name=variable_name, value=None, reason='unrecognized_variable') labeled_value = config.labels.get(label) if labeled_value is None: - return ResolvedVariable(name=variable_name, value=None, _reason='resolved') + return ResolvedVariable(name=variable_name, value=None, reason='resolved') serialized, version = config.follow_ref(labeled_value) return ResolvedVariable( @@ -692,7 +707,7 @@ def get_serialized_value_for_label( value=serialized, label=label, version=version, - _reason='resolved', + reason='resolved', ) def refresh(self, force: bool = False): @@ -1422,7 +1437,7 @@ def get_serialized_value( Returns: A ResolvedVariable with value=None. """ - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') def get_variable_config(self, name: str) -> VariableConfig | None: """Return None for all variable lookups. diff --git a/logfire/variables/config.py b/logfire/variables/config.py index 50cdee7c1..58e223841 100644 --- a/logfire/variables/config.py +++ b/logfire/variables/config.py @@ -507,7 +507,7 @@ def resolve_serialized_value( """ variable_config = self._get_variable_config(name) if variable_config is None: - return ResolvedVariable(name=name, value=None, _reason='unrecognized_variable') + return ResolvedVariable(name=name, value=None, reason='unrecognized_variable') serialized_value, selected_label, version = variable_config.resolve_value( targeting_key, attributes, label=label @@ -517,7 +517,7 @@ def resolve_serialized_value( value=serialized_value, label=selected_label, version=version, - _reason='resolved', + reason='resolved', ) def _get_variable_config(self, name: VariableName) -> VariableConfig | None: diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 0de792837..4966efb2e 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -356,7 +356,7 @@ def get_serialized_value( self.refresh() if self._config is None: - return ResolvedVariable(name=variable_name, value=None, _reason='missing_config') + return ResolvedVariable(name=variable_name, value=None, reason='missing_config') return self._config.resolve_serialized_value(variable_name, targeting_key, attributes) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 9cb292763..dae0bb2c2 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -4,7 +4,7 @@ from collections.abc import Generator, Mapping, Sequence from contextlib import ExitStack, contextmanager from contextvars import ContextVar -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar @@ -248,7 +248,7 @@ def get( 'value': serialized_value, 'label': result.label, 'version': result.version, - 'reason': result._reason, # pyright: ignore[reportPrivateUsage] + 'reason': result.reason, } ) if result.exception: @@ -270,7 +270,7 @@ def _resolve( context_value = context_overrides[self.name] if is_resolve_function(context_value): context_value = context_value(targeting_key, attributes) - return ResolvedVariable(name=self.name, value=context_value, _reason='context_override') + return ResolvedVariable(name=self.name, value=context_value, reason='context_override') provider = self.logfire_instance.config.get_variable_provider() @@ -286,21 +286,35 @@ def _resolve( span.set_attribute('invalid_serialized_value', serialized_result.value) default = self._get_default(targeting_key, attributes) reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable(name=self.name, value=default, exception=value_or_exc, _reason=reason) + return ResolvedVariable( + name=self.name, + value=default, + exception=value_or_exc, + reason=reason, + label=serialized_result.label, + version=serialized_result.version, + ) return ResolvedVariable( name=self.name, value=value_or_exc, label=serialized_result.label, version=serialized_result.version, - _reason='resolved', + reason='resolved', ) # Label not found - fall through to default resolution serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) if serialized_result.value is None: - default = self._get_default(targeting_key, attributes) - return _with_value(serialized_result, default) + # Provider had no value; surface that the code default was used. + return ResolvedVariable( + name=self.name, + value=self._get_default(targeting_key, attributes), + exception=serialized_result.exception, + label=serialized_result.label, + version=serialized_result.version, + reason='code_default', + ) # Deserialize - returns T | Exception value_or_exc = self._deserialize(serialized_result.value) @@ -310,14 +324,21 @@ def _resolve( span.set_attribute('invalid_serialized_value', serialized_result.value) default = self._get_default(targeting_key, attributes) reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable(name=self.name, value=default, exception=value_or_exc, _reason=reason) + return ResolvedVariable( + name=self.name, + value=default, + exception=value_or_exc, + reason=reason, + label=serialized_result.label, + version=serialized_result.version, + ) return ResolvedVariable( name=self.name, value=value_or_exc, label=serialized_result.label, version=serialized_result.version, - _reason='resolved', + reason='resolved', ) except Exception as e: @@ -325,7 +346,7 @@ def _resolve( span.set_attribute('invalid_serialized_label', serialized_result.label) span.set_attribute('invalid_serialized_value', serialized_result.value) default = self._get_default(targeting_key, attributes) - return ResolvedVariable(name=self.name, value=default, exception=e, _reason='other_error') + return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') def _get_default( self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None @@ -385,19 +406,6 @@ def to_config(self) -> VariableConfig: ) -def _with_value(details: ResolvedVariable[Any], new_value: T_co) -> ResolvedVariable[T_co]: - """Return a copy of the provided resolution details, just with a different value. - - Args: - details: Existing resolution details to modify. - new_value: The new value to use. - - Returns: - A new ResolvedVariable with the given value. - """ - return replace(details, value=new_value) - - @contextmanager def targeting_context( targeting_key: str, diff --git a/tests/test_variables.py b/tests/test_variables.py index 20d0dc6a9..f988a4c1f 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -634,7 +634,7 @@ def test_returns_none(self): provider = NoOpVariableProvider() result = provider.get_serialized_value('any_variable') assert result.value is None - assert result._reason == 'no_provider' + assert result.reason == 'no_provider' def test_with_targeting_key_and_attributes(self): provider = NoOpVariableProvider() @@ -662,19 +662,19 @@ def test_shutdown_does_nothing(self): class TestResolvedVariable: def test_basic_details(self): - details = ResolvedVariable(name='test_var', value='test', _reason='resolved') + details = ResolvedVariable(name='test_var', value='test', reason='resolved') assert details.name == 'test_var' assert details.value == 'test' assert details.label is None assert details.exception is None def test_with_label(self): - details = ResolvedVariable(name='test_var', value='test', label='v1', _reason='resolved') + details = ResolvedVariable(name='test_var', value='test', label='v1', reason='resolved') assert details.label == 'v1' def test_with_exception(self): error = ValueError('test error') - details = ResolvedVariable(name='test_var', value='default', exception=error, _reason='validation_error') + details = ResolvedVariable(name='test_var', value='default', exception=error, reason='validation_error') assert details.exception is error def test_context_manager_sets_baggage(self, config_kwargs: dict[str, Any]): @@ -837,7 +837,7 @@ def test_get_serialized_value_basic(self, simple_config: VariablesConfig): result = provider.get_serialized_value('test_var') assert result.value == '"default_value"' assert result.label == 'default' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_get_serialized_value_with_override(self, simple_config: VariablesConfig): provider = LocalVariableProvider(simple_config) @@ -852,7 +852,7 @@ def test_get_serialized_value_unrecognized(self, simple_config: VariablesConfig) provider = LocalVariableProvider(simple_config) result = provider.get_serialized_value('unknown_var') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' def test_rollout_returns_none(self): config = VariablesConfig( @@ -868,7 +868,7 @@ def test_rollout_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value('partial_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' # ============================================================================= @@ -939,7 +939,7 @@ def test_get_serialized_value_missing_config_no_block(self) -> None: # Without blocking, config might not be fetched yet result = provider.get_serialized_value('test_var') # Should return missing_config if not fetched - assert result._reason in ('missing_config', 'resolved', 'unrecognized_variable') + assert result.reason in ('missing_config', 'resolved', 'unrecognized_variable') finally: provider.shutdown() @@ -975,7 +975,7 @@ def test_unrecognized_variable(self) -> None: try: result = provider.get_serialized_value('nonexistent_var') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1043,7 +1043,7 @@ def test_refresh_with_force(self) -> None: try: provider.refresh(force=True) result = provider.get_serialized_value('test_var') - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1081,7 +1081,7 @@ def test_rollout_returns_none_label(self) -> None: try: result = provider.get_serialized_value('partial_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' finally: provider.shutdown() @@ -1160,7 +1160,7 @@ def test_get_serialized_value_for_label_no_block(self) -> None: # since no config has been fetched yet result = provider.get_serialized_value_for_label('test_var', 'production') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' finally: provider.shutdown() @@ -1302,7 +1302,7 @@ def test_handles_unexpected_response(self) -> None: try: # The mock returns an error, so config should not be set result = provider.get_serialized_value('test_var') - assert result._reason == 'missing_config' + assert result.reason == 'missing_config' finally: provider.shutdown() @@ -1325,7 +1325,7 @@ def test_handles_validation_error(self) -> None: try: # The mock returns invalid data, so validation error happens result = provider.get_serialized_value('test_var') - assert result._reason == 'missing_config' + assert result.reason == 'missing_config' finally: provider.shutdown() @@ -1652,15 +1652,40 @@ def test_get_details_with_validation_error(self, config_kwargs: dict[str, Any], # Falls back to default when validation fails assert details.value == 999 assert details.exception is not None - assert details._reason == 'validation_error' + assert details.reason == 'validation_error' + assert details.label == 'default' + assert details.version == 1 def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) var = lf.var(name='unconfigured', default='my_default', type=str) - value = var.get().value - assert value == 'my_default' + result = var.get() + assert result.value == 'my_default' + assert result.reason == 'code_default' + + def test_get_preserves_provider_exception_when_using_code_default( + self, config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + provider_error = RuntimeError('missing') + + def missing_get( + variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None + ) -> ResolvedVariable[str | None]: + return ResolvedVariable( + name=variable_name, value=None, exception=provider_error, reason='unrecognized_variable' + ) + + monkeypatch.setattr(lf.config._variable_provider, 'get_serialized_value', missing_get) + + var = lf.var(name='unconfigured', default='my_default', type=str) + result = var.get() + assert result.value == 'my_default' + assert result.reason == 'code_default' + assert result.exception is provider_error def test_override_context_manager(self, config_kwargs: dict[str, Any], variables_config: VariablesConfig): config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) @@ -2197,15 +2222,15 @@ def test_exception_handling_in_get_details(self, config_kwargs: dict[str, Any]): original = lf.config._variable_provider.get_serialized_value def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: - raise RuntimeError('Provider failed!') + raise IndexError('Provider failed!') lf.config._variable_provider.get_serialized_value = failing_get var = lf.var(name='failing_var', default='fallback', type=str) details = var.get() assert details.value == 'fallback' - assert details._reason == 'other_error' - assert isinstance(details.exception, RuntimeError) + assert details.reason == 'other_error' + assert isinstance(details.exception, IndexError) # Restore original lf.config._variable_provider.get_serialized_value = original @@ -2849,7 +2874,7 @@ def test_alias_resolution_success(self): # Access via alias result = config.resolve_serialized_value('old_name') assert result.value == '"value"' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_multiple_aliases(self): """Test that multiple aliases resolve correctly.""" @@ -2868,7 +2893,7 @@ def test_multiple_aliases(self): for alias in ['alias1', 'alias2', 'alias3']: result = config.resolve_serialized_value(alias) assert result.value == '"value"' - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_nonexistent_variable_returns_unrecognized(self): """Test that nonexistent variable returns unrecognized.""" @@ -2884,7 +2909,7 @@ def test_nonexistent_variable_returns_unrecognized(self): ) result = config.resolve_serialized_value('nonexistent') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' def test_direct_name_takes_precedence(self): """Test that direct variable name takes precedence over alias lookup.""" @@ -2922,7 +2947,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() result = provider.get_all_variables_config() @@ -2935,7 +2960,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableConfig( @@ -2955,7 +2980,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableConfig( @@ -2975,7 +3000,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not persist variable writes'): @@ -2994,7 +3019,7 @@ def __init__(self): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_variable_config(self, name: str) -> VariableConfig | None: return self.configs.get(name) @@ -3449,7 +3474,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -3476,7 +3501,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -3500,7 +3525,7 @@ class FailingApplyProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: return VariablesConfig(variables={}) @@ -3525,7 +3550,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -3547,7 +3572,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -3853,7 +3878,7 @@ def test_unknown_variable_returns_unrecognized(self): provider = NoOpVariableProvider() result = provider.get_serialized_value_for_label('nonexistent', 'v1') assert result.value is None - assert result._reason == 'unrecognized_variable' + assert result.reason == 'unrecognized_variable' class TestBaseVariableProviderTypesMethods: @@ -3866,7 +3891,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not support variable types'): @@ -3880,7 +3905,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() with pytest.warns(UserWarning, match='does not support variable types'): @@ -3895,7 +3920,7 @@ class MinimalProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover provider = MinimalProvider() config = VariableTypeConfig(name='test_type', json_schema={'type': 'string'}) @@ -4247,7 +4272,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, VariableTypeConfig]: return dict(self._types) @@ -4351,7 +4376,7 @@ class FailingRefreshProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def refresh(self, force: bool = False): raise RuntimeError('Refresh failed!') @@ -4375,7 +4400,7 @@ class FailingListProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, Any]: raise RuntimeError('List failed!') @@ -4397,7 +4422,7 @@ class FailingApplyProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, Any]: return {} @@ -4483,7 +4508,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def list_variable_types(self) -> dict[str, VariableTypeConfig]: return dict(self._types) @@ -4521,7 +4546,7 @@ def __init__(self) -> None: def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: return self._variables_config @@ -4617,7 +4642,7 @@ class FailingConfigProvider(VariableProvider): def get_serialized_value( self, variable_name: str, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None ) -> ResolvedVariable[str | None]: - return ResolvedVariable(name=variable_name, value=None, _reason='no_provider') # pragma: no cover + return ResolvedVariable(name=variable_name, value=None, reason='no_provider') # pragma: no cover def get_all_variables_config(self) -> VariablesConfig: raise RuntimeError('Config fetch failed!') @@ -4939,7 +4964,7 @@ def test_code_default_variable_returns_none(self): ) result = config.resolve_serialized_value('test_var') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestVariablesConfigValidationErrorsWithLatestVersion: @@ -5024,7 +5049,7 @@ def test_code_default_label_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value_for_label('test_var', 'v1') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestGetSerializedValueForLabelNotFound: @@ -5044,7 +5069,7 @@ def test_missing_label_returns_none(self): provider = LocalVariableProvider(config) result = provider.get_serialized_value_for_label('test_var', 'nonexistent') assert result.value is None - assert result._reason == 'resolved' + assert result.reason == 'resolved' class TestVariableGetWithExplicitLabel: @@ -5072,7 +5097,7 @@ def test_explicit_label_resolves_successfully(self, config_kwargs: dict[str, Any assert result.value == 'experiment_value' assert result.label == 'experiment' assert result.version == 2 - assert result._reason == 'resolved' + assert result.reason == 'resolved' def test_explicit_label_not_found_falls_through(self, config_kwargs: dict[str, Any]): variables_config = VariablesConfig( From be4926eb9c39292f1810db25581171bd4aac7874 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:03:20 +0200 Subject: [PATCH 02/18] Extract variable resolution structure --- logfire/variables/variable.py | 64 +++++++++++++++++++++++------------ tests/test_variables.py | 19 ++++++++++- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index dae0bb2c2..20a16d01b 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -27,6 +27,7 @@ __all__ = ( 'ResolveFunction', 'is_resolve_function', + '_BaseVariable', 'Variable', 'targeting_context', ) @@ -37,6 +38,15 @@ _VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None) +def _record_exception(exception: BaseException, span: logfire.LogfireSpan) -> None: + """Record an exception on a span, ignoring a CPython traceback extraction bug.""" + try: + span.record_exception(exception) + except RuntimeError as exc: + if 'generator raised StopIteration' not in str(exc): + raise + + @dataclass class _TargetingContextData: """Internal data structure for targeting context.""" @@ -115,8 +125,8 @@ def is_resolve_function(f: Any) -> TypeIs[ResolveFunction[Any]]: return required_positional <= 2 and total_positional >= 2 -class Variable(Generic[T_co]): - """A managed variable that can be resolved dynamically based on configuration.""" +class _BaseVariable(Generic[T_co]): + """Base class for managed variables with shared resolution infrastructure.""" name: str """Unique name identifying this variable.""" @@ -187,28 +197,12 @@ def refresh_sync(self, force: bool = False): """Synchronously refresh the variable.""" self.logfire_instance.config.get_variable_provider().refresh(force=force) - def get( + def _get_result_and_record_span( self, targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None, - *, label: str | None = None, ) -> ResolvedVariable[T_co]: - """Resolve the variable and return full details including label, version, and any errors. - - Args: - targeting_key: Optional key for deterministic label selection (e.g., user ID). - If not provided, falls back to contextvar targeting key (set via targeting_context), - then to the current trace ID if there is an active trace. - attributes: Optional attributes for condition-based targeting rules. - label: Optional explicit label name to select. If provided, bypasses rollout - weights and targeting, directly selecting the specified label. If the label - doesn't exist in the configuration, falls back to default resolution. - - Returns: - A ResolvedVariable object containing the resolved value, selected label, - version, and any errors that occurred. - """ merged_attributes = self._get_merged_attributes(attributes) # Targeting key resolution: call-site > contextvar > trace_id @@ -252,9 +246,7 @@ def get( } ) if result.exception: - span.record_exception( - result.exception, - ) + _record_exception(result.exception, span) return result def _resolve( @@ -406,6 +398,34 @@ def to_config(self) -> VariableConfig: ) +class Variable(_BaseVariable[T_co]): + """A managed variable that can be resolved dynamically based on configuration.""" + + def get( + self, + targeting_key: str | None = None, + attributes: Mapping[str, Any] | None = None, + *, + label: str | None = None, + ) -> ResolvedVariable[T_co]: + """Resolve the variable and return full details including label, version, and any errors. + + Args: + targeting_key: Optional key for deterministic label selection (e.g., user ID). + If not provided, falls back to contextvar targeting key (set via targeting_context), + then to the current trace ID if there is an active trace. + attributes: Optional attributes for condition-based targeting rules. + label: Optional explicit label name to select. If provided, bypasses rollout + weights and targeting, directly selecting the specified label. If the label + doesn't exist in the configuration, falls back to default resolution. + + Returns: + A ResolvedVariable object containing the resolved value, selected label, + version, and any errors that occurred. + """ + return self._get_result_and_record_span(targeting_key, attributes, label) + + @contextmanager def targeting_context( targeting_key: str, diff --git a/tests/test_variables.py b/tests/test_variables.py index f988a4c1f..bb1b6e927 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -39,7 +39,7 @@ ) from logfire.variables.local import LocalVariableProvider from logfire.variables.remote import LogfireRemoteVariableProvider -from logfire.variables.variable import is_resolve_function +from logfire.variables.variable import _record_exception, is_resolve_function # ============================================================================= # Test Condition Classes @@ -2235,6 +2235,23 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: # Restore original lf.config._variable_provider.get_serialized_value = original + def test_record_exception_ignores_cpython_traceback_bug(self): + span = unittest.mock.Mock() + error = ValueError('Provider failed!') + span.record_exception.side_effect = RuntimeError('generator raised StopIteration') + + _record_exception(error, span) + + span.record_exception.assert_called_once_with(error) + + def test_record_exception_reraises_other_runtime_errors(self): + span = unittest.mock.Mock() + error = ValueError('Provider failed!') + span.record_exception.side_effect = RuntimeError('unexpected recording failure') + + with pytest.raises(RuntimeError, match='unexpected recording failure'): + _record_exception(error, span) + def test_variables_build_config(self, config_kwargs: dict[str, Any]): """Test that variables_build_config on a Logfire instance delegates to VariablesConfig.from_variables.""" lf = logfire.configure(**config_kwargs) From 796e3336ac298d1ea1fcd5b6ffe3cd3daabe9ce2 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:07:32 +0200 Subject: [PATCH 03/18] Widen targeting context variable type --- logfire/variables/variable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 20a16d01b..51aa10c67 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -429,7 +429,7 @@ def get( @contextmanager def targeting_context( targeting_key: str, - variables: Sequence[Variable[Any]] | None = None, + variables: Sequence[_BaseVariable[Any]] | None = None, ) -> Generator[None]: """Set the targeting key for variable resolution within this context. From 817ade8823b44302d910566c9a278f5a5f72d7a2 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:10:27 +0200 Subject: [PATCH 04/18] Record placeholder exception event --- logfire/variables/variable.py | 15 +++++++++++++++ tests/test_variables.py | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 51aa10c67..91db63ec7 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -45,6 +45,21 @@ def _record_exception(exception: BaseException, span: logfire.LogfireSpan) -> No except RuntimeError as exc: if 'generator raised StopIteration' not in str(exc): raise + module = type(exception).__module__ + qualname = type(exception).__qualname__ + exception_type = f'{module}.{qualname}' if module and module != 'builtins' else qualname + otel_span = span._span # pyright: ignore[reportPrivateUsage] + assert otel_span is not None + otel_span.add_event( + 'exception', + attributes={ + 'exception.type': exception_type, + 'exception.message': str(exception), + 'exception.stacktrace': ( + 'Traceback unavailable: traceback formatting raised RuntimeError("generator raised StopIteration")' + ), + }, + ) @dataclass diff --git a/tests/test_variables.py b/tests/test_variables.py index bb1b6e927..c395d0e8e 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -2237,12 +2237,24 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: def test_record_exception_ignores_cpython_traceback_bug(self): span = unittest.mock.Mock() + otel_span = unittest.mock.Mock() + span._span = otel_span error = ValueError('Provider failed!') span.record_exception.side_effect = RuntimeError('generator raised StopIteration') _record_exception(error, span) span.record_exception.assert_called_once_with(error) + otel_span.add_event.assert_called_once_with( + 'exception', + attributes={ + 'exception.type': 'ValueError', + 'exception.message': 'Provider failed!', + 'exception.stacktrace': ( + 'Traceback unavailable: traceback formatting raised RuntimeError("generator raised StopIteration")' + ), + }, + ) def test_record_exception_reraises_other_runtime_errors(self): span = unittest.mock.Mock() From 52fec272e9657f217f7d5b1f3b657e1fa97836c7 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:21:24 +0200 Subject: [PATCH 05/18] Remove exception recording changes --- logfire/variables/variable.py | 36 +++++++++-------------------------- tests/test_variables.py | 31 +----------------------------- 2 files changed, 10 insertions(+), 57 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 91db63ec7..c415ace23 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -38,30 +38,6 @@ _VARIABLE_OVERRIDES: ContextVar[dict[str, Any] | None] = ContextVar('_VARIABLE_OVERRIDES', default=None) -def _record_exception(exception: BaseException, span: logfire.LogfireSpan) -> None: - """Record an exception on a span, ignoring a CPython traceback extraction bug.""" - try: - span.record_exception(exception) - except RuntimeError as exc: - if 'generator raised StopIteration' not in str(exc): - raise - module = type(exception).__module__ - qualname = type(exception).__qualname__ - exception_type = f'{module}.{qualname}' if module and module != 'builtins' else qualname - otel_span = span._span # pyright: ignore[reportPrivateUsage] - assert otel_span is not None - otel_span.add_event( - 'exception', - attributes={ - 'exception.type': exception_type, - 'exception.message': str(exception), - 'exception.stacktrace': ( - 'Traceback unavailable: traceback formatting raised RuntimeError("generator raised StopIteration")' - ), - }, - ) - - @dataclass class _TargetingContextData: """Internal data structure for targeting context.""" @@ -141,7 +117,11 @@ def is_resolve_function(f: Any) -> TypeIs[ResolveFunction[Any]]: class _BaseVariable(Generic[T_co]): - """Base class for managed variables with shared resolution infrastructure.""" + """Base class for managed variables with shared resolution infrastructure. + + Contains all shared logic: init, deserialization, override, refresh, config, + resolution pipeline. Subclasses (Variable, TemplateVariable) add their own get() method. + """ name: str """Unique name identifying this variable.""" @@ -261,7 +241,9 @@ def _get_result_and_record_span( } ) if result.exception: - _record_exception(result.exception, span) + span.record_exception( + result.exception, + ) return result def _resolve( @@ -444,7 +426,7 @@ def get( @contextmanager def targeting_context( targeting_key: str, - variables: Sequence[_BaseVariable[Any]] | None = None, + variables: Sequence[Variable[Any]] | None = None, ) -> Generator[None]: """Set the targeting key for variable resolution within this context. diff --git a/tests/test_variables.py b/tests/test_variables.py index c395d0e8e..f988a4c1f 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -39,7 +39,7 @@ ) from logfire.variables.local import LocalVariableProvider from logfire.variables.remote import LogfireRemoteVariableProvider -from logfire.variables.variable import _record_exception, is_resolve_function +from logfire.variables.variable import is_resolve_function # ============================================================================= # Test Condition Classes @@ -2235,35 +2235,6 @@ def failing_get(*args: Any, **kwargs: Any) -> ResolvedVariable[str | None]: # Restore original lf.config._variable_provider.get_serialized_value = original - def test_record_exception_ignores_cpython_traceback_bug(self): - span = unittest.mock.Mock() - otel_span = unittest.mock.Mock() - span._span = otel_span - error = ValueError('Provider failed!') - span.record_exception.side_effect = RuntimeError('generator raised StopIteration') - - _record_exception(error, span) - - span.record_exception.assert_called_once_with(error) - otel_span.add_event.assert_called_once_with( - 'exception', - attributes={ - 'exception.type': 'ValueError', - 'exception.message': 'Provider failed!', - 'exception.stacktrace': ( - 'Traceback unavailable: traceback formatting raised RuntimeError("generator raised StopIteration")' - ), - }, - ) - - def test_record_exception_reraises_other_runtime_errors(self): - span = unittest.mock.Mock() - error = ValueError('Provider failed!') - span.record_exception.side_effect = RuntimeError('unexpected recording failure') - - with pytest.raises(RuntimeError, match='unexpected recording failure'): - _record_exception(error, span) - def test_variables_build_config(self, config_kwargs: dict[str, Any]): """Test that variables_build_config on a Logfire instance delegates to VariablesConfig.from_variables.""" lf = logfire.configure(**config_kwargs) From 81eb0ee7b3337bb7c33ee2352baf69febb55c380 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:24:45 +0200 Subject: [PATCH 06/18] Extract variable resolve helpers --- logfire/variables/variable.py | 103 +++++++++++++++++----------------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index c415ace23..50cee0de8 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -268,67 +268,17 @@ def _resolve( serialized_result = provider.get_serialized_value_for_label(self.name, label) if serialized_result.value is not None: # Successfully got the explicit label - value_or_exc = self._deserialize(serialized_result.value) - if isinstance(value_or_exc, Exception): - if span: # pragma: no branch - span.set_attribute('invalid_serialized_label', serialized_result.label) - span.set_attribute('invalid_serialized_value', serialized_result.value) - default = self._get_default(targeting_key, attributes) - reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable( - name=self.name, - value=default, - exception=value_or_exc, - reason=reason, - label=serialized_result.label, - version=serialized_result.version, - ) - return ResolvedVariable( - name=self.name, - value=value_or_exc, - label=serialized_result.label, - version=serialized_result.version, - reason='resolved', - ) + return self._deserialize_result(serialized_result, targeting_key, attributes, span) # Label not found - fall through to default resolution serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) if serialized_result.value is None: # Provider had no value; surface that the code default was used. - return ResolvedVariable( - name=self.name, - value=self._get_default(targeting_key, attributes), - exception=serialized_result.exception, - label=serialized_result.label, - version=serialized_result.version, - reason='code_default', - ) + return self._resolve_code_default(serialized_result, targeting_key, attributes) # Deserialize - returns T | Exception - value_or_exc = self._deserialize(serialized_result.value) - if isinstance(value_or_exc, Exception): - if span: # pragma: no branch - span.set_attribute('invalid_serialized_label', serialized_result.label) - span.set_attribute('invalid_serialized_value', serialized_result.value) - default = self._get_default(targeting_key, attributes) - reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' - return ResolvedVariable( - name=self.name, - value=default, - exception=value_or_exc, - reason=reason, - label=serialized_result.label, - version=serialized_result.version, - ) - - return ResolvedVariable( - name=self.name, - value=value_or_exc, - label=serialized_result.label, - version=serialized_result.version, - reason='resolved', - ) + return self._deserialize_result(serialized_result, targeting_key, attributes, span) except Exception as e: if span and serialized_result is not None: # pragma: no cover @@ -337,6 +287,53 @@ def _resolve( default = self._get_default(targeting_key, attributes) return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') + def _resolve_code_default( + self, + serialized_result: ResolvedVariable[str | None], + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + ) -> ResolvedVariable[T_co]: + return ResolvedVariable( + name=self.name, + value=self._get_default(targeting_key, attributes), + exception=serialized_result.exception, + label=serialized_result.label, + version=serialized_result.version, + reason='code_default', + ) + + def _deserialize_result( + self, + serialized_result: ResolvedVariable[str | None], + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + span: logfire.LogfireSpan | None, + ) -> ResolvedVariable[T_co]: + assert serialized_result.value is not None + value_or_exc = self._deserialize(serialized_result.value) + if isinstance(value_or_exc, Exception): + if span: # pragma: no branch + span.set_attribute('invalid_serialized_label', serialized_result.label) + span.set_attribute('invalid_serialized_value', serialized_result.value) + default = self._get_default(targeting_key, attributes) + reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' + return ResolvedVariable( + name=self.name, + value=default, + exception=value_or_exc, + reason=reason, + label=serialized_result.label, + version=serialized_result.version, + ) + + return ResolvedVariable( + name=self.name, + value=value_or_exc, + label=serialized_result.label, + version=serialized_result.version, + reason='resolved', + ) + def _get_default( self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None ) -> T_co: From 7f0b05d8892baee18d520f98c9956d64dd303031 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:27:24 +0200 Subject: [PATCH 07/18] Align variable span helper location --- logfire/variables/variable.py | 108 +++++++++++++++++----------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 50cee0de8..27902df51 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -192,60 +192,6 @@ def refresh_sync(self, force: bool = False): """Synchronously refresh the variable.""" self.logfire_instance.config.get_variable_provider().refresh(force=force) - def _get_result_and_record_span( - self, - targeting_key: str | None = None, - attributes: Mapping[str, Any] | None = None, - label: str | None = None, - ) -> ResolvedVariable[T_co]: - merged_attributes = self._get_merged_attributes(attributes) - - # Targeting key resolution: call-site > contextvar > trace_id - if targeting_key is None: - targeting_key = _get_contextvar_targeting_key(self.name) - - if targeting_key is None and (current_trace_id := get_current_span().get_span_context().trace_id): - # If there is no active trace, the current_trace_id will be zero - targeting_key = f'trace_id:{current_trace_id:032x}' - - # Include the variable name directly here to make the span name more useful, - # it'll still be low cardinality. This also prevents it from being scrubbed from the message. - # Don't inline the f-string to avoid f-string magic. - span_name = f'Resolve variable {self.name}' - with ExitStack() as stack: - span: logfire.LogfireSpan | None = None - if _get_variables_instrument(self.logfire_instance.config.variables): - span = stack.enter_context( - self.logfire_instance.span( - span_name, - name=self.name, - targeting_key=targeting_key, - attributes=merged_attributes, - ) - ) - result = self._resolve(targeting_key, merged_attributes, span, label) - if span is not None: - # Serialize value safely for OTel span attributes, which only support primitives. - # Try to JSON serialize the value; if that fails, fall back to string representation. - try: - serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') - except Exception: - serialized_value = repr(result.value) - span.set_attributes( - { - 'name': result.name, - 'value': serialized_value, - 'label': result.label, - 'version': result.version, - 'reason': result.reason, - } - ) - if result.exception: - span.record_exception( - result.exception, - ) - return result - def _resolve( self, targeting_key: str | None, @@ -391,6 +337,60 @@ def to_config(self) -> VariableConfig: example=example, ) + def _get_result_and_record_span( + self, + targeting_key: str | None = None, + attributes: Mapping[str, Any] | None = None, + label: str | None = None, + ) -> ResolvedVariable[T_co]: + merged_attributes = self._get_merged_attributes(attributes) + + # Targeting key resolution: call-site > contextvar > trace_id + if targeting_key is None: + targeting_key = _get_contextvar_targeting_key(self.name) + + if targeting_key is None and (current_trace_id := get_current_span().get_span_context().trace_id): + # If there is no active trace, the current_trace_id will be zero + targeting_key = f'trace_id:{current_trace_id:032x}' + + # Include the variable name directly here to make the span name more useful, + # it'll still be low cardinality. This also prevents it from being scrubbed from the message. + # Don't inline the f-string to avoid f-string magic. + span_name = f'Resolve variable {self.name}' + with ExitStack() as stack: + span: logfire.LogfireSpan | None = None + if _get_variables_instrument(self.logfire_instance.config.variables): + span = stack.enter_context( + self.logfire_instance.span( + span_name, + name=self.name, + targeting_key=targeting_key, + attributes=merged_attributes, + ) + ) + result = self._resolve(targeting_key, merged_attributes, span, label) + if span is not None: + # Serialize value safely for OTel span attributes, which only support primitives. + # Try to JSON serialize the value; if that fails, fall back to string representation. + try: + serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') + except Exception: + serialized_value = repr(result.value) + span.set_attributes( + { + 'name': result.name, + 'value': serialized_value, + 'label': result.label, + 'version': result.version, + 'reason': result.reason, + } + ) + if result.exception: + span.record_exception( + result.exception, + ) + return result + class Variable(_BaseVariable[T_co]): """A managed variable that can be resolved dynamically based on configuration.""" From 611b4b162b59af97befbbc503c367906fc903227 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:32:29 +0200 Subject: [PATCH 08/18] Add render hook to variable span helper --- logfire/variables/variable.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 27902df51..1973ebd7a 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -1,7 +1,7 @@ from __future__ import annotations as _annotations import inspect -from collections.abc import Generator, Mapping, Sequence +from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import ExitStack, contextmanager from contextvars import ContextVar from dataclasses import dataclass, field @@ -198,6 +198,7 @@ def _resolve( attributes: Mapping[str, Any] | None, span: logfire.LogfireSpan | None, label: str | None = None, + render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: serialized_result: ResolvedVariable[str | None] | None = None try: @@ -342,6 +343,7 @@ def _get_result_and_record_span( targeting_key: str | None = None, attributes: Mapping[str, Any] | None = None, label: str | None = None, + render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: merged_attributes = self._get_merged_attributes(attributes) @@ -368,7 +370,7 @@ def _get_result_and_record_span( attributes=merged_attributes, ) ) - result = self._resolve(targeting_key, merged_attributes, span, label) + result = self._resolve(targeting_key, merged_attributes, span, label, render_fn=render_fn) if span is not None: # Serialize value safely for OTel span attributes, which only support primitives. # Try to JSON serialize the value; if that fails, fall back to string representation. From 5b16a87ca4c7dc5f192d1dfeb4f6ff0f0f0d3dd7 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:35:17 +0200 Subject: [PATCH 09/18] Align override rendering hook --- logfire/variables/variable.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 1973ebd7a..0541aed39 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -206,6 +206,10 @@ def _resolve( context_value = context_overrides[self.name] if is_resolve_function(context_value): context_value = context_value(targeting_key, attributes) + # For TemplateVariable (render_fn set), the override is a template + # that still gets rendered with inputs. + if render_fn is not None: + context_value = self._render_default(context_value, render_fn) return ResolvedVariable(name=self.name, value=context_value, reason='context_override') provider = self.logfire_instance.config.get_variable_provider() @@ -234,6 +238,15 @@ def _resolve( default = self._get_default(targeting_key, attributes) return ResolvedVariable(name=self.name, value=default, exception=e, reason='other_error') + def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co: + """Serialize the default value, apply render_fn, then deserialize back.""" + serialized = self.type_adapter.dump_json(default).decode('utf-8') + rendered = render_fn(serialized) + result = self._deserialize(rendered) + if isinstance(result, Exception): + raise result + return result + def _resolve_code_default( self, serialized_result: ResolvedVariable[str | None], From 01570d3c5da1bf259ff720690e8159108953f693 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:40:30 +0200 Subject: [PATCH 10/18] Align variable deserialization helpers --- logfire/variables/variable.py | 96 ++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 0541aed39..fd62fb165 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -13,6 +13,7 @@ from typing_extensions import TypeIs if TYPE_CHECKING: + from logfire.variables.abstract import VariableProvider from logfire.variables.config import VariableConfig if find_spec('anyio') is not None: # pragma: no branch @@ -218,18 +219,36 @@ def _resolve( if label is not None: serialized_result = provider.get_serialized_value_for_label(self.name, label) if serialized_result.value is not None: - # Successfully got the explicit label - return self._deserialize_result(serialized_result, targeting_key, attributes, span) + return self._expand_and_deserialize( + serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn + ) # Label not found - fall through to default resolution serialized_result = provider.get_serialized_value(self.name, targeting_key, attributes) if serialized_result.value is None: + default_result = self._resolve_serialized_default( + provider, + targeting_key, + attributes, + span, + render_fn=render_fn, + ) + if default_result is not None: + return default_result # Provider had no value; surface that the code default was used. - return self._resolve_code_default(serialized_result, targeting_key, attributes) + return ResolvedVariable( + name=self.name, + value=self._get_default(targeting_key, attributes), + exception=serialized_result.exception, + label=serialized_result.label, + version=serialized_result.version, + reason='code_default', + ) - # Deserialize - returns T | Exception - return self._deserialize_result(serialized_result, targeting_key, attributes, span) + return self._expand_and_deserialize( + serialized_result, provider, targeting_key, attributes, span, render_fn=render_fn + ) except Exception as e: if span and serialized_result is not None: # pragma: no cover @@ -247,34 +266,26 @@ def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co raise result return result - def _resolve_code_default( - self, - serialized_result: ResolvedVariable[str | None], - targeting_key: str | None, - attributes: Mapping[str, Any] | None, - ) -> ResolvedVariable[T_co]: - return ResolvedVariable( - name=self.name, - value=self._get_default(targeting_key, attributes), - exception=serialized_result.exception, - label=serialized_result.label, - version=serialized_result.version, - reason='code_default', - ) - - def _deserialize_result( + def _expand_and_deserialize( self, serialized_result: ResolvedVariable[str | None], + provider: VariableProvider, targeting_key: str | None, attributes: Mapping[str, Any] | None, span: logfire.LogfireSpan | None, + render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: assert serialized_result.value is not None - value_or_exc = self._deserialize(serialized_result.value) + + serialized_value = serialized_result.value + if render_fn is not None: + serialized_value = render_fn(serialized_value) + + value_or_exc = self._deserialize(serialized_value) if isinstance(value_or_exc, Exception): if span: # pragma: no branch span.set_attribute('invalid_serialized_label', serialized_result.label) - span.set_attribute('invalid_serialized_value', serialized_result.value) + span.set_attribute('invalid_serialized_value', serialized_value) default = self._get_default(targeting_key, attributes) reason: str = 'validation_error' if isinstance(value_or_exc, ValidationError) else 'other_error' return ResolvedVariable( @@ -302,6 +313,45 @@ def _get_default( else: return self.default + def _get_serialized_default( + self, targeting_key: str | None = None, merged_attributes: Mapping[str, Any] | None = None + ) -> str | None: + """Return the code default serialized as JSON, or None if serialization fails.""" + try: + default = self._get_default(targeting_key, merged_attributes) + return self.type_adapter.dump_json(default).decode('utf-8') + except (ValueError, TypeError, RuntimeError): + return None + + def _resolve_serialized_default( + self, + provider: VariableProvider, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + span: logfire.LogfireSpan | None, + render_fn: Callable[[str], str] | None = None, + ) -> ResolvedVariable[T_co] | None: + """Resolve the code default through composition/rendering when needed.""" + serialized_default = self._get_serialized_default(targeting_key, attributes) + if serialized_default is None: + return None + if render_fn is None: + return None + + result = self._expand_and_deserialize( + ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), + provider, + targeting_key, + attributes, + span, + render_fn=render_fn, + ) + if result.reason == 'resolved': + # The expansion succeeded against the code default; flag the top-level + # reason as 'code_default' so callers can distinguish from a provider hit. + result.reason = 'code_default' + return result + def _get_merged_attributes(self, attributes: Mapping[str, Any] | None = None) -> Mapping[str, Any]: from logfire._internal.config import LocalVariablesOptions, VariablesOptions From e4d85e68a31432dcd2436cbe535b2e8729181ece Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:47:33 +0200 Subject: [PATCH 11/18] Align variable deserialize errors --- logfire/variables/variable.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index fd62fb165..6aebdf8c8 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -163,11 +163,11 @@ def __init__( self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) - def _deserialize(self, serialized_value: str) -> T_co | Exception: + def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" try: return self.type_adapter.validate_json(serialized_value) - except Exception as e: + except (ValidationError, ValueError) as e: return e @contextmanager @@ -262,7 +262,7 @@ def _render_default(self, default: Any, render_fn: Callable[[str], str]) -> T_co serialized = self.type_adapter.dump_json(default).decode('utf-8') rendered = render_fn(serialized) result = self._deserialize(rendered) - if isinstance(result, Exception): + if isinstance(result, (ValidationError, ValueError)): raise result return result From 36539fc5e9f03aa5fdebf7f8c4a98b6158f2e329 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:49:49 +0200 Subject: [PATCH 12/18] Align variable span helper signature --- logfire/variables/variable.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 6aebdf8c8..332571c60 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -403,11 +403,12 @@ def to_config(self) -> VariableConfig: def _get_result_and_record_span( self, - targeting_key: str | None = None, - attributes: Mapping[str, Any] | None = None, - label: str | None = None, + targeting_key: str | None, + attributes: Mapping[str, Any] | None, + label: str | None, render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co]: + """Common get() logic: resolve targeting key, open span, call _resolve, record attributes.""" merged_attributes = self._get_merged_attributes(attributes) # Targeting key resolution: call-site > contextvar > trace_id From 25247ac8d716760e6ba772add2911afd670e3ef1 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:51:41 +0200 Subject: [PATCH 13/18] Align variable helper stubs --- logfire/variables/variable.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 332571c60..831acebdc 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -163,6 +163,14 @@ def __init__( self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) + def get_template_inputs_schema(self) -> dict[str, Any] | None: + """Return the JSON schema for template inputs. + + Returns None on plain `Variable` instances. `TemplateVariable` overrides this + to return the schema derived from its `inputs_type`. + """ + return None + def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" try: @@ -440,7 +448,7 @@ def _get_result_and_record_span( # Try to JSON serialize the value; if that fails, fall back to string representation. try: serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') - except Exception: + except (ValueError, TypeError, RuntimeError): serialized_value = repr(result.value) span.set_attributes( { From 019ae7a64e2712dbafed77d93f44e44390444be1 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 15:53:33 +0200 Subject: [PATCH 14/18] Align variable span attributes --- logfire/variables/variable.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 831acebdc..6dcc31003 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -450,15 +450,14 @@ def _get_result_and_record_span( serialized_value = self.type_adapter.dump_json(result.value).decode('utf-8') except (ValueError, TypeError, RuntimeError): serialized_value = repr(result.value) - span.set_attributes( - { - 'name': result.name, - 'value': serialized_value, - 'label': result.label, - 'version': result.version, - 'reason': result.reason, - } - ) + attrs: dict[str, Any] = { + 'name': result.name, + 'value': serialized_value, + 'label': result.label, + 'version': result.version, + 'reason': result.reason, + } + span.set_attributes(attrs) if result.exception: span.record_exception( result.exception, From 69edbc4ecc84ff90b67541039e5cf509e5dbe3cf Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 16:06:15 +0200 Subject: [PATCH 15/18] Cover variable render helper paths --- tests/test_variables.py | 90 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/test_variables.py b/tests/test_variables.py index f988a4c1f..b2fffd369 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1665,6 +1665,96 @@ def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): assert result.value == 'my_default' assert result.reason == 'code_default' + def test_get_template_inputs_schema_defaults_to_none(self, config_kwargs: dict[str, Any]): + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='plain_var', default='default', type=str) + + assert var.get_template_inputs_schema() is None + + def test_get_result_applies_render_fn_to_provider_value( + self, config_kwargs: dict[str, Any], variables_config: VariablesConfig + ): + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='string_var', default='default_value', type=str) + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"rendered"') + + assert result.value == 'rendered' + assert result.reason == 'resolved' + + def test_get_result_applies_render_fn_to_context_override( + self, config_kwargs: dict[str, Any], variables_config: VariablesConfig + ): + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='string_var', default='default_value', type=str) + + with var.override('overridden'): + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"rendered"') + + assert result.value == 'rendered' + assert result.reason == 'context_override' + + def test_get_result_returns_render_error_for_context_override( + self, config_kwargs: dict[str, Any], variables_config: VariablesConfig + ): + config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='int_var', default=0, type=int) + + with var.override(1): + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') + + assert result.value == 0 + assert result.reason == 'other_error' + assert isinstance(result.exception, ValidationError) + + def test_get_result_applies_render_fn_to_code_default(self, config_kwargs: dict[str, Any]): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='unconfigured', default='my_default', type=str) + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"rendered_default"') + + assert result.value == 'rendered_default' + assert result.reason == 'code_default' + + def test_get_result_returns_render_validation_error_for_code_default(self, config_kwargs: dict[str, Any]): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + var = lf.var(name='unconfigured', default=0, type=int) + result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') + + assert result.value == 0 + assert result.reason == 'validation_error' + assert isinstance(result.exception, ValidationError) + + def test_resolve_serialized_default_returns_none_when_default_cannot_be_serialized( + self, config_kwargs: dict[str, Any] + ): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + + def bad_default(targeting_key: str | None, attributes: Mapping[str, Any] | None) -> str: + raise RuntimeError('default failed') + + var = lf.var(name='unconfigured', default=bad_default, type=str) + + result = var._resolve_serialized_default( + lf.config.get_variable_provider(), + None, + None, + None, + render_fn=lambda value: value, + ) + + assert result is None + def test_get_preserves_provider_exception_when_using_code_default( self, config_kwargs: dict[str, Any], monkeypatch: pytest.MonkeyPatch ): From 3aa3c82d8e1fe779a471e2f5788eac805189e06d Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 16:23:18 +0200 Subject: [PATCH 16/18] Align variable render tests --- tests/test_variables.py | 44 +++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/tests/test_variables.py b/tests/test_variables.py index b2fffd369..2bd2ea91c 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1665,14 +1665,14 @@ def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): assert result.value == 'my_default' assert result.reason == 'code_default' - def test_get_template_inputs_schema_defaults_to_none(self, config_kwargs: dict[str, Any]): + def test_plain_variable_has_no_template_inputs_schema(self, config_kwargs: dict[str, Any]): lf = logfire.configure(**config_kwargs) var = lf.var(name='plain_var', default='default', type=str) assert var.get_template_inputs_schema() is None - def test_get_result_applies_render_fn_to_provider_value( + def test_render_fn_applies_to_provider_value( self, config_kwargs: dict[str, Any], variables_config: VariablesConfig ): config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) @@ -1684,7 +1684,7 @@ def test_get_result_applies_render_fn_to_provider_value( assert result.value == 'rendered' assert result.reason == 'resolved' - def test_get_result_applies_render_fn_to_context_override( + def test_render_fn_applies_to_context_override( self, config_kwargs: dict[str, Any], variables_config: VariablesConfig ): config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) @@ -1698,22 +1698,16 @@ def test_get_result_applies_render_fn_to_context_override( assert result.value == 'rendered' assert result.reason == 'context_override' - def test_get_result_returns_render_error_for_context_override( - self, config_kwargs: dict[str, Any], variables_config: VariablesConfig - ): - config_kwargs['variables'] = LocalVariablesOptions(config=variables_config) - lf = logfire.configure(**config_kwargs) - - var = lf.var(name='int_var', default=0, type=int) + int_var = lf.var(name='int_var', default=0, type=int) - with var.override(1): - result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') + with int_var.override(1): + invalid = int_var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') - assert result.value == 0 - assert result.reason == 'other_error' - assert isinstance(result.exception, ValidationError) + assert invalid.value == 0 + assert invalid.reason == 'other_error' + assert isinstance(invalid.exception, ValidationError) - def test_get_result_applies_render_fn_to_code_default(self, config_kwargs: dict[str, Any]): + def test_render_fn_applies_to_code_default(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) @@ -1723,20 +1717,14 @@ def test_get_result_applies_render_fn_to_code_default(self, config_kwargs: dict[ assert result.value == 'rendered_default' assert result.reason == 'code_default' - def test_get_result_returns_render_validation_error_for_code_default(self, config_kwargs: dict[str, Any]): - config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) - lf = logfire.configure(**config_kwargs) + invalid_var = lf.var(name='unconfigured_int', default=0, type=int) + invalid = invalid_var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') - var = lf.var(name='unconfigured', default=0, type=int) - result = var._get_result_and_record_span(None, None, None, render_fn=lambda _: '"not_an_int"') + assert invalid.value == 0 + assert invalid.reason == 'validation_error' + assert isinstance(invalid.exception, ValidationError) - assert result.value == 0 - assert result.reason == 'validation_error' - assert isinstance(result.exception, ValidationError) - - def test_resolve_serialized_default_returns_none_when_default_cannot_be_serialized( - self, config_kwargs: dict[str, Any] - ): + def test_render_fn_skips_code_default_when_default_cannot_be_serialized(self, config_kwargs: dict[str, Any]): config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) lf = logfire.configure(**config_kwargs) From d19c2d7aa482cf2ac3459c7bf66e6cd592de0e8e Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 20:48:28 +0200 Subject: [PATCH 17/18] Avoid serializing plain variable defaults --- logfire/variables/variable.py | 4 ++-- tests/test_variables.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 6dcc31003..2da9c2a93 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -340,11 +340,11 @@ def _resolve_serialized_default( render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co] | None: """Resolve the code default through composition/rendering when needed.""" + if render_fn is None: + return None serialized_default = self._get_serialized_default(targeting_key, attributes) if serialized_default is None: return None - if render_fn is None: - return None result = self._expand_and_deserialize( ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), diff --git a/tests/test_variables.py b/tests/test_variables.py index 2bd2ea91c..38f5e042c 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1665,6 +1665,22 @@ def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): assert result.value == 'my_default' assert result.reason == 'code_default' + def test_get_calls_function_default_once_when_no_config(self, config_kwargs: dict[str, Any]): + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + calls = 0 + + def default(targeting_key: str | None, attributes: Mapping[str, Any] | None) -> str: + nonlocal calls + calls += 1 + return 'my_default' + + var = lf.var(name='unconfigured', default=default, type=str) + result = var.get() + assert result.value == 'my_default' + assert result.reason == 'code_default' + assert calls == 1 + def test_plain_variable_has_no_template_inputs_schema(self, config_kwargs: dict[str, Any]): lf = logfire.configure(**config_kwargs) From 0b246268db43e5ac9492a388d0c322c31b3a47e1 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 21 May 2026 20:52:57 +0200 Subject: [PATCH 18/18] Revert variable default serialization fix --- logfire/variables/variable.py | 4 ++-- tests/test_variables.py | 16 ---------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 2da9c2a93..6dcc31003 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -340,11 +340,11 @@ def _resolve_serialized_default( render_fn: Callable[[str], str] | None = None, ) -> ResolvedVariable[T_co] | None: """Resolve the code default through composition/rendering when needed.""" - if render_fn is None: - return None serialized_default = self._get_serialized_default(targeting_key, attributes) if serialized_default is None: return None + if render_fn is None: + return None result = self._expand_and_deserialize( ResolvedVariable(name=self.name, value=serialized_default, reason='missing_config'), diff --git a/tests/test_variables.py b/tests/test_variables.py index 38f5e042c..2bd2ea91c 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1665,22 +1665,6 @@ def test_get_uses_default_when_no_config(self, config_kwargs: dict[str, Any]): assert result.value == 'my_default' assert result.reason == 'code_default' - def test_get_calls_function_default_once_when_no_config(self, config_kwargs: dict[str, Any]): - config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) - lf = logfire.configure(**config_kwargs) - calls = 0 - - def default(targeting_key: str | None, attributes: Mapping[str, Any] | None) -> str: - nonlocal calls - calls += 1 - return 'my_default' - - var = lf.var(name='unconfigured', default=default, type=str) - result = var.get() - assert result.value == 'my_default' - assert result.reason == 'code_default' - assert calls == 1 - def test_plain_variable_has_no_template_inputs_schema(self, config_kwargs: dict[str, Any]): lf = logfire.configure(**config_kwargs)