diff --git a/docs/reference/advanced/managed-variables/remote.md b/docs/reference/advanced/managed-variables/remote.md index 4dd33641f..c6cdaea0f 100644 --- a/docs/reference/advanced/managed-variables/remote.md +++ b/docs/reference/advanced/managed-variables/remote.md @@ -393,3 +393,62 @@ merged = server_config.merge(local_config) |--------|-------------| | `config.merge(other)` | Merge with another config (other takes precedence) | | `VariablesConfig.from_variables(vars)` | Create minimal config from Variable instances | + +## Change Notifications + +You can register callbacks that fire when a variable's configuration changes: + +```python +import logfire + +feature_enabled = logfire.var('feature_enabled', default=False) + + +@feature_enabled.on_change +def on_feature_change(): + new_value = feature_enabled.get().value + logfire.info('feature_enabled changed to {new_value}', new_value=new_value) +``` + +**What fires a notification:** + +- Changes to the resolution-relevant parts of the variable's configuration: its labels, + rollout, overrides, latest version, or aliases. Metadata-only edits (e.g. changing the + variable's description in the UI) do not fire. +- Changes to any variable this one (transitively) references via + [`@{ref}@` composition](templates-and-composition.md) — whether the reference appears in a + server-stored value or in the variable's code default — since the composed value this + variable resolves to may have changed even though its own configuration didn't. +- For local providers, `create_variable`, `update_variable`, and `delete_variable` fire the + same way (an update with an identical configuration does not). + +**Callbacks must be idempotent.** A configuration change does not necessarily change the +value *you* resolve to: a change to a label the rollout never serves, or to a value only +served for other targeting keys, still fires. Treat the callback as "re-read and +reconcile", not "the value definitely changed". + +Other key points: + +- Callbacks receive no arguments; call `variable.get()` to see the current value +- Callbacks may run on the provider's polling thread — keep them fast and non-blocking +- Don't create, update, or delete variables from inside a callback (that would re-enter + change notification) +- Multiple callbacks can be registered on the same variable +- Exceptions in callbacks are caught and logged (they don't crash the polling thread) +- Delivery of the *initial* load of remote configuration is best-effort: the provider may + fetch it before your callback is registered, so don't rely on it firing. To reliably + reconcile once at startup, call your handler yourself right after registering it: + +```python +import logfire + +feature_enabled = logfire.var('feature_enabled', default=False) + + +@feature_enabled.on_change +def on_feature_change(): + logfire.info('feature_enabled is now {value}', value=feature_enabled.get().value) + + +on_feature_change() # reconcile once at startup +``` diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 7a34891f6..0dcc7cc29 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -740,6 +740,13 @@ def configure( # are started when first accessed via get_variable_provider(). if config.variables is not None: config.get_variable_provider().start(logfire_instance if config.variables.instrument else None) + elif config._variable_change_polling_requested: # pyright: ignore[reportPrivateUsage] + # An on_change callback is registered but no variables were explicitly configured. The + # poller would otherwise only start on the first variable resolution, so callbacks would + # never fire if nothing is resolved. Trigger lazy provider init (which starts polling) so + # on_change works without an eager get(). This also restarts the poller after a reconfigure + # rebuilt the provider as a no-op. Does nothing if no API key is available to resolve against. + config.get_variable_provider() return logfire_instance @@ -1017,6 +1024,15 @@ def __init__( # thus it "shuts down" when it's gc'ed self._meter_provider = ProxyMeterProvider(NoOpMeterProvider()) self._variable_provider: VariableProvider = NoOpVariableProvider() + # Listeners for variable config changes. Held on the config (not the provider) so they + # survive reconfiguration: each provider this config creates is wired to dispatch here. + self._variables_change_listeners: list[Callable[[set[str]], None]] = [] + # Set once any variable's `on_change` callback is registered. on_change is only useful + # if the background poller is running, but the poller is otherwise started lazily on the + # first variable resolution. This flag lets us start it eagerly when a callback is + # registered, and restart it after a reconfigure (which rebuilds the provider). It lives + # on the config so it survives reconfiguration. + self._variable_change_polling_requested: bool = False self._logger_provider = ProxyLoggerProvider(NoOpLoggerProvider()) self._otlp_forwarding = OTLPForwardingManager([]) # This ensures that we only call OTEL's global set_tracer_provider once to avoid warnings. @@ -1489,6 +1505,7 @@ def fix_pid(): # pragma: no cover options=self.variables, server_response_hook=self.advanced.server_response_hook, ) + self._variable_provider.add_on_config_change(self._notify_variables_change_listeners) multi_log_processor = SynchronousMultiLogRecordProcessor() for processor in log_record_processors: multi_log_processor.add_log_record_processor(processor) @@ -1670,10 +1687,37 @@ def _lazy_init_variable_provider(self) -> VariableProvider: options=options, server_response_hook=self.advanced.server_response_hook, ) + provider.add_on_config_change(self._notify_variables_change_listeners) self._variable_provider = provider provider.start(Logfire(config=self)) return provider + def add_variables_change_listener(self, listener: Callable[[set[str]], None]) -> None: + """Register a listener for variable config changes. + + Registration is idempotent (adding the same listener twice has no effect) and + survives reconfiguration: every variable provider this config creates dispatches + its change notifications to the registered listeners. + + Args: + listener: Called with the set of variable names (including aliases) whose + configs changed. + """ + if listener not in self._variables_change_listeners: + self._variables_change_listeners.append(listener) + + def _notify_variables_change_listeners(self, changed_names: set[str]) -> None: + """Dispatch a provider's config-change notification to all registered listeners.""" + # Snapshot: dispatch runs on the provider's polling thread, and a concurrent + # registration on another thread shouldn't affect the in-flight dispatch. + for listener in list(self._variables_change_listeners): + try: + listener(changed_names) + except Exception: + import logging + + logging.getLogger('logfire').exception('Error in variables change listener') + 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 diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 63aa8a91c..b98e2c0c6 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -2603,6 +2603,7 @@ def var( description=description, ) self._variables[name] = variable + self._config.add_variables_change_listener(self._on_variables_config_change) from logfire.variables.variable import warn_on_template_inputs_composition_mismatch @@ -2740,6 +2741,7 @@ class PromptInputs(BaseModel): template_mismatch_policy=template_mismatch_policy, ) self._variables[name] = variable + self._config.add_variables_change_listener(self._on_variables_config_change) from logfire.variables.variable import warn_on_template_inputs_composition_mismatch @@ -2747,6 +2749,44 @@ class PromptInputs(BaseModel): return variable + def _on_variables_config_change(self, changed_names: set[str]) -> None: + """Dispatch variable config changes to registered variables' on_change callbacks. + + Registered with this instance's `LogfireConfig` (which wires it to every provider it + creates) the first time a variable is defined. Expands the directly-changed names to + every registered variable that is *effectively* changed — including variables that + (transitively) compose a changed variable via `@{ref}@` references — then fires each + affected variable's callbacks. + """ + # Snapshot the registry: dispatch runs on the provider's polling thread, and a + # concurrent `var()` on another thread mutating the dict mid-iteration would + # otherwise raise (losing the rest of this change cycle's notifications). + variables = dict(self._variables) + if not variables: + return + + from logfire.variables.variable import expand_config_changes + + provider_config = self.config.get_variable_provider().get_all_variables_config() + for name in sorted(expand_config_changes(changed_names, provider_config, variables)): + variables[name]._notify_change() # pyright: ignore[reportPrivateUsage] + + def _ensure_variable_change_polling(self) -> None: + """Make sure the background poller is running so registered on_change callbacks can fire. + + Called when a variable's `on_change` callback is registered. The remote provider's poller + is otherwise started lazily on the first variable resolution, so a program that only + registers callbacks (without ever resolving a variable) would never receive notifications. + We record the request on the config — so it survives reconfiguration, which rebuilds the + provider — and, if configuration has already happened, start the provider now (a no-op for + already-running and for no-op/local providers). + """ + self.config._variable_change_polling_requested = True # pyright: ignore[reportPrivateUsage] + # For an explicitly-configured provider this is already started; for the lazy-init + # remote path this creates and starts it. Returns a no-op provider when there's nothing + # to poll (no API key / no variables configured). + self.config.get_variable_provider() + def variables_clear(self) -> None: """Clear all registered variables from this Logfire instance. diff --git a/logfire/variables/abstract.py b/logfire/variables/abstract.py index fd1f4204c..72d81fef8 100644 --- a/logfire/variables/abstract.py +++ b/logfire/variables/abstract.py @@ -4,7 +4,7 @@ import warnings from abc import ABC, abstractmethod from collections import deque -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from contextlib import ExitStack from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast @@ -1120,9 +1120,69 @@ def _update_variable_schema( print(f' {ANSI_YELLOW}Updated schema: {change.name}{ANSI_RESET}') +# Fields of `VariableConfig` that affect what a variable resolves to. Metadata-only fields +# (description, json_schema, example, type_name, template_inputs_schema) are excluded so that +# e.g. editing a variable's description in the UI doesn't fire change notifications. +_RESOLUTION_FIELDS: set[str] = {'name', 'labels', 'rollout', 'overrides', 'latest_version', 'aliases'} + + +def resolution_relevant_config_changed(old: VariableConfig, new: VariableConfig) -> bool: + """Whether the parts of a variable's config that affect resolution differ between *old* and *new*.""" + return old.model_dump(include=_RESOLUTION_FIELDS) != new.model_dump(include=_RESOLUTION_FIELDS) + + +def changed_config_keys(*configs: VariableConfig) -> set[str]: + """All names a change to the given config(s) can be observed under: the name plus any aliases. + + Providers pass both the old and the new config of a changed variable (where available), + so consumers matching a `@{ref}@` (or a registration) against an alias still see the + change even after the alias is removed, e.g. by deleting the variable. + """ + keys: set[str] = set() + for config in configs: + keys.add(config.name) + keys.update(config.aliases or ()) + return keys + + class VariableProvider(ABC): """Abstract base class for variable value providers.""" + _on_config_change_callbacks: list[Callable[[set[str]], None]] | None = None + + def add_on_config_change(self, callback: Callable[[set[str]], None]) -> None: + """Register a callback to be called when variable configurations change. + + Registration is idempotent: adding the same callback (by equality) twice has no + effect, so callers can safely re-register e.g. each time a variable is defined. + + Args: + callback: Called with the set of variable names whose configs changed. + The set includes any aliases of the changed variables, so consumers can + match changes against names that reference a variable via an alias. + """ + if self._on_config_change_callbacks is None: + self._on_config_change_callbacks = [] + if callback not in self._on_config_change_callbacks: + self._on_config_change_callbacks.append(callback) + + def _notify_config_change(self, changed_names: set[str]) -> None: + """Notify all registered callbacks about changed variables. + + Exceptions raised by a callback are caught and logged so one failing callback + can't break other callbacks (or crash a provider's polling thread). + """ + if self._on_config_change_callbacks and changed_names: + # Snapshot: notification runs on the provider's polling thread, and a concurrent + # registration on another thread shouldn't affect the in-flight dispatch. + for callback in list(self._on_config_change_callbacks): + try: + callback(changed_names) + except Exception: + import logging + + logging.getLogger('logfire').exception('Error in on_config_change callback') + @abstractmethod def get_serialized_value( self, diff --git a/logfire/variables/local.py b/logfire/variables/local.py index 2f8b03f6e..f77da5552 100644 --- a/logfire/variables/local.py +++ b/logfire/variables/local.py @@ -9,6 +9,8 @@ VariableAlreadyExistsError, VariableNotFoundError, VariableProvider, + changed_config_keys, + resolution_relevant_config_changed, ) from logfire.variables.config import VariableConfig, VariablesConfig @@ -96,6 +98,7 @@ def create_variable(self, config: VariableConfig) -> VariableConfig: raise VariableAlreadyExistsError(f"Variable '{config.name}' already exists") self._config.variables[config.name] = config self._config._invalidate_alias_map() # pyright: ignore[reportPrivateUsage] + self._notify_config_change(changed_config_keys(config)) return config def update_variable(self, name: str, config: VariableConfig) -> VariableConfig: @@ -114,8 +117,11 @@ def update_variable(self, name: str, config: VariableConfig) -> VariableConfig: with self._lock: if name not in self._config.variables: raise VariableNotFoundError(f"Variable '{name}' not found") + old_config = self._config.variables[name] self._config.variables[name] = config self._config._invalidate_alias_map() # pyright: ignore[reportPrivateUsage] + if resolution_relevant_config_changed(old_config, config): + self._notify_config_change(changed_config_keys(old_config, config)) return config def delete_variable(self, name: str) -> None: @@ -130,5 +136,6 @@ def delete_variable(self, name: str) -> None: with self._lock: if name not in self._config.variables: raise VariableNotFoundError(f"Variable '{name}' not found") - del self._config.variables[name] + old_config = self._config.variables.pop(name) self._config._invalidate_alias_map() # pyright: ignore[reportPrivateUsage] + self._notify_config_change(changed_config_keys(old_config)) diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 8fde64e2c..0633c52a3 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -25,6 +25,8 @@ VariableNotFoundError, VariableProvider, VariableWriteError, + changed_config_keys, + resolution_relevant_config_changed, ) from logfire.variables.config import ( KeyIsNotPresent, @@ -323,6 +325,7 @@ def refresh(self, force: bool = False): # Note: Eventually we may want to rework the client and server implementations to use a NotModifiedResponse # to reduce the amount of overhead from polling. We could also use a websocket/SSE to get real time updates # when the user makes changes. + changed: set[str] = set() with self._refresh_lock: # Make at most one request at a time if ( not force @@ -350,14 +353,36 @@ def refresh(self, force: bool = False): return try: + old_config = self._config new_config = VariablesConfig.model_validate(variables_config_data) self._config = new_config self._last_fetched_at = datetime.now(tz=timezone.utc) + + # Detect which variables' configs changed since the previous fetch. The first + # fetch (old_config is None) is diffed against an empty config, so every variable + # is reported as changed: from the user's perspective a variable's resolved value + # *can* change at the first fetch — with block_before_first_resolve=False (or after + # an initial fetch failure) a get() returns the code default first and the server + # value once it arrives. We notify eagerly rather than trying to suppress these + # first-fetch transitions; on_change is documented as idempotent ("re-read and + # reconcile"). + previous_variables = old_config.variables if old_config is not None else {} + all_names = set(previous_variables) | set(new_config.variables) + for name in all_names: + old_var = previous_variables.get(name) + new_var = new_config.variables.get(name) + if old_var is None or new_var is None or resolution_relevant_config_changed(old_var, new_var): + changed |= changed_config_keys(*(c for c in (old_var, new_var) if c is not None)) except ValidationError as e: self._log_error('Failed to parse variables configuration from Logfire API', e) finally: self._has_attempted_fetch = True + # Notify outside the refresh lock: callbacks may resolve variables or even trigger + # another refresh, which would deadlock on the (non-reentrant) lock otherwise. + if changed: + self._notify_config_change(changed) + def get_serialized_value( self, variable_name: str, diff --git a/logfire/variables/variable.py b/logfire/variables/variable.py index 1e053087a..bb23d64b8 100644 --- a/logfire/variables/variable.py +++ b/logfire/variables/variable.py @@ -2,7 +2,7 @@ import inspect import warnings -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 @@ -23,7 +23,7 @@ if TYPE_CHECKING: from logfire._internal.config import TemplateMismatchPolicy from logfire.variables.abstract import VariableProvider - from logfire.variables.config import VariableConfig + from logfire.variables.config import VariableConfig, VariablesConfig if find_spec('anyio') is not None: # pragma: no branch # Use anyio for running sync functions on separate threads in an event loop if it is available @@ -239,6 +239,7 @@ def __init__( self._variable_registry = logfire_instance._variables # pyright: ignore[reportPrivateUsage] self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='variables') self.type_adapter = TypeAdapter[T_co](type) + self._on_change_callbacks: list[Callable[[], None]] = [] def _deserialize(self, serialized_value: str) -> T_co | ValidationError | ValueError | TypeError: """Deserialize a JSON string to the variable's type, returning an Exception on failure.""" @@ -300,6 +301,64 @@ def refresh_sync(self, force: bool = False): """Synchronously refresh the variable.""" self.logfire_instance.config.get_variable_provider().refresh(force=force) + def on_change(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register a callback to be called when this variable's configuration changes. + + The callback fires when the resolution-relevant parts of this variable's provider + configuration change (its labels, rollout, overrides, latest version, or aliases — + not metadata like its description), and also when the configuration of any variable + it (transitively) composes via `@{ref}@` references changes — in either case the + value this variable resolves to may have changed. + + Callbacks must be **idempotent**: a configuration change does not necessarily change + the value *you* resolve to (e.g. a change to a label this variable's rollout never + serves, or to a value only served for other targeting keys, still fires). Treat the + callback as "re-read and reconcile", not "the value definitely changed". + + The callback receives no arguments; call `variable.get()` inside it to see the + current value. Callbacks may run on the provider's polling thread, so they should be + fast and non-blocking, and they should not create, update, or delete variables + (which would re-enter change notification). Exceptions raised by the callback are + logged, not raised. + + Delivery of the *initial* load of remote configuration is best-effort: the provider may + fetch it before your callback is registered, in which case that first value won't fire a + callback. To reliably reconcile once at startup, call your handler yourself after + registering it — `on_change` returns the callback to make that easy: + + @my_var.on_change + def handle_change(): + refresh_cache(my_var.get().value) + + handle_change() # reconcile once at startup + + Args: + callback: A no-argument callable to invoke when the variable's config changes. + + Returns: + The callback (for use as a decorator). + """ + self._on_change_callbacks.append(callback) + # Make sure the background poller is running so this callback can actually fire, even if no + # variable is ever resolved eagerly (which is otherwise what starts the remote poller). + self.logfire_instance._ensure_variable_change_polling() # pyright: ignore[reportPrivateUsage] + return callback + + def _notify_change(self) -> None: + """Fire all registered on_change callbacks, logging (not raising) their exceptions.""" + # Snapshot: notification runs on the provider's polling thread, and a concurrent + # `on_change()` registration shouldn't affect the in-flight dispatch. + for callback in list(self._on_change_callbacks): + try: + callback() + except Exception as e: + self.logfire_instance.error( + 'Error in on_change callback for variable {name}: {error}', + name=self.name, + error=str(e), + _exc_info=e, + ) + def _resolve( self, targeting_key: str | None, @@ -1181,6 +1240,86 @@ def _static_composition_refs(variable: Variable[Any]) -> set[str]: return set(find_references(serialized)) +def expand_config_changes( + changed_names: set[str], + config: VariablesConfig, + registry: Mapping[str, Variable[Any]], +) -> set[str]: + """Expand provider-level config changes to the registered variables that are effectively changed. + + A registered variable is effectively changed when its own config changed, or when it + (transitively) composes a changed variable via `@{ref}@` references — whether those + references appear in its server-stored values (any label or the latest version) or in + its code default. Changed names and reference names are normalized through the config's + alias map, so a variable registered (or referenced) under an alias still matches; for + names no longer in the config (e.g. a deleted variable), providers include the old + aliases in `changed_names` directly. + + Args: + changed_names: Names (and aliases) of variables whose provider configs changed. + config: The provider's current (post-change) configuration. + registry: The registered variables, keyed by their registered name. + + Returns: + The subset of `registry` keys whose variables are effectively changed. + """ + from logfire.variables.config import LabeledValue + + def canonical(name: str) -> str: + variable_config = config._get_variable_config(name) # pyright: ignore[reportPrivateUsage] + return variable_config.name if variable_config is not None else name + + # Composition edges: canonical variable name -> canonical names of the variables it references. + # Edges only change when the source variable's own config (or code default) changes, and a + # changed variable is in the closure regardless of its edges, so building the graph from the + # new config alone is sufficient. + edges: dict[str, set[str]] = {} + + def add_refs(node: str, serialized_value: str) -> None: + refs = find_references(serialized_value) + if refs: + edges.setdefault(node, set()).update(canonical(ref) for ref in refs) + + for variable_config in config.variables.values(): + for labeled_value in variable_config.labels.values(): + if isinstance(labeled_value, LabeledValue): + add_refs(variable_config.name, labeled_value.serialized_value) + if variable_config.latest_version is not None: + add_refs(variable_config.name, variable_config.latest_version.serialized_value) + + # The code default participates in resolution (and composition through it) too, so its + # references count as edges even for variables with no server-side values. A *callable* + # code default is treated as referencing every variable (see `dynamic_default_names` + # below) rather than being added to the static edge graph. + dynamic_default_names: set[str] = set() + for name, variable in registry.items(): + if is_resolve_function(variable.default): + # We can't know which variables a callable default composes without invoking user + # code, which may be slow, side-effectful, or return different refs per targeting + # context. So we conservatively assume it depends on *every* variable: any change + # effectively changes it. on_change is documented as idempotent, so firing it on an + # unrelated change is acceptable. + dynamic_default_names.add(name) + continue + for ref in _static_composition_refs(variable): + edges.setdefault(canonical(name), set()).add(canonical(ref)) + + # Reverse reachability to a fixpoint: anything that composes a changed variable is changed too. + closure = {canonical(name) for name in changed_names} | set(changed_names) + while True: + additions = {node for node, refs in edges.items() if node not in closure and refs & closure} + if not additions: + break + closure |= additions + + result = {name for name in registry if canonical(name) in closure} + # Any change at all effectively changes a variable with a callable default (it may compose + # the changed variable). `changed_names` is always non-empty here, so include them all. + if changed_names: + result |= dynamic_default_names + return result + + def warn_on_template_inputs_composition_mismatch( registry: Mapping[str, Variable[Any]], variable: Variable[Any] ) -> None: diff --git a/tests/test_variables.py b/tests/test_variables.py index 91f49cbf4..d0f0f9c9f 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -4208,6 +4208,1012 @@ def test_to_config_with_unserializable_default(self, config_kwargs: dict[str, An assert config.example is None +# ============================================================================= +# Test on_change callbacks +# ============================================================================= + + +def single_label_config(name: str, serialized_value: str, *, aliases: list[str] | None = None) -> VariableConfig: + """A config with one label `a` serving `serialized_value` 100% of the time.""" + return VariableConfig( + name=name, + labels={'a': LabeledValue(version=1, serialized_value=serialized_value)}, + rollout=Rollout(labels={'a': 1.0}), + overrides=[], + aliases=aliases, + ) + + +class TestOnChangeCallbacks: + def test_on_change_callback(self, config_kwargs: dict[str, Any]): + """on_change callbacks fire when variable config changes.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(my_var.get().value) + + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert changes == ['world'] + + def test_on_change_decorator_returns_callback(self, config_kwargs: dict[str, Any]): + """on_change can be used as a decorator and returns the callback.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + + my_var = lf.var('my_var', default='default', type=str) + + @my_var.on_change + def callback(): + pass # pragma: no cover + + assert callback is not None + assert callable(callback) + + def test_on_change_callback_exception_doesnt_break_others(self, config_kwargs: dict[str, Any]): + """Exceptions in callbacks are caught and logged; other callbacks still run.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + good_changes: list[str] = [] + + def bad_callback(): + raise ValueError('boom') + + def good_callback(): + good_changes.append(my_var.get().value) + + my_var.on_change(bad_callback) + my_var.on_change(good_callback) + + # bad callback raises, but good callback still runs + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert good_changes == ['world'] + + def test_on_change_multiple_callbacks(self, config_kwargs: dict[str, Any]): + """Multiple on_change callbacks all fire.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + calls_1: list[str] = [] + calls_2: list[str] = [] + + @my_var.on_change + def callback_1(): # pyright: ignore[reportUnusedFunction] + calls_1.append('called') + + @my_var.on_change + def callback_2(): # pyright: ignore[reportUnusedFunction] + calls_2.append('called') + + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert calls_1 == ['called'] + assert calls_2 == ['called'] + + def test_on_change_fires_on_create_variable(self, config_kwargs: dict[str, Any]): + """on_change fires when a variable is created via the provider.""" + config_kwargs['variables'] = LocalVariablesOptions(config=VariablesConfig(variables={})) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + # Register a variable that doesn't exist yet in the provider + my_var = lf.var('new_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + provider.create_variable(single_label_config('new_var', '"hello"')) + + assert changes == ['changed'] + + def test_on_change_fires_on_delete_variable(self, config_kwargs: dict[str, Any]): + """on_change fires when a variable is deleted via the provider.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append('deleted') + + provider.delete_variable('my_var') + + assert changes == ['deleted'] + + def test_on_change_only_fires_for_changed_variable(self, config_kwargs: dict[str, Any]): + """on_change only fires for the variable that actually changed.""" + config = VariablesConfig( + variables={ + 'var_a': single_label_config('var_a', '"a_value"'), + 'var_b': single_label_config('var_b', '"b_value"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + var_a = lf.var('var_a', default='default', type=str) + var_b = lf.var('var_b', default='default', type=str) + + a_changes: list[str] = [] + b_changes: list[str] = [] + + @var_a.on_change + def on_a_change(): # pyright: ignore[reportUnusedFunction] + a_changes.append('a_changed') + + @var_b.on_change + def on_b_change(): # pyright: ignore[reportUnusedFunction] + b_changes.append('b_changed') + + provider.update_variable('var_a', single_label_config('var_a', '"new_a"')) + + assert a_changes == ['a_changed'] + assert b_changes == [] + + def test_on_change_fires_for_variables_on_shared_provider_instances(self, config_kwargs: dict[str, Any]): + """Variables registered on different Logfire instances sharing a provider all get notified. + + `with_settings()` returns a new Logfire instance that shares the same config (and + therefore the same variable provider) but has its own `_variables` map. Registering + variables on both instances must not cause one to clobber the other's change + notifications. + """ + config = VariablesConfig( + variables={ + 'var_a': single_label_config('var_a', '"a_value"'), + 'var_b': single_label_config('var_b', '"b_value"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + # var_a is registered on the default instance... + var_a = lf.var('var_a', default='default', type=str) + # ...and var_b on a `with_settings()` instance that shares the same provider. + lf2 = lf.with_settings(tags=['other']) + var_b = lf2.var('var_b', default='default', type=str) + + a_changes: list[str] = [] + b_changes: list[str] = [] + + @var_a.on_change + def on_a_change(): # pyright: ignore[reportUnusedFunction] + a_changes.append('a_changed') + + @var_b.on_change + def on_b_change(): # pyright: ignore[reportUnusedFunction] + b_changes.append('b_changed') + + # Updating var_a must still fire its callback even though lf2 registered later. + provider.update_variable('var_a', single_label_config('var_a', '"new_a"')) + provider.update_variable('var_b', single_label_config('var_b', '"new_b"')) + + assert a_changes == ['a_changed'] + assert b_changes == ['b_changed'] + + def test_metadata_only_update_does_not_fire(self, config_kwargs: dict[str, Any]): + """Changing only metadata (e.g. the description) does not fire on_change.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pragma: no cover # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + updated = single_label_config('my_var', '"hello"') + updated.description = 'a new description' + updated.json_schema = {'type': 'string'} + provider.update_variable('my_var', updated) + + assert changes == [] + + def test_identical_config_update_does_not_fire(self, config_kwargs: dict[str, Any]): + """update_variable with an identical config does not fire on_change.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pragma: no cover # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + provider.update_variable('my_var', single_label_config('my_var', '"hello"')) + + assert changes == [] + + def test_on_change_fires_for_unserved_label_change(self, config_kwargs: dict[str, Any]): + """A change to a label the rollout never serves still fires (callbacks must be idempotent). + + Pins the deliberately conservative contract: notifications mean "the config changed, + re-read and reconcile", not "the value you resolve to definitely changed". + """ + + def two_label_config(unserved_value: str) -> VariableConfig: + return VariableConfig( + name='my_var', + labels={ + 'served': LabeledValue(version=1, serialized_value='"served"'), + 'unserved': LabeledValue(version=2, serialized_value=unserved_value), + }, + rollout=Rollout(labels={'served': 1.0}), + overrides=[], + ) + + config = VariablesConfig(variables={'my_var': two_label_config('"unserved"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(my_var.get().value) + + provider.update_variable('my_var', two_label_config('"unserved v2"')) + + # The callback fired even though the served value didn't change. + assert changes == ['served'] + + def test_callback_can_register_variable_during_dispatch(self, config_kwargs: dict[str, Any]): + """A callback registering a new variable doesn't break the in-flight dispatch.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + lf.var('side_var', default='side', type=str) + changes.append(my_var.get().value) + + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert changes == ['world'] + assert 'side_var' in lf._variables + + def test_variables_clear_stops_notifications(self, config_kwargs: dict[str, Any]): + """After variables_clear(), provider changes no longer notify anything.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pragma: no cover # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + lf.variables_clear() + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert changes == [] + + def test_no_duplicate_notifications_after_clear_and_redefine(self, config_kwargs: dict[str, Any]): + """variables_clear() followed by re-registration must not duplicate notifications.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + lf.var('my_var', default='default', type=str) + lf.variables_clear() + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert changes == ['changed'] # exactly once + + +class TestOnChangeComposition: + """on_change must also fire for variables that compose a changed variable via @{ref}@.""" + + def test_on_change_fires_for_composing_variable(self, config_kwargs: dict[str, Any]): + """A variable composing a changed variable is effectively changed too.""" + config = VariablesConfig( + variables={ + 'greeting': single_label_config('greeting', '"@{name}@, hello!"'), + 'name': single_label_config('name', '"world"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + greeting = lf.var('greeting', default='default', type=str) + name = lf.var('name', default='nobody', type=str) + + greeting_changes: list[str] = [] + name_changes: list[str] = [] + + @greeting.on_change + def on_greeting_change(): # pyright: ignore[reportUnusedFunction] + greeting_changes.append(greeting.get().value) + + @name.on_change + def on_name_change(): # pyright: ignore[reportUnusedFunction] + name_changes.append(name.get().value) + + # Only `name` changes, but `greeting` composes it, so both must be notified. + provider.update_variable('name', single_label_config('name', '"universe"')) + + assert name_changes == ['universe'] + assert greeting_changes == ['universe, hello!'] + + def test_on_change_fires_transitively(self, config_kwargs: dict[str, Any]): + """Change notifications follow chains of @{ref}@ references.""" + config = VariablesConfig( + variables={ + 'a': single_label_config('a', '"a sees @{b}@"'), + 'b': single_label_config('b', '"b sees @{c}@"'), + 'c': single_label_config('c', '"c"'), + 'unrelated': single_label_config('unrelated', '"unrelated"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + changes: list[str] = [] + for var_name in ('a', 'b', 'c', 'unrelated'): + variable = lf.var(var_name, default='default', type=str) + variable.on_change(lambda var_name=var_name: changes.append(var_name)) + + provider.update_variable('c', single_label_config('c', '"c2"')) + + assert sorted(changes) == ['a', 'b', 'c'] + + def test_on_change_fires_for_reference_in_unserved_label(self, config_kwargs: dict[str, Any]): + """Composition edges are conservative: a reference in an unserved label still counts. + + Determining whether a reference is actually served would require evaluating rollout + weights per targeting key, so any reference in any label (or the latest version) + creates an edge, and callbacks are expected to be idempotent. + """ + config = VariablesConfig( + variables={ + 'greeting': VariableConfig( + name='greeting', + labels={ + 'served': LabeledValue(version=1, serialized_value='"plain"'), + 'unserved': LabeledValue(version=2, serialized_value='"@{name}@!"'), + }, + rollout=Rollout(labels={'served': 1.0}), + overrides=[], + ), + 'name': single_label_config('name', '"hello"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + greeting = lf.var('greeting', default='default', type=str) + + changes: list[str] = [] + + @greeting.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(greeting.get().value) + + provider.update_variable('name', single_label_config('name', '"goodbye"')) + + # The callback fired even though the served value didn't change. + assert changes == ['plain'] + + def test_on_change_fires_for_latest_version_composition(self, config_kwargs: dict[str, Any]): + """A reference appearing in a variable's latest_version counts as a composition edge.""" + from logfire.variables.config import LatestVersion + + config = VariablesConfig( + variables={ + 'greeting': VariableConfig( + name='greeting', + labels={'a': LabelRef(version=1, ref='latest')}, + rollout=Rollout(labels={'a': 1.0}), + overrides=[], + latest_version=LatestVersion(version=1, serialized_value='"@{name}@!"'), + ), + 'name': single_label_config('name', '"hello"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + greeting = lf.var('greeting', default='default', type=str) + + changes: list[str] = [] + + @greeting.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(greeting.get().value) + + provider.update_variable('name', single_label_config('name', '"goodbye"')) + + assert changes == ['goodbye!'] + + def test_on_change_fires_for_code_default_composition(self, config_kwargs: dict[str, Any]): + """A reference in a registered code default counts, even with no server-side values.""" + config = VariablesConfig(variables={'name': single_label_config('name', '"world"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + # `greeting` exists only in code; its default composes `name`. + greeting = lf.var('greeting', default='hi @{name}@', type=str) + lf.var('name', default='nobody', type=str) + + greeting_changes: list[str] = [] + + @greeting.on_change + def on_greeting_change(): # pyright: ignore[reportUnusedFunction] + greeting_changes.append(greeting.get().value) + + provider.update_variable('name', single_label_config('name', '"universe"')) + + assert greeting_changes == ['hi universe'] + + def test_on_change_fires_for_alias_registration(self, config_kwargs: dict[str, Any]): + """A variable registered under an alias is notified when the canonical config changes.""" + config = VariablesConfig( + variables={'new_name': single_label_config('new_name', '"hello"', aliases=['old_name'])} + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + # Registered under the alias; resolves through the canonical config. + my_var = lf.var('old_name', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(my_var.get().value) + + provider.update_variable('new_name', single_label_config('new_name', '"world"', aliases=['old_name'])) + + assert changes == ['world'] + + def test_on_change_fires_for_composition_via_alias_reference(self, config_kwargs: dict[str, Any]): + """A variable referencing a changed variable through an alias is notified too.""" + config = VariablesConfig( + variables={ + 'greeting': single_label_config('greeting', '"@{old_name}@!"'), + 'new_name': single_label_config('new_name', '"hello"', aliases=['old_name']), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + greeting = lf.var('greeting', default='default', type=str) + + changes: list[str] = [] + + @greeting.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(greeting.get().value) + + provider.update_variable('new_name', single_label_config('new_name', '"goodbye"', aliases=['old_name'])) + + assert changes == ['goodbye!'] + + def test_on_change_fires_when_aliased_reference_target_deleted(self, config_kwargs: dict[str, Any]): + """Deleting a variable notifies dependents that referenced it via an alias. + + After the delete the alias map no longer knows the alias, so the changed-name set + must carry the deleted variable's aliases itself. + """ + config = VariablesConfig( + variables={ + 'greeting': single_label_config('greeting', '"@{old_name}@!"'), + 'new_name': single_label_config('new_name', '"hello"', aliases=['old_name']), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + greeting = lf.var('greeting', default='default', type=str) + + changes: list[str] = [] + + @greeting.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + provider.delete_variable('new_name') + + assert changes == ['changed'] + + def test_on_change_fires_for_callable_default_on_unrelated_change(self, config_kwargs: dict[str, Any]): + """A variable with a callable code default is treated as composing every variable. + + We can't introspect a callable default's references without invoking user code, so any + change at all is assumed to (possibly) affect it. on_change is documented as idempotent, + so firing on an unrelated change is acceptable. + """ + config = VariablesConfig( + variables={ + 'name': single_label_config('name', '"world"'), + 'unrelated': single_label_config('unrelated', '"x"'), + } + ) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + # A callable default — its `@{name}@` reference is only visible at resolution time. + dynamic = lf.var('dynamic', default=lambda targeting_key, attributes: 'hi @{name}@', type=str) + + changes: list[str] = [] + + @dynamic.on_change + def on_dynamic_change(): # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + # Changing an entirely unrelated variable still notifies the callable-default variable. + provider.update_variable('unrelated', single_label_config('unrelated', '"y"')) + + assert changes == ['changed'] + + def test_expand_config_changes_empty_changed_names_expands_to_nothing(self, config_kwargs: dict[str, Any]): + """An empty change set expands to nothing — not even to callable-default variables. + + Providers only dispatch when `changed_names` is non-empty, but `expand_config_changes` + guards against the empty set directly: with nothing changed, a variable whose callable + default is treated as "depends on everything" must still not be reported as changed. + """ + from logfire.variables.variable import expand_config_changes + + config = VariablesConfig(variables={'name': single_label_config('name', '"world"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + + dynamic = lf.var('dynamic', default=lambda targeting_key, attributes: 'hi @{name}@', type=str) + + assert expand_config_changes(set(), provider.get_all_variables_config(), {'dynamic': dynamic}) == set() + + +class TestReconfigureWithExistingVariables: + """Change notifications survive reconfiguration.""" + + def test_reconfigure_rewires_change_notifications(self, config_kwargs: dict[str, Any]): + """When variables exist and logfire is reconfigured, notifications keep working.""" + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append(my_var.get().value) + + # Reconfigure with a new provider — notifications must be wired to it as well. + config2 = VariablesConfig(variables={'my_var': single_label_config('my_var', '"reconfigured"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config2) + lf = logfire.configure(**config_kwargs) + provider2 = lf.config.get_variable_provider() + assert isinstance(provider2, LocalVariableProvider) + + provider2.update_variable('my_var', single_label_config('my_var', '"after_reconfigure"')) + + assert changes == ['after_reconfigure'] + + def test_on_change_before_configure_records_request_without_starting_provider(self): + """Registering on_change before configure() records the polling request but starts nothing. + + The poller can only be created once configuration has happened (configure() rebuilds the + provider), so on a not-yet-initialized config we just record the request; configure() then + honors it. Registering a callback must not eagerly create a provider in the meantime. + """ + config = LogfireConfig() + assert config._initialized is False + + lf = logfire.Logfire(config=config) + my_var = lf.var('my_var', default='default', type=str) + + @my_var.on_change + def on_change(): # pragma: no cover # pyright: ignore[reportUnusedFunction] + ... + + # The request is recorded for configure() to honor later... + assert config._variable_change_polling_requested is True + # ...but no provider was created — it's still the initial no-op. + assert isinstance(config.get_variable_provider(), NoOpVariableProvider) + + +@pytest.mark.filterwarnings('ignore::pytest.PytestUnhandledThreadExceptionWarning') +class TestRemoteProviderChangeDetection: + """Test change detection and notification in remote provider refresh.""" + + def test_change_detection_notifies_callback(self): + """When remote config changes between fetches, on_config_change callback fires.""" + request_mocker = requests_mock_module.Mocker() + responses: list[dict[str, Any]] = [ + { + 'json': { + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 1, 'serialized_value': '"initial"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + } + } + } + }, + { + 'json': { + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 2, 'serialized_value': '"updated"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + } + } + } + }, + ] + request_mocker.get('http://localhost:8000/v1/variables/', responses) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + changed_vars: list[set[str]] = [] + provider.add_on_config_change(lambda names: changed_vars.append(names)) + + # First fetch — every variable is reported as changed (it transitions from the + # code default to the server value). Notifying here is deliberately eager. + provider.refresh(force=True) + assert changed_vars == [{'my_var'}] + + # Second fetch — config changed + provider.refresh(force=True) + assert changed_vars == [{'my_var'}, {'my_var'}] + finally: + provider.shutdown() + + def test_no_notification_when_config_unchanged(self): + """No notification when remote config is the same between fetches.""" + request_mocker = requests_mock_module.Mocker() + request_mocker.get( + 'http://localhost:8000/v1/variables/', + json={ + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 1, 'serialized_value': '"same"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + } + } + }, + ) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + changed_vars: list[set[str]] = [] + provider.add_on_config_change(lambda names: changed_vars.append(names)) + + provider.refresh(force=True) # first fetch notifies (code default -> server value) + changed_vars.clear() + provider.refresh(force=True) + assert changed_vars == [] # No changes between fetches + finally: + provider.shutdown() + + def test_no_notification_for_metadata_only_change(self): + """Metadata-only changes (e.g. description) between fetches do not notify.""" + request_mocker = requests_mock_module.Mocker() + responses: list[dict[str, Any]] = [ + { + 'json': { + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 1, 'serialized_value': '"same"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + 'description': 'old description', + } + } + } + }, + { + 'json': { + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 1, 'serialized_value': '"same"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + 'description': 'new description', + } + } + } + }, + ] + request_mocker.get('http://localhost:8000/v1/variables/', responses) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + changed_vars: list[set[str]] = [] + provider.add_on_config_change(lambda names: changed_vars.append(names)) + + provider.refresh(force=True) # first fetch notifies (code default -> server value) + changed_vars.clear() + provider.refresh(force=True) + assert changed_vars == [] # metadata-only edit doesn't notify + finally: + provider.shutdown() + + def test_changed_names_include_aliases(self): + """Changed-name sets include the variable's aliases from both old and new configs.""" + request_mocker = requests_mock_module.Mocker() + responses: list[dict[str, Any]] = [ + { + 'json': { + 'variables': { + 'my_var': { + 'name': 'my_var', + 'labels': {'v1': {'version': 1, 'serialized_value': '"initial"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + 'aliases': ['old_alias'], + } + } + } + }, + {'json': {'variables': {}}}, # variable deleted + ] + request_mocker.get('http://localhost:8000/v1/variables/', responses) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + changed_vars: list[set[str]] = [] + provider.add_on_config_change(lambda names: changed_vars.append(names)) + + provider.refresh(force=True) # first fetch notifies (code default -> server value) + changed_vars.clear() + provider.refresh(force=True) # variable deleted + assert changed_vars == [{'my_var', 'old_alias'}] + finally: + provider.shutdown() + + def test_first_fetch_notifies_all_variables(self): + """The first successful fetch reports every variable as changed (eager notification). + + A variable's resolved value can change at the first fetch (e.g. with + block_before_first_resolve=False a get() returns the code default before the server value + arrives), so we notify rather than treat the initial load as "no change". + """ + request_mocker = requests_mock_module.Mocker() + request_mocker.get( + 'http://localhost:8000/v1/variables/', + json={ + 'variables': { + 'a': { + 'name': 'a', + 'labels': {'v1': {'version': 1, 'serialized_value': '"a"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + }, + 'b': { + 'name': 'b', + 'labels': {'v1': {'version': 1, 'serialized_value': '"b"'}}, + 'rollout': {'labels': {'v1': 1.0}}, + 'overrides': [], + }, + } + }, + ) + with request_mocker: + provider = LogfireRemoteVariableProvider( + base_url=REMOTE_BASE_URL, + token=REMOTE_TOKEN, + options=VariablesOptions(block_before_first_resolve=False, polling_interval=timedelta(seconds=60)), + ) + try: + changed_vars: list[set[str]] = [] + provider.add_on_config_change(lambda names: changed_vars.append(names)) + + provider.refresh(force=True) + assert changed_vars == [{'a', 'b'}] + finally: + provider.shutdown() + + +class TestNotifyConfigChangeError: + """Test _notify_config_change when the callback raises.""" + + def test_callback_exception_is_caught(self): + """When the on_config_change callback raises, it's caught and logged.""" + + class SimpleProvider(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 + + provider = SimpleProvider() + called = False + + def bad_callback(changed_names: set[str]) -> None: + nonlocal called + called = True + raise RuntimeError('Callback failed!') + + provider.add_on_config_change(bad_callback) + + # The _notify_config_change method catches exceptions and logs them + provider._notify_config_change({'test'}) + assert called + + def test_add_on_config_change_is_idempotent(self): + """Adding the same callback twice only registers it once.""" + + class SimpleProvider(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 + + provider = SimpleProvider() + calls: list[set[str]] = [] + + def callback(changed_names: set[str]) -> None: + calls.append(changed_names) + + provider.add_on_config_change(callback) + provider.add_on_config_change(callback) + + provider._notify_config_change({'test'}) + assert calls == [{'test'}] + + +class TestOnConfigChangeUnknownVariable: + """Test the change dispatcher when changed names include unknown variables.""" + + def test_unknown_variable_name_is_ignored(self, config_kwargs: dict[str, Any]): + lf = logfire.configure(**config_kwargs) + # Register a variable so the change listener gets set up + lf.var(name='known_var', default='default', type=str) + + provider = lf.config.get_variable_provider() + # Trigger the callback with a name that has no registered variable + provider._notify_config_change({'unknown_var'}) + + +class TestVariablesChangeListenerError: + """A raising change listener is caught and logged, and other listeners still run.""" + + def test_listener_exception_is_caught(self, config_kwargs: dict[str, Any]): + config = VariablesConfig(variables={'my_var': single_label_config('my_var', '"hello"')}) + config_kwargs['variables'] = LocalVariablesOptions(config=config) + lf = logfire.configure(**config_kwargs) + provider = lf.config.get_variable_provider() + assert isinstance(provider, LocalVariableProvider) + + def bad_listener(changed_names: set[str]) -> None: + raise RuntimeError('Listener failed!') + + # Registered before the variable's own listener, so the dispatch to the + # variable below also proves a raising listener doesn't break later ones. + lf.config.add_variables_change_listener(bad_listener) + + my_var = lf.var('my_var', default='default', type=str) + + changes: list[str] = [] + + @my_var.on_change + def on_change(): # pyright: ignore[reportUnusedFunction] + changes.append('changed') + + provider.update_variable('my_var', single_label_config('my_var', '"world"')) + + assert changes == ['changed'] + + # ============================================================================= # Tests for additional coverage # ============================================================================= @@ -5738,6 +6744,64 @@ def test_lazy_init_double_check_returns_early(self, config_kwargs: dict[str, Any provider1.shutdown() +@pytest.mark.filterwarnings('ignore::pytest.PytestUnhandledThreadExceptionWarning') +class TestOnChangePollingLifecycle: + """Registering on_change must start (and keep) the background poller running. + + The remote poller is otherwise only started on the first variable resolution, so a program + that only registers callbacks (without eagerly resolving) would never get notified. + """ + + def test_registering_on_change_starts_lazy_poller(self, config_kwargs: dict[str, Any]) -> None: + """Registering an on_change callback lazily starts the remote provider, without an eager get().""" + request_mocker = requests_mock_module.Mocker() + request_mocker.get('http://localhost:8000/v1/variables/', json={'variables': {}}) + logfire.DEFAULT_LOGFIRE_INSTANCE.config._variable_change_polling_requested = False + with request_mocker, unittest.mock.patch.dict('os.environ', {'LOGFIRE_API_KEY': REMOTE_TOKEN}): + lf = logfire.configure(**config_kwargs) + # No explicit variables configured: the provider starts out as a no-op. + # (this currently depends on setting _variable_change_polling_requested=False above) + assert isinstance(lf.config._variable_provider, NoOpVariableProvider) + + my_var = lf.var('my_var', default='x', type=str) + # Merely defining a variable does not start the poller. + # TODO maybe it should? + assert isinstance(lf.config._variable_provider, NoOpVariableProvider) + + @my_var.on_change + def _cb() -> None: # pyright: ignore[reportUnusedFunction] + ... + + # Registering the callback starts the (lazy) remote poller so the callback can fire. + provider = lf.config._variable_provider + assert isinstance(provider, LogfireRemoteVariableProvider) + provider.shutdown() + + def test_on_change_polling_survives_reconfigure(self, config_kwargs: dict[str, Any]) -> None: + """After a reconfigure rebuilds the provider, the poller is restarted for existing callbacks.""" + request_mocker = requests_mock_module.Mocker() + request_mocker.get('http://localhost:8000/v1/variables/', json={'variables': {}}) + with request_mocker, unittest.mock.patch.dict('os.environ', {'LOGFIRE_API_KEY': REMOTE_TOKEN}): + lf = logfire.configure(**config_kwargs) + my_var = lf.var('my_var', default='x', type=str) + + @my_var.on_change + def _cb() -> None: # pyright: ignore[reportUnusedFunction] + ... + + provider1 = lf.config._variable_provider + assert isinstance(provider1, LogfireRemoteVariableProvider) + + # Reconfigure with no explicit variables: the old provider is shut down and rebuilt. + # Because an on_change callback is registered, the poller is restarted rather than + # left as a no-op (which would silently stop notifications). + lf = logfire.configure(**config_kwargs) + provider2 = lf.config._variable_provider + assert isinstance(provider2, LogfireRemoteVariableProvider) + assert provider2 is not provider1 + provider2.shutdown() + + class TestConfigVariablesDictDeserialization: """Tests for LogfireConfig deserializing variables from a dict (as in executors.py)."""