Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 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,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

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.

this is basically the same snippet as the first

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
```
44 changes: 44 additions & 0 deletions logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

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.

Maybe polling should start if any variable is defined? Then it has a chance of being ready by the first .get().

# 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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 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,52 @@ 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 _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.

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))
25 changes: 25 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,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,
Expand Down
Loading
Loading