Skip to content

Add managed variables#1691

Merged
dmontagu merged 47 commits intomainfrom
managed-variables
Feb 13, 2026
Merged

Add managed variables#1691
dmontagu merged 47 commits intomainfrom
managed-variables

Conversation

@dmontagu
Copy link
Copy Markdown
Contributor

@dmontagu dmontagu commented Feb 8, 2026

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

  • logfire/variables/ module — Variable class, VariableProvider ABC, local and remote providers, config models (rollout, conditions, variants)
  • Logfire.var() — creates managed variables with typed defaults, optional resolve functions, and Pydantic validation
  • variables_push / variables_validate / variables_push_types — CLI-friendly methods for syncing variable definitions to a provider
  • Targeting & rollout — deterministic variant selection via targeting keys (user ID, trace ID), weighted rollouts, condition-based overrides (attribute matching, regex, presence checks)
  • Context managers — variable.override(), variable.use_variant(), targeting_context() for scoped variable behavior
  • Integration with logfire.configure() — VariablesOptions dataclass, provider lifecycle tied to config/shutdown

Unresolved review comments from #1548 to revisit

These are comments that were noted but not actioned yet in this PR:

  1. Verb/naming inconsistency — Alex noted the mismatch between "get" vs "pull" and "sync" vs "push" in method names. Worth a naming review pass.
  2. Method names sound generic — Alex suggested the variables_ prefix approach (already done) but also noted names could still be confused with logfire.configure options.
  3. VariantKey/VariableName as NewTypes — Alex asked why not. David responded concerns about runtime overhead and deepening Pydantic dependency; changed to Pydantic models per Marcelo's suggestion. May revisit.
  4. logfire/variables/config.py unused code — Alex asked "is this used?" about some config code (around line 473). Worth auditing.
  5. Stale docs reference — Alex noted docs/reference/advanced/managed-variables.md:875 references a method that
    doesn't seem to exist. Docs may need a cleanup pass.
  6. Provider start lifecycle — Devin flagged that start(None) in _initialize() followed by start(logfire_instance) in configure() is fragile. Currently works because _logfire is set before the _started early-return, but the two-phase start is worth reviewing.
  7. PEP 747 (TypeForm) — Viicos suggested we could drop the sequence-of-types overload once PEP 747 lands, allowing type: str | int directly.
  8. TypeAdapter deferred build — Viicos suggested using defer_build config for variables. David opted for eager build for now to avoid forward-ref issues; can revisit if build time is a problem.

dmontagu and others added 13 commits February 5, 2026 11:09
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.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Feb 8, 2026

Deploying logfire-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: deb4842
Status: ✅  Deploy successful!
Preview URL: https://e256b7c5.logfire-docs.pages.dev
Branch Preview URL: https://managed-variables.logfire-docs.pages.dev

View logs

…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
@alexmojaki
Copy link
Copy Markdown
Collaborator

the docs page is by far the biggest, please can it be split up into multiple pages?

wc  **/*.md | sort | tail
     286    1434   11469 why.md
     310    2032   14554 guides/onboarding-checklist/add-manual-tracing.md
     404    1764   13057 how-to-guides/sampling.md
     445    4132   29336 reference/sql.md
     549    2245   20157 how-to-guides/otel-collector/otel-collector-overview.md
     561    2234   18907 reference/self-hosted/installation.md
     581    2716   23374 how-to-guides/cloud-metrics.md
     609    3827   28436 how-to-guides/write-dashboard-queries.md
    1320    5957   49723 reference/advanced/managed-variables.md
   16835   82542  682764 total

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.variables module 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 into configure() / 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.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Extract on_change callback functionality into a separate PR to keep
the managed-variables branch focused on the core feature.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

- 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.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 9 additional findings in Devin Review.

Open in Devin Review

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
@dmontagu
Copy link
Copy Markdown
Contributor Author

NOTE: screenshots are out of date and should be updated (with playwright) before merging.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 32 additional findings in Devin Review.

Open in Devin Review

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
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 33 additional findings in Devin Review.

Open in Devin Review

- 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.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 34 additional findings in Devin Review.

Open in Devin Review

…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
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 36 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 36 additional findings in Devin Review.

Open in Devin Review

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.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 38 additional findings in Devin Review.

Open in Devin Review

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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 41 additional findings in Devin Review.

Open in Devin Review

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").
@dmontagu dmontagu enabled auto-merge (squash) February 13, 2026 19:55
@dmontagu dmontagu merged commit cf2bd1d into main Feb 13, 2026
15 checks passed
@dmontagu dmontagu deleted the managed-variables branch February 13, 2026 20:02
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 43 additional findings in Devin Review.

Open in Devin Review

Comment on lines +102 to +119
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()
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.

🟡 _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.

Suggested change
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()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

3 participants