Skip to content

Add on_change callbacks for managed variables#1990

Open
dmontagu wants to merge 10 commits into
mainfrom
managed-variables-on-change-merge
Open

Add on_change callbacks for managed variables#1990
dmontagu wants to merge 10 commits into
mainfrom
managed-variables-on-change-merge

Conversation

@dmontagu

@dmontagu dmontagu commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for registering callbacks that fire when a managed variable's configuration changes, enabling reactive patterns like cache invalidation and feature-flag updates.

Reworked on top of current main now that composition and templating (#1954) have landed, addressing the review feedback about composition and duplicate registration.

What's added

  • Variable.on_change() decorator + _notify_change() (variable.py)
  • Composition-aware change expansion: expand_config_changes() (variable.py) expands provider-level config changes to every registered variable that (transitively) composes a changed variable via @{ref}@ references — whether the reference appears in a server-stored value (any label or latest_version) or in a code default. Reference and changed names are normalized through the alias map.
  • VariableProvider.add_on_config_change() / _notify_config_change() (abstract.py), with idempotent registration. Providers report changed names including aliases (from both old and new configs), so dependents referencing a deleted variable via an alias are still notified.
  • Change notifications on local create/update/delete (local.py) and remote refresh diffing (remote.py). The remote provider notifies outside its refresh lock to avoid deadlocks if a callback triggers resolution or another refresh.
  • Listeners live on LogfireConfig, not the provider (config.py): every provider the config creates (including the lazy-init path) is wired to the config's dispatcher, so notifications survive configure() re-runs — including for with_settings() instances — and there is no setup flag to get out of sync. Logfire._on_variables_config_change dispatches to registered variables (main.py).
  • Docs section in managed-variables/remote.md

Notification contract

  • Only resolution-relevant changes fire: labels, rollout, overrides, latest version, aliases. Metadata-only edits (e.g. changing a description in the UI) don't notify. Local and remote providers diff identically, so an identical-config update_variable is silent on both.
  • Callbacks must be idempotent (documented): notifications are deliberately conservative — a change to a label the rollout never serves, or to a value only served for other targeting keys, still fires. Rollout-aware filtering would require per-targeting-key evaluation, so the contract is "re-read and reconcile".
  • No notification on the provider's initial load (that would race registration); to reconcile once at startup, call your handler directly after registering it — on_change returns the callback to make that easy.
  • Dispatch snapshots the variables registry and callback lists, so concurrent or re-entrant registration can't disrupt an in-flight notification cycle. Exceptions in callbacks are logged, not raised.

Usage

feature_enabled = logfire.var('feature_enabled', default=False)


@feature_enabled.on_change
def on_feature_change():
    new_value = feature_enabled.get().value
    invalidate_cache()


on_feature_change()  # optionally, reconcile once at startup too

If another variable composes feature_enabled (e.g. a prompt containing @{feature_enabled}@), its on_change callbacks fire too when feature_enabled changes.

Tests

  • 30 new tests: basics, composition (direct/transitive/code-default/latest_version), aliases (registration, reference, deleted target), duplicate prevention after variables_clear(), reconfigure, remote diff detection, and characterization tests pinning the contract (metadata-only edits don't fire; unserved-label changes do; re-entrant registration is safe)
  • Full variables suites + configure: 675 passed; variables modules at 100% coverage
  • pyright clean, ruff clean

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread logfire/variables/remote.py Outdated
Reworked on top of main now that composition and templating (#1954) have
landed:

- Variable.on_change() registers callbacks fired when the variable's
  effective value may have changed.
- Change notifications are composition-aware: when a variable's config
  changes, dependents that (transitively) reference it via @{ref}@ —
  in server-stored values or code defaults — are notified too, with
  reference and changed names normalized through the alias map.
- Providers report config-level diffs (local create/update/delete,
  remote refresh) including changed variables' aliases; the remote
  provider notifies outside its refresh lock to avoid deadlocks.
- Change listeners live on LogfireConfig (not the provider), so they
  survive reconfiguration, and registration is idempotent so
  variables_clear()/re-registration cycles can't duplicate
  notifications.
@dmontagu dmontagu force-pushed the managed-variables-on-change-merge branch from 629dbb6 to 6c310d0 Compare June 11, 2026 04:54
dmontagu added 2 commits June 10, 2026 23:19
…mpotency docs

Follow-up addressing review of the on_change semantics:

- Only fire when resolution-relevant config (name, labels, rollout,
  overrides, latest_version, aliases) changes; metadata-only edits like
  descriptions no longer notify. Local update_variable now diffs the
  same way as remote refresh, so the two providers share one contract
  (an identical-config update is silent on both).
- Snapshot the variables registry and callback/listener lists before
  iterating, so registration on another thread (or from inside a
  callback) can't disrupt an in-flight dispatch.
- Document the contract: callbacks must be idempotent (changes to
  unserved labels or other targeting keys' values still fire), run on
  the polling thread, must not mutate variables, and don't fire on the
  provider's initial load — call your handler once after registering to
  reconcile at startup.
- Characterization tests pinning the conservative over-notification
  (unserved label changes fire, metadata-only changes don't), plus
  re-entrant registration and local/remote consistency tests.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 6 additional findings.

Open in Devin Review

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Docs Preview

commit a87366a
preview https://38fa8701-pydantic-docs-preview.pydantic.workers.dev/logfire/

Re-add the trigger:docs label to rebuild for a newer commit.

Comment thread docs/reference/advanced/managed-variables/remote.md Outdated
Comment thread docs/reference/advanced/managed-variables/remote.md Outdated
Comment thread logfire/variables/remote.py Outdated
Comment thread logfire/variables/variable.py
dmontagu added 3 commits June 16, 2026 02:40
…r lifecycle

- remote.py: fire on_change on the first successful fetch (diff against an
  empty config), so a variable transitioning from its code default to the
  server value notifies. Eager + idempotent per the on_change contract.
- variable.py: in expand_config_changes, treat a callable code default as
  referencing every variable (any change effectively changes it) rather than
  silently skipping it, since we can't introspect the callable's refs.
- main.py/config.py/variable.py: registering an on_change callback now starts
  the background poller (Logfire._ensure_variable_change_polling), so callbacks
  fire even without an eager get(); the request is recorded on LogfireConfig so
  it survives reconfiguration. variables_clear() resets it.
- docs: apply review suggestion (import logfire, drop undefined invalidate_cache,
  make the snippet runnable); update initial-load note to best-effort delivery.
- tests: first-fetch notification, callable-default unrelated-change,
  poller-starts-on-registration, poller-survives-reconfigure; updated existing
  remote change-detection tests for the new first-fetch behavior.
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

# 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().

Comment on lines +1317 to +1318
# the changed variable). `changed_names` is always non-empty here, so include them all.
if changed_names:

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.

"changed_names is always non-empty here"

so why the if condition?

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a6f3184e-f973-43ce-8a57-6a695bd4561c

📥 Commits

Reviewing files that changed from the base of the PR and between dcb5fa6 and 2c107a2.

📒 Files selected for processing (1)
  • tests/test_variables.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/pydantic (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_variables.py

📝 Walkthrough

Walkthrough

This PR adds a change notification system for managed variables. A new Variable.on_change() method lets callers register no-argument callbacks that fire when the variable's resolved configuration changes. Resolution-relevance filtering (resolution_relevant_config_changed, changed_config_keys) determines which config field changes matter. LocalVariableProvider emits notifications on create/update/delete; LogfireRemoteVariableProvider.refresh() diffs old vs new VariablesConfig per variable after each poll. LogfireConfig and Logfire wire the dispatch chain, using expand_config_changes() to traverse composition dependency graphs and compute the transitive set of impacted variables before invoking their callbacks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly describes the main change: adding on_change callbacks for managed variables, which is the core feature of this pull request.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering the feature, implementation details, notification contract, usage examples, and test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch managed-variables-on-change-merge

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
logfire/variables/variable.py (1)

341-341: Variable.on_change lacks idempotency guard unlike provider's add_on_config_change.

The provider's add_on_config_change documents idempotent registration (line 1166: if callback not in self._on_config_change_callbacks), but Variable.on_change appends unconditionally, so registering the same callback twice will invoke it twice per change. This likely mirrors intended usage (decorator-style callbacks are typically unique lambdas that don't compare equal), but the asymmetry warrants consistency consideration if idempotency becomes a requirement.

🤖 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/variable.py` at line 341, The Variable.on_change method at
line 341 appends callbacks unconditionally, allowing duplicate registrations,
while the provider's add_on_config_change method uses an idempotency check (if
callback not in self._on_config_change_callbacks). To maintain consistency, add
a conditional check in Variable.on_change to verify the callback is not already
in self._on_change_callbacks before appending it, mirroring the pattern used in
the provider's add_on_config_change method.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@logfire/variables/local.py`:
- 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.

---

Nitpick comments:
In `@logfire/variables/variable.py`:
- Line 341: The Variable.on_change method at line 341 appends callbacks
unconditionally, allowing duplicate registrations, while the provider's
add_on_config_change method uses an idempotency check (if callback not in
self._on_config_change_callbacks). To maintain consistency, add a conditional
check in Variable.on_change to verify the callback is not already in
self._on_change_callbacks before appending it, mirroring the pattern used in the
provider's add_on_config_change method.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 241734c9-7cf7-42f3-a6f0-adb22acbf2f3

📥 Commits

Reviewing files that changed from the base of the PR and between 91f5225 and dcb5fa6.

📒 Files selected for processing (8)
  • docs/reference/advanced/managed-variables/remote.md
  • logfire/_internal/config.py
  • logfire/_internal/main.py
  • logfire/variables/abstract.py
  • logfire/variables/local.py
  • logfire/variables/remote.py
  • logfire/variables/variable.py
  • tests/test_variables.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/pydantic (auto-detected)

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.

@alexmojaki

Copy link
Copy Markdown
Collaborator

Reviewing this led to 2 small PRs from main: #2033 and #2034. The latter affects how things are done here.

Comment thread logfire/_internal/main.py
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants