Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
46866e2
Add variable composition and Handlebars template rendering
dmontagu May 21, 2026
e80febf
Document managed variable composition and template rendering
dmontagu May 21, 2026
d177c30
Run @{...}@ composition through native pydantic-handlebars
dmontagu May 22, 2026
9a44b69
Tighten JSON-null check and cover dotted-ref protection branch
dmontagu May 22, 2026
4f5cec9
Merge branch 'main' of github.com:pydantic/logfire into feature/varia…
alexmojaki May 22, 2026
9d32b10
Merge branch 'feature/variable-composition-clean' into feature/variab…
alexmojaki May 22, 2026
be9cd1f
Remove Python 3.9 handlebars test skips (#1955)
alexmojaki May 22, 2026
e3540eb
Merge branch 'feature/variable-composition-clean' of github.com:pydan…
alexmojaki May 22, 2026
487a29e
Remove Python 3.9 handlebars fallback (#1956)
alexmojaki May 22, 2026
de12509
Tier 1 fixes for #1954 (variable composition review feedback) (#1962)
dmontagu May 25, 2026
231559d
Merge branch 'main' into feature/variable-composition-native-handlebars
dmontagu May 25, 2026
fd00885
Address remaining #1954 review threads (#1964)
dmontagu May 25, 2026
f3e694c
Cache failing callable defaults too in _get_default_cached (#1965)
dmontagu May 25, 2026
1bf910b
Merge branch 'main' into feature/variable-composition-native-handlebars
dmontagu Jun 2, 2026
3fb5da0
Warn on validation-failure fallback to code default
dmontagu Jun 2, 2026
603878f
Document that 'latest'/'code_default' are reserved label names
dmontagu Jun 3, 2026
bc7684b
Run context overrides through composition
dmontagu Jun 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
| `json_schema` | JSON Schema for validation (optional) |
| `description` | Human-readable description (optional) |
| `aliases` | Alternative names that resolve to this variable (optional, for migrations) |
| `example` | JSON-serialized example value, used as template in UI (optional) |
| `example` | JSON-serialized example value, used as starting point when creating versions in the UI (optional) |
| `template_inputs_schema` | JSON Schema for template `{{placeholder}}` inputs (optional, set automatically when template inputs are declared via `logfire.template_var()`) |

**LabeledValue** — A label with an inline serialized value:

Expand Down Expand Up @@ -131,6 +132,31 @@ def test_premium_config_handling():
# Back to normal after context exits
```

#### Overrides and composition

Overrides take precedence over both the provider value and the code default, but they are treated as the user's literal choice. Two consequences worth knowing:

- **`@{ref}@` composition is skipped on overrides.** Overriding with `'hi @{user}@'` produces the literal string — `@{user}@` is *not* expanded. Composition is for resolving fragments out of the configured graph; an override sits outside that graph.
- **Template rendering still happens for `TemplateVariable` overrides**, as long as the override value is JSON-serializable. Overriding with `'Hi {{name}}'` and calling `get(Inputs(name='Alice'))` returns `'Hi Alice'`. An override that *isn't* JSON-serializable (e.g. an arbitrary Python object) skips the render pass and comes back exactly as passed — useful for `Variable[SomeClass]` where the value is a typed Python object rather than a template string.

```python
import logfire

logfire.configure()

logfire.var('user', type=str, default='Alice')
greeting = logfire.var('greeting', type=str, default='Hello, @{user}@!')

# Without an override: composition expands @{user}@.
print(greeting.get().value)
#> Hello, Alice!

# Override is literal — @{user}@ is *not* expanded.
with greeting.override('Hi @{user}@'):
print(greeting.get().value)
#> Hi @{user}@
```

### Dynamic Override Functions

Override with a function that computes the value based on context:
Expand Down
51 changes: 47 additions & 4 deletions docs/reference/advanced/managed-variables/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Managed variables are a way to externalize runtime configuration from your code.
- **Observability-integrated**: Every variable resolution creates a span, and using the context manager automatically sets baggage so downstream operations are tagged with which label and version was used
- **Versions and labels**: Create immutable version snapshots of your variable's value, and assign labels (like `production`, `staging`, `canary`) that point to specific versions
- **Rollouts and targeting**: Control what percentage of requests receive each labeled version, and route specific users or segments based on attributes
- **Templates and composition**: Use `{{placeholder}}` Handlebars syntax in values that get rendered with runtime inputs, and compose variables from reusable fragments via `@{other_variable}@` references (see [Templates and Composition](templates-and-composition.md))

### Versions and Labels

Expand Down Expand Up @@ -112,6 +113,46 @@ With managed variables, you can iterate safely in production:
- **Instant rollback**: If a version is causing problems, move the label back to the previous version in seconds, with no deploy required
- **Full history**: Every version is immutable and preserved, so you can always see exactly what was served and when

## Template Variables

For AI applications, variables often contain prompt templates with placeholders that get filled in at runtime. **Template variables** support this natively with Handlebars `{{placeholder}}` syntax:

!!! note "Install the variables extra for templates"
Template rendering requires the `pydantic-handlebars` package, which is installed by the `logfire[variables]` extra.

```bash
pip install 'logfire[variables]'
```

```python
from pydantic import BaseModel

import logfire

logfire.configure()


class PromptInputs(BaseModel):
user_name: str
is_premium: bool = False


prompt = logfire.template_var(
'system_prompt',
type=str,
default='Hello {{user_name}}!{{#if is_premium}} Welcome back, valued member.{{/if}}',
inputs_type=PromptInputs,
)

with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved:
print(resolved.value)
#> Hello Alice! Welcome back, valued member.
```

Variables can also reference other variables using `@{variable_name}@` syntax, allowing you to compose values from reusable fragments that can be independently updated in the UI.

For full details, see [Templates and Composition](templates-and-composition.md).

## How It Works

Here's the typical workflow using the `AgentConfig` example from above:
Expand Down Expand Up @@ -231,8 +272,10 @@ This bypasses the rollout weights and directly resolves the value from the speci

### Variable Parameters

| Parameter | Description |
|-----------|-------------------------------------------------------------------------|
| `name` | Unique identifier for the variable |
| Parameter | Description |
|-----------|-------------|
| `name` | Unique identifier for the variable |
| `type` | Expected type for validation; can be a primitive type or Pydantic model |
| `default` | Default value when no configuration is found (can also be a function) |
| `default` | Default value when no configuration is found (can also be a function) |

For variables with Handlebars template rendering, use `logfire.template_var()` instead, which adds an `inputs_type` parameter. See [Templates and Composition](templates-and-composition.md).
284 changes: 284 additions & 0 deletions docs/reference/advanced/managed-variables/templates-and-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
# Template Variables and Composition

Managed variables can contain **Handlebars templates** (`{{placeholder}}`) and **composition references** (`@{other_variable}@`), enabling dynamic values that are assembled from multiple sources and rendered with runtime inputs.

This is especially useful for AI applications where prompts are built from reusable fragments and personalized with request-specific data.

!!! note "Install the variables extra"
Template rendering requires the `pydantic-handlebars` package, which is installed by the `logfire[variables]` extra:

```bash
pip install 'logfire[variables]'
```

Without this extra, `logfire.template_var()` raises an error immediately so your application does not silently use an unrendered template.

## Template Variables

A **template variable** is a variable whose value contains `{{placeholder}}` expressions that are rendered with typed inputs at resolution time. Define one with `logfire.template_var()` and call `.get(inputs)` to resolve and render in one step:

```python
from pydantic import BaseModel

import logfire

logfire.configure()


class PromptInputs(BaseModel):
user_name: str
is_premium: bool = False


prompt = logfire.template_var(
'system_prompt',
type=str,
default='Hello {{user_name}}!{{#if is_premium}} Thank you for being a premium member.{{/if}}',
inputs_type=PromptInputs,
)

with prompt.get(PromptInputs(user_name='Alice', is_premium=True)) as resolved:
print(resolved.value)
#> Hello Alice! Thank you for being a premium member.

with prompt.get(PromptInputs(user_name='Bob')) as resolved:
print(resolved.value)
#> Hello Bob!
```

The full resolution pipeline is:

1. **Resolve** — fetch the serialized value from the provider (or use the code default)
2. **Compose** — expand any `@{variable_name}@` references (see [Composition](#variable-composition) below)
3. **Render** — render `{{placeholder}}` Handlebars templates using the provided inputs
4. **Deserialize** — validate and deserialize to the variable's type

`logfire.template_var()` accepts the same parameters as `logfire.var()` plus an `inputs_type` parameter — a Pydantic `BaseModel` (or any type supported by `TypeAdapter`) describing the expected template inputs. It is used for type-safe `.get(inputs)` calls and generates a `template_inputs_schema` for validation.

### Handlebars Syntax

Template variables use [Handlebars](https://handlebarsjs.com/) syntax, powered by the [`pydantic-handlebars`](https://github.com/pydantic/pydantic-handlebars) library. The most common patterns:

| Syntax | Description |
|--------|-------------|
| `{{field}}` | Insert a value |
| `{{obj.nested}}` | Dot-notation access |
| `{{#if field}}...{{/if}}` | Conditional block |
| `{{#unless field}}...{{/unless}}` | Inverse conditional |
| `{{#each items}}...{{/each}}` | Iterate over a list |
| `{{#with obj}}...{{/with}}` | Change context |
| `{{! comment }}` | Comment (not rendered) |

### Structured Template Variables

Template variables work with structured types too. Only string fields containing `{{placeholders}}` are rendered — other fields pass through unchanged:

```python
from pydantic import BaseModel

import logfire

logfire.configure()


class UserContext(BaseModel):
user_name: str
tier: str


class AgentConfig(BaseModel):
instructions: str
model: str
temperature: float


agent_config = logfire.template_var(
'agent_config',
type=AgentConfig,
default=AgentConfig(
instructions='You are helping {{user_name}}, a {{tier}} customer.',
model='openai:gpt-4o-mini',
temperature=0.7,
),
inputs_type=UserContext,
)

with agent_config.get(UserContext(user_name='Alice', tier='premium')) as resolved:
print(resolved.value.instructions)
#> You are helping Alice, a premium customer.
print(resolved.value.model)
#> openai:gpt-4o-mini
```

### Template Validation

When a template variable is pushed to Logfire (via `logfire.variables_push()`), the `template_inputs_schema` is synced alongside the variable's JSON schema. The system validates that all `{{field}}` references in variable values (including values reachable through composition) are compatible with the declared schema.

For example, if your `inputs_type` declares `user_name: str` and `is_premium: bool`, but a version value references `{{unknown_field}}`, the validation will flag this as an error.

## Variable Composition {#variable-composition}

**Composition** lets a variable's value reference other variables using `@{variable_name}@` syntax. When the variable is resolved, `@{ref}@` references are expanded by looking up the referenced variable and substituting its value.

This is useful for building values from reusable fragments:

```python
import logfire

logfire.configure()

# A reusable instruction fragment
safety_rules = logfire.var(
'safety_rules',
type=str,
default='Never share personal data. Always be respectful.',
)

# A prompt that includes the safety rules via composition
agent_prompt = logfire.var(
'agent_prompt',
type=str,
default='You are a helpful assistant. @{safety_rules}@',
)

with agent_prompt.get() as resolved:
print(resolved.value)
#> You are a helpful assistant. Never share personal data. Always be respectful.
```

When `safety_rules` is updated in the Logfire UI, all variables that reference `@{safety_rules}@` automatically pick up the new value — no code changes or redeployment required.

### Composition Control Flow

The `@{}@` syntax runs through the full Handlebars engine (just with `@{` / `}@` as the delimiter pair instead of the default `{{` / `}}`), so any expression form that works in Handlebars also works here — simple references, dotted field reads, block helpers, and helper sub-expressions:

| Syntax | Description |
|--------|-------------|
| `@{variable_name}@` | Insert a variable's value |
| `@{variable.field}@` | Access a nested field |
| `@{#if variable}@...@{else}@...@{/if}@` | Conditional on whether a variable is set |
| `@{#if user.active}@...@{/if}@` | Conditional on a dotted field |
| `@{#each items}@...@{/each}@` | Iterate over a list variable |
| `@{#each items}@@{../top}@@{/each}@` | Access an outer-scope value from inside a block |

### Composition Tracking

Every `@{ref}@` expansion is recorded in the resolution result. You can inspect which variables were composed and their values:

```python
import logfire

logfire.configure()

logfire.var('city', type=str, default='Paris')
report = logfire.var('report', type=str, default='Weather in @{city}@: sunny.')

with report.get() as resolved:
for ref in resolved.composed_from:
print(f'{ref.name}={ref.value!r} reason={ref.reason}')
#> city='Paris' reason=code_default
```

These composition details are also recorded as span attributes, so you can see the full composition chain in your Logfire traces.

### Combining Templates and Composition

Template variables and composition work together. A common pattern is to compose reusable fragments via `@{ref}@` and render runtime inputs via `{{}}`:

```python
from pydantic import BaseModel

import logfire

logfire.configure()


class ChatInputs(BaseModel):
user_name: str
language: str


# Reusable fragment (no template inputs)
logfire.var('tone_instructions', type=str, default='Be friendly and concise.')

# Template variable that composes the fragment and renders inputs
chat_prompt = logfire.template_var(
'chat_prompt',
type=str,
default='You are helping {{user_name}}. Respond in {{language}}. @{tone_instructions}@',
inputs_type=ChatInputs,
)

# Resolution: compose @{tone_instructions}@ first, then render {{user_name}} and {{language}}
with chat_prompt.get(ChatInputs(user_name='Alice', language='French')) as resolved:
print(resolved.value)
#> You are helping Alice. Respond in French. Be friendly and concise.
```

### Recursive Resolution

!!! warning "Different from plain Handlebars"
Standard Handlebars expressions like `{{greeting}}` perform a **one-shot string substitution**: whatever string `greeting` resolves to appears verbatim in the output. If that string happens to contain `{{name}}`, the inner `{{name}}` is *not* re-evaluated — it ends up in the output as the literal text `{{name}}`.

`@{...}@` composition does the opposite: when the SDK substitutes a referenced variable, it first **fully resolves** that variable — including expanding any `@{...}@` references *inside* it — before splicing the result in.

Concretely, composition walks the reference graph at resolution time. A tree like `parent → @{middle}@ → @{leaf}@` resolves leaf-first, builds `middle`, then substitutes the result into `parent`:

```python
import logfire

logfire.configure()

logfire.var('leaf', type=str, default='LEAF')
logfire.var('middle', type=str, default='middle wraps @{leaf}@')
parent = logfire.var('parent', type=str, default='top: @{middle}@')

with parent.get() as resolved:
print(resolved.value)
#> top: middle wraps LEAF
# composed_from mirrors the tree:
print(f'{resolved.composed_from[0].name} -> {resolved.composed_from[0].composed_from[0].name}')
#> middle -> leaf
```

Contrast with plain Handlebars rendering, where `{{...}}` only substitutes — no graph walk, no re-rendering of values that happen to look template-like:

```python
from pydantic_handlebars import render

print(render('{{greeting}}', {'greeting': 'Hello, {{name}}!', 'name': 'Alice'}))
#> Hello, {{name}}!
```

### Cycle and depth guards

Because resolution walks an arbitrary graph, two failure modes need explicit handling: cycles (`A → @{B}@`, `B → @{A}@`) and deep chains. Both are caught at two layers:

- **Push / sync time** — `logfire.variables_validate()` reports reference errors and cycles; `logfire.variables_push(strict=True)` fails instead of applying an invalid configuration. The walk covers the *full* reachable graph (local code defaults and server-stored label values), so a cycle whose midpoint is a server-only variable is still detected. This is the loud-by-default path.
- **Runtime** — if an invalid composition slips through (e.g. a server value changed between validation and the next resolve), `Variable.get()` catches the cycle (via a visited-set) or depth overflow (`MAX_COMPOSITION_DEPTH = 20`) and falls back to the variable's *code default* with a `RuntimeWarning`. The exception is recorded on `ResolvedVariable.exception` and the resolution reason becomes `'other_error'` so callers can detect and react. The same fallback applies when a `@{ref}@` points at a variable that doesn't exist at runtime — this differs from a missing `{{field}}` (Handlebars' empty-string substitution); composition treats unresolvable references as a real failure.

```python
import warnings

import logfire
from logfire.variables import VariableCompositionError

logfire.configure()

# A pair of variables that reference each other — push-time validation
# would catch this; we register them here just to show what the runtime
# guard does when it does have to step in.
left = logfire.var('cycle_left', type=str, default='@{cycle_right}@')
logfire.var('cycle_right', type=str, default='@{cycle_left}@')

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
with left.get() as resolved:
# `resolved.reason` is `'other_error'` because composition failed,
# and `resolved.exception` is a `VariableCompositionError` (or a
# subclass like `VariableCompositionCycleError` for cycles).
print(resolved.reason, isinstance(resolved.exception, VariableCompositionError))
#> other_error True
print(any('composition failed' in str(w.message) for w in caught))
#> True
```
Loading
Loading