Skip to content
Open
46 changes: 46 additions & 0 deletions docs/reference/advanced/managed-variables/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,49 @@ 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 skip="true"
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)
invalidate_cache()
Comment thread
alexmojaki marked this conversation as resolved.
Outdated


on_feature_change() # optionally, reconcile once at startup too
```

**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)
- The initial load of remote configuration does not fire callbacks; to reconcile once at
Comment thread
alexmojaki marked this conversation as resolved.
Outdated
startup, call your handler directly after registering it (as in the example above)
31 changes: 31 additions & 0 deletions logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,9 @@ 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]] = []
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.
Expand Down Expand Up @@ -1400,6 +1403,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)
Expand Down Expand Up @@ -1581,10 +1585,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
Expand Down
24 changes: 24 additions & 0 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2603,6 +2603,7 @@ def var(
description=description,
)
self._variables[name] = variable
self._config.add_variables_change_listener(self._on_variables_config_change)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think #2034 should be merged first, and then this should change so that Logfire doesn't need to own callbacks. then there'll be 3 layers of callbacks instead of 4.


from logfire.variables.variable import warn_on_template_inputs_composition_mismatch

Expand Down Expand Up @@ -2740,13 +2741,36 @@ 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

warn_on_template_inputs_composition_mismatch(self._variables, variable)

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 variables_clear(self) -> None:
"""Clear all registered variables from this Logfire instance.

Expand Down
62 changes: 61 additions & 1 deletion logfire/variables/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion logfire/variables/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
VariableAlreadyExistsError,
VariableNotFoundError,
VariableProvider,
changed_config_keys,
resolution_relevant_config_changed,
)
from logfire.variables.config import VariableConfig, VariablesConfig

Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Snapshot configs before diffing notifications.

old_config is a live mutable object, and the provider also returns/stores VariableConfig objects by reference. If a caller edits a returned config and then calls update_variable, the “old” config can already contain the new labels/aliases, causing on_change to be skipped for a real resolution-relevant change.

Suggested boundary-copy shape
 def get_variable_config(self, name: str) -> VariableConfig | None:
     ...
     with self._lock:
-        return self._config._get_variable_config(name)  # pyright: ignore[reportPrivateUsage]
+        config = self._config._get_variable_config(name)  # pyright: ignore[reportPrivateUsage]
+        return config.model_copy(deep=True) if config is not None else None

 def create_variable(self, config: VariableConfig) -> VariableConfig:
     ...
     with self._lock:
+        config = config.model_copy(deep=True)
         if config.name in self._config.variables:
             raise VariableAlreadyExistsError(f"Variable '{config.name}' already exists")
         self._config.variables[config.name] = config
         self._config._invalidate_alias_map()  # pyright: ignore[reportPrivateUsage]

 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
+        old_config = self._config.variables[name].model_copy(deep=True)
+        config = config.model_copy(deep=True)
+        self._config.variables[name] = config
         self._config._invalidate_alias_map()  # pyright: ignore[reportPrivateUsage]

 def delete_variable(self, name: str) -> None:
     ...
-        old_config = self._config.variables.pop(name)
+        old_config = self._config.variables.pop(name).model_copy(deep=True)

Also applies to: 120-124, 139-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@logfire/variables/local.py` at line 101, The issue is that old_config is a
mutable object stored by reference, so if a caller modifies a returned config
and then calls update_variable, the comparison in changed_config_keys will use
an already-modified old_config instead of the true original state, causing real
changes to be missed. Create a snapshot copy of the configuration before calling
_notify_config_change and changed_config_keys at line 101, and apply the same
snapshot approach to the other affected locations at lines 120-124 and 139-141,
ensuring that the diff comparison always operates on the original unmodified
state of old_config.

return config

def update_variable(self, name: str, config: VariableConfig) -> VariableConfig:
Expand All @@ -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:
Expand All @@ -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))
20 changes: 20 additions & 0 deletions logfire/variables/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
VariableNotFoundError,
VariableProvider,
VariableWriteError,
changed_config_keys,
resolution_relevant_config_changed,
)
from logfire.variables.config import (
KeyIsNotPresent,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -350,14 +353,31 @@ 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 not a "change" — variables are only beginning
# to resolve against the provider at that point.
Comment thread
alexmojaki marked this conversation as resolved.
Outdated
if old_config is not None:
all_names = set(old_config.variables) | set(new_config.variables)
for name in all_names:
old_var = old_config.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,
Expand Down
Loading
Loading