Conversation
Add comprehensive tests covering uncovered lines across the variables module: is_resolve_function edge cases, variant deserialization errors, remote provider change detection, force refresh, variant diffing, variable type operations, push_variable_types, and more. Add pragmas for genuinely untestable code paths (fork handler, unreachable fallback).
Covers: resolve function default without type, duplicate variable name, on_config_change with unknown variable, and multiple keyword-only params.
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Add a `timeout` field to `RemoteVariablesConfig` (default `(10, 10)`) so that both the polling thread and the blocking first-resolve path use a bounded `Session.get` call. Also propagate `timeout_millis` through `VariableProvider.shutdown()` so thread joins respect the shutdown budget.
Deploying logfire-docs with
|
| Latest commit: |
deb4842
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e256b7c5.logfire-docs.pages.dev |
| Branch Preview URL: | https://managed-variables.logfire-docs.pages.dev |
…sion optional - Remove `enabled` field from VariableConfig (disabled variables are now modeled by pointing labels to code_default) - Add `code_default` handling in `follow_ref` - returns (None, None) to trigger code default fallthrough - Make `LabelRef.version` optional (None for code_default and label-to-label refs) - Update `_validate_labels` to skip code_default refs in label validation - Update tests to use code_default pattern instead of enabled=False
- Remove `enabled` field from VariableConfig reference table - Document `code_default` as a LabelRef target - Note that LabelRef.version is optional - Add explanations for when to use each ref target type
|
the docs page is by far the biggest, please can it be split up into multiple pages? |
There was a problem hiding this comment.
Pull request overview
Adds a “managed variables” feature to Logfire, providing typed runtime-config variables backed by local config or remote Logfire API with polling/SSE updates, plus supporting CLI-friendly sync/validation workflows.
Changes:
- Introduces
logfire.variablesmodule with config models, providers (local/remote), and variable resolution utilities (targeting, overrides, on-change callbacks). - Adds public
logfire.var()+variables_*helper APIs and wires provider lifecycle intoconfigure()/shutdown(). - Adds extensive tests and documentation for managed variables and provider sync tooling.
Reviewed changes
Copilot reviewed 22 out of 29 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/type_checking.py | Adds typing regression/behavior documentation for var() defaults. |
| tests/test_push_variables.py | Adds unit tests for schema generation, diffing, push/validate formatting, and registration. |
| tests/test_logfire_api.py | Ensures managed-variables symbols are present in the public API surface. |
| tests/test_configure.py | Extends config serialization test to include variables + remote provider mocking. |
| tests/otel_integrations/test_requests.py | Makes request metric assertions tolerant of numeric totals. |
| tests/otel_integrations/test_openai.py | Fixes IsNumeric usage to matcher instance. |
| tests/conftest.py | Clears variable registry between tests. |
| pyproject.toml | Adds variables extra dependency on Pydantic. |
| mkdocs.yml | Adds Managed Variables doc page to nav. |
| logfire/variables/variable.py | Implements Variable, targeting context, overrides, resolution, and on-change callbacks. |
| logfire/variables/remote.py | Implements remote provider with polling, SSE listener, and CRUD APIs. |
| logfire/variables/local.py | Implements in-memory local provider with CRUD + change notification. |
| logfire/variables/config.py | Adds Pydantic config models for labels/rollouts/conditions and resolution logic. |
| logfire/variables/abstract.py | Defines provider ABC, resolved details, diff/push/validate utilities, and variable-type push. |
| logfire/variables/init.py | Exposes managed variables public surface via lazy imports. |
| logfire/_internal/main.py | Adds variable registry + public variable APIs and integrates provider shutdown. |
| logfire/_internal/config_params.py | Adds LOGFIRE_API_KEY config param for public API calls (variables). |
| logfire/_internal/config.py | Adds variables options/config, provider creation, and provider start lifecycle integration. |
| logfire/_internal/client.py | Adds internal PUT helper (currently unused in shown diff). |
| logfire/init.py | Exposes var + variables_* helpers at top-level and adds VariablesOptions. |
| logfire-api/logfire_api/init.py | Updates logfire-api stubs to include managed variables APIs. |
| docs/reference/advanced/managed-variables.md | Adds full managed variables guide and reference documentation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Extract on_change callback functionality into a separate PR to keep the managed-variables branch focused on the core feature.
- Fix lost-wakeup race in remote provider worker by using wait(timeout) then clear() pattern instead of clear() before wait() - Pass timeout budget to variable provider shutdown to prevent exceeding total shutdown timeout - Validate variable names at registration time against VariableName pattern to fail early with a clear error - Fix deserialization cache race by re-checking key presence under lock before inserting to prevent duplicate _cache_order entries - Fix attribute merge order so user-provided attributes have highest priority over baggage and resource attributes - Fix docs referencing non-existent latest_weight field on Rollout model
Update test to use underscore-separated name (`my_variable_2`) instead of hyphenated name (`my-variable-2`) to match the new variable name validation added in the previous commit.
Code fixes: - Remove deserialization cache from Variable (shared mutable instances issue) - Fix ValidationReport.format() to count unique variables with errors - Don't eagerly create lazy remote variable provider during configure(), fixing errors when LOGFIRE_API_KEY lacks variables permission - Update docstrings: remaining traffic uses code default, not latest version Docs fixes: - Fix: remaining traffic goes to code default, not latest version - Fix: polling interval default is 60s not 30s - Fix: use valid Python identifiers in push_types example - Split managed-variables.md into focused sub-pages
|
NOTE: screenshots are out of date and should be updated (with playwright) before merging. |
Cover all previously-uncovered lines: - config.py line 783: VariablesOptions dict deserialization path - config.py line 1382: _lazy_init_variable_provider double-check guard - abstract.py lines 380-411: _check_type_label_compatibility function - abstract.py lines 538-543: _format_diff unchanged variables with incompatible labels - abstract.py lines 1035, 1039: push_variables incompatible label messages - abstract.py lines 1336-1344, 1347-1359: push_variable_types label compatibility
- Add LabelRef to _check_type_label_compatibility test (386->385) - Add variable with compatible labels to push_variable_types test (1339->1335)
- Fix rollout None selection to use code default instead of latest_version. Empty rollouts and remainder probability now correctly fall through to the code default rather than serving the remote latest_version. - Add timeout to all write operations in remote.py (create, update, delete variable; list and upsert variable types) to prevent hangs. - Fix LocalVariablesOptions deserialization in child processes. When dataclasses.asdict() serializes LocalVariablesOptions, the config field is a VariablesConfig Pydantic model (not a dict). The deserialization now correctly reconstructs LocalVariablesOptions instead of silently converting to VariablesOptions.
…ite ops - Update resolve_value() docstring to match actual behavior: no label selected uses code default, not latest_version - Catch RequestException in addition to UnexpectedResponse in create_variable, update_variable, and delete_variable so that connection errors and timeouts are wrapped in VariableWriteError
Custom providers are not currently needed. Remove VariableProvider from the variables parameter type in configure() and from the public exports in logfire.variables. The class remains available internally for LocalVariableProvider and LogfireRemoteVariableProvider.
Split external variables and OFREP content out of remote.md into a new external.md page. The new page clearly documents the distinction between pull-based SDK access (full config, local evaluation) and OFREP (server-side evaluation, config never exposed), and explicitly notes that project:read_external_variables cannot be used with logfire.variable.get(). Also converts with-open patterns to pathlib in code examples.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 36 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
tests/test_push_variables.py:1
- The import statement is repeated on line 482 when it's already imported at the top of the file on line 25. Remove this duplicate import.
tests/test_push_variables.py:1 - The import statement is repeated on line 514 when it's already imported at the top of the file on line 25. Remove this duplicate import.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The 'latest' keyword is only valid as a LabelRef.ref on individual labels, not as a key in rollout.labels. Updated docs to clarify that you should create a label referencing 'latest' and include that label in the rollout.
…EP docs Add blocking logic to LogfireRemoteVariableProvider.get_serialized_value_for_label() so it waits for the initial config fetch when block_before_first_resolve is True, matching the existing behavior in get_serialized_value(). Also document OFREP response behavior when no value can be resolved (returns value: null, reason: "DEFAULT").
| def _at_fork_reinit(self): # pragma: no cover | ||
| # Recreate all things threading related | ||
| self._refresh_lock = threading.Lock() | ||
| self._session_lock = threading.Lock() | ||
| self._worker_awaken = threading.Event() | ||
| self._force_refresh_event = threading.Event() | ||
| # Only restart threads if we were started before the fork | ||
| if self._started: | ||
| self._worker_thread = threading.Thread( | ||
| name='LogfireRemoteProvider', | ||
| target=self._worker, | ||
| daemon=True, | ||
| ) | ||
| self._worker_thread.start() | ||
| # Restart SSE listener | ||
| self._sse_connected = False | ||
| self._start_sse_listener() | ||
| self._pid = os.getpid() |
There was a problem hiding this comment.
🟡 _at_fork_reinit does not reset _shutdown flag, causing newly started worker threads to immediately exit
After a fork(), _at_fork_reinit recreates threading primitives and starts new worker/SSE threads, but never resets self._shutdown back to False. If the provider was shut down before the fork (e.g., via logfire.shutdown()), the child process inherits _shutdown = True, and the newly started _worker thread immediately exits at its while not self._shutdown check (logfire/variables/remote.py:256).
Root Cause
The _at_fork_reinit method at line 102 recreates locks and events, and conditionally starts new worker and SSE threads when self._started is True. However, it does not reset self._shutdown = False. The _worker method (logfire/variables/remote.py:255-276) checks while not self._shutdown at the top of its loop, so if _shutdown is True, the thread body never executes and the thread immediately exits.
This means the child process starts a thread (incurring thread-creation overhead) that does zero useful work. The child process won't receive background variable config updates and will use the stale config from the parent.
Impact: In forked child processes where shutdown() was called on the provider before fork, background polling for variable updates silently stops working. The child falls back to stale configuration inherited from the parent.
| def _at_fork_reinit(self): # pragma: no cover | |
| # Recreate all things threading related | |
| self._refresh_lock = threading.Lock() | |
| self._session_lock = threading.Lock() | |
| self._worker_awaken = threading.Event() | |
| self._force_refresh_event = threading.Event() | |
| # Only restart threads if we were started before the fork | |
| if self._started: | |
| self._worker_thread = threading.Thread( | |
| name='LogfireRemoteProvider', | |
| target=self._worker, | |
| daemon=True, | |
| ) | |
| self._worker_thread.start() | |
| # Restart SSE listener | |
| self._sse_connected = False | |
| self._start_sse_listener() | |
| self._pid = os.getpid() | |
| def _at_fork_reinit(self): # pragma: no cover | |
| # Recreate all things threading related | |
| self._shutdown = False | |
| self._refresh_lock = threading.Lock() | |
| self._session_lock = threading.Lock() | |
| self._worker_awaken = threading.Event() | |
| self._force_refresh_event = threading.Event() | |
| # Only restart threads if we were started before the fork | |
| if self._started: | |
| self._worker_thread = threading.Thread( | |
| name='LogfireRemoteProvider', | |
| target=self._worker, | |
| daemon=True, | |
| ) | |
| self._worker_thread.start() | |
| # Restart SSE listener | |
| self._sse_connected = False | |
| self._start_sse_listener() | |
| self._pid = os.getpid() |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Adds a managed variables feature to Logfire, enabling dynamic configuration management with feature flags, A/B testing, and targeted rollouts. Variables can be resolved from local in-memory configuration or from the remote Logfire API with background polling and SSE-based real-time updates.
This is a replacement of #1548 to reduce review noise.
Key additions
Unresolved review comments from #1548 to revisit
These are comments that were noted but not actioned yet in this PR:
doesn't seem to exist. Docs may need a cleanup pass.