Skip to content

Commit 629dbb6

Browse files
committed
Address cubic review: multi-subscriber config-change callbacks + deterministic dispatch
- Provider now supports multiple on_config_change subscribers (add_on_config_change) so Logfire instances sharing a provider via with_settings() don't clobber each other's variable change notifications (cubic P2) - Sort changed names before dispatch for deterministic callback order (cubic P3) - Add regression test for the shared-provider scenario
1 parent 94b032d commit 629dbb6

3 files changed

Lines changed: 94 additions & 17 deletions

File tree

logfire/_internal/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2586,12 +2586,12 @@ def _setup_variable_change_notifications(self) -> None:
25862586
provider = self.config.get_variable_provider()
25872587

25882588
def on_config_change(changed_names: set[str]) -> None:
2589-
for name in changed_names:
2589+
for name in sorted(changed_names):
25902590
variable = self._variables.get(name)
25912591
if variable is not None:
25922592
variable._notify_change() # pyright: ignore[reportPrivateUsage]
25932593

2594-
provider.set_on_config_change(on_config_change)
2594+
provider.add_on_config_change(on_config_change)
25952595

25962596
def variables_clear(self) -> None:
25972597
"""Clear all registered variables from this Logfire instance.

logfire/variables/abstract.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -640,25 +640,33 @@ def _update_variable_schema(
640640
class VariableProvider(ABC):
641641
"""Abstract base class for variable value providers."""
642642

643-
_on_config_change: Callable[[set[str]], None] | None = None
643+
_on_config_change_callbacks: list[Callable[[set[str]], None]] | None = None
644644

645-
def set_on_config_change(self, callback: Callable[[set[str]], None]) -> None:
646-
"""Set a callback that will be called when variable configurations change.
645+
def add_on_config_change(self, callback: Callable[[set[str]], None]) -> None:
646+
"""Register a callback to be called when variable configurations change.
647+
648+
Multiple callbacks may be registered. This is important because several
649+
`Logfire` instances can share a single provider (e.g. via `with_settings()`),
650+
each with its own set of registered variables, and all of them need to be
651+
notified of changes.
647652
648653
Args:
649654
callback: Called with the set of variable names whose configs changed.
650655
"""
651-
self._on_config_change = callback
656+
if self._on_config_change_callbacks is None:
657+
self._on_config_change_callbacks = []
658+
self._on_config_change_callbacks.append(callback)
652659

653660
def _notify_config_change(self, changed_names: set[str]) -> None:
654-
"""Notify the registered callback about changed variables."""
655-
if self._on_config_change and changed_names:
656-
try:
657-
self._on_config_change(changed_names)
658-
except Exception:
659-
import logging
660-
661-
logging.getLogger('logfire').exception('Error in on_config_change callback')
661+
"""Notify all registered callbacks about changed variables."""
662+
if self._on_config_change_callbacks and changed_names:
663+
for callback in self._on_config_change_callbacks:
664+
try:
665+
callback(changed_names)
666+
except Exception:
667+
import logging
668+
669+
logging.getLogger('logfire').exception('Error in on_config_change callback')
662670

663671
@abstractmethod
664672
def get_serialized_value(

tests/test_variables.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3991,6 +3991,75 @@ def on_b_change(): # pyright: ignore[reportUnusedFunction]
39913991
assert a_changes == ['a_changed']
39923992
assert b_changes == []
39933993

3994+
def test_on_change_fires_for_variables_on_shared_provider_instances(self, config_kwargs: dict[str, Any]):
3995+
"""Variables registered on different Logfire instances sharing a provider all get notified.
3996+
3997+
`with_settings()` returns a new Logfire instance that shares the same config (and
3998+
therefore the same variable provider) but has its own `_variables` map. Registering
3999+
variables on both instances must not cause one to clobber the other's change
4000+
notifications.
4001+
"""
4002+
config = VariablesConfig(
4003+
variables={
4004+
'var_a': VariableConfig(
4005+
name='var_a',
4006+
labels={'x': LabeledValue(version=1, serialized_value='"a_value"')},
4007+
rollout=Rollout(labels={'x': 1.0}),
4008+
overrides=[],
4009+
),
4010+
'var_b': VariableConfig(
4011+
name='var_b',
4012+
labels={'x': LabeledValue(version=1, serialized_value='"b_value"')},
4013+
rollout=Rollout(labels={'x': 1.0}),
4014+
overrides=[],
4015+
),
4016+
}
4017+
)
4018+
config_kwargs['variables'] = LocalVariablesOptions(config=config)
4019+
lf = logfire.configure(**config_kwargs)
4020+
provider = lf.config.get_variable_provider()
4021+
assert isinstance(provider, LocalVariableProvider)
4022+
4023+
# var_a is registered on the default instance...
4024+
var_a = lf.var('var_a', default='default', type=str)
4025+
# ...and var_b on a `with_settings()` instance that shares the same provider.
4026+
lf2 = lf.with_settings(tags=['other'])
4027+
var_b = lf2.var('var_b', default='default', type=str)
4028+
4029+
a_changes: list[str] = []
4030+
b_changes: list[str] = []
4031+
4032+
@var_a.on_change
4033+
def on_a_change(): # pyright: ignore[reportUnusedFunction]
4034+
a_changes.append('a_changed')
4035+
4036+
@var_b.on_change
4037+
def on_b_change(): # pyright: ignore[reportUnusedFunction]
4038+
b_changes.append('b_changed')
4039+
4040+
# Updating var_a must still fire its callback even though lf2 registered later.
4041+
provider.update_variable(
4042+
'var_a',
4043+
VariableConfig(
4044+
name='var_a',
4045+
labels={'x': LabeledValue(version=1, serialized_value='"new_a"')},
4046+
rollout=Rollout(labels={'x': 1.0}),
4047+
overrides=[],
4048+
),
4049+
)
4050+
provider.update_variable(
4051+
'var_b',
4052+
VariableConfig(
4053+
name='var_b',
4054+
labels={'x': LabeledValue(version=1, serialized_value='"new_b"')},
4055+
rollout=Rollout(labels={'x': 1.0}),
4056+
overrides=[],
4057+
),
4058+
)
4059+
4060+
assert a_changes == ['a_changed']
4061+
assert b_changes == ['b_changed']
4062+
39944063

39954064
# =============================================================================
39964065
# Tests for additional coverage
@@ -4156,7 +4225,7 @@ def bad_callback(changed_names: set[str]) -> None:
41564225
called = True
41574226
raise RuntimeError('Callback failed!')
41584227

4159-
provider.set_on_config_change(bad_callback)
4228+
provider.add_on_config_change(bad_callback)
41604229

41614230
# The _notify_config_change method catches exceptions and logs them
41624231
provider._notify_config_change({'test'})
@@ -4381,7 +4450,7 @@ def test_change_detection_notifies_callback(self):
43814450
)
43824451
try:
43834452
changed_vars: list[set[str]] = []
4384-
provider.set_on_config_change(lambda names: changed_vars.append(names))
4453+
provider.add_on_config_change(lambda names: changed_vars.append(names))
43854454

43864455
# First fetch
43874456
provider.refresh(force=True)
@@ -4418,7 +4487,7 @@ def test_no_notification_when_config_unchanged(self):
44184487
)
44194488
try:
44204489
changed_vars: list[set[str]] = []
4421-
provider.set_on_config_change(lambda names: changed_vars.append(names))
4490+
provider.add_on_config_change(lambda names: changed_vars.append(names))
44224491

44234492
provider.refresh(force=True)
44244493
provider.refresh(force=True)

0 commit comments

Comments
 (0)