Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
33 changes: 33 additions & 0 deletions logfire/_internal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,27 @@ class CodeSource:
"""


TemplateMismatchPolicy = Literal['warn', 'error', 'ignore']
"""How `TemplateVariable.get(inputs)` reacts to a `{{field}}` reference that
the runtime `inputs` does not satisfy.

- `'warn'` (default): emit a `RuntimeWarning` and render the template anyway
(missing fields substitute as the empty string, matching default Handlebars
behaviour).
- `'error'`: raise `HandlebarsRuntimeError` instead of rendering.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
- `'ignore'`: render silently, no warning.

Configurable at three levels, with the variable-level value winning when set
(`None` means "inherit from the surrounding options"):

1. Per-variable via `var()` / `template_var()`'s `template_mismatch_policy`
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
argument — variable-level wins, even when relaxing.
2. Per-Logfire-instance via `VariablesOptions.template_mismatch_policy` or
`LocalVariablesOptions.template_mismatch_policy`.
3. Falls back to `'warn'` when nothing is set.
"""


@dataclass
class VariablesOptions:
"""Configuration for managed variables using the Logfire remote API.
Expand All @@ -378,6 +399,12 @@ class VariablesOptions:
"""Whether to include OpenTelemetry baggage when resolving variables."""
instrument: bool = True
"""Whether to create spans when resolving variables."""
template_mismatch_policy: TemplateMismatchPolicy = 'warn'
"""How to react when a `TemplateVariable`'s `{{field}}` references something its
`inputs_type` doesn't declare. See `TemplateMismatchPolicy` for the full semantics.

Overridden per-variable by the matching argument on `var()` / `template_var()`.
"""

def __post_init__(self):
interval_seconds = (
Expand Down Expand Up @@ -408,6 +435,12 @@ class LocalVariablesOptions:
"""Whether to include OpenTelemetry baggage when resolving variables."""
instrument: bool = True
"""Whether to create spans when resolving variables."""
template_mismatch_policy: TemplateMismatchPolicy = 'warn'
"""How to react when a `TemplateVariable`'s `{{field}}` references something its
`inputs_type` doesn't declare. See `TemplateMismatchPolicy` for the full semantics.

Overridden per-variable by the matching argument on `var()` / `template_var()`.
"""


class DeprecatedKwargs(TypedDict):
Expand Down
10 changes: 10 additions & 0 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
Variable,
VariablesConfig,
)
from .config import TemplateMismatchPolicy
from .integrations.asgi import ASGIApp, ASGIInstrumentKwargs
from .integrations.aws_lambda import LambdaEvent, LambdaHandler
from .integrations.llm_providers.semconv import SemconvVersion
Expand Down Expand Up @@ -2630,6 +2631,7 @@ def template_var(
default: T | ResolveFunction[T],
inputs_type: type[InputsT],
description: str | None = None,
template_mismatch_policy: TemplateMismatchPolicy | None = None,
) -> TemplateVariable[T, InputsT]:
"""Define a managed template variable with integrated rendering.

Expand Down Expand Up @@ -2669,6 +2671,13 @@ class PromptInputs(BaseModel):
inputs_type: The type (typically a Pydantic `BaseModel`) describing the expected
template inputs. Used for type-safe `get(inputs)` calls and JSON schema generation.
description: Optional human-readable description of what the variable controls.
template_mismatch_policy: How to react when `get(inputs)` is called with inputs that
don't satisfy a `{{field}}` reference in the resolved template. `'warn'` emits a
`RuntimeWarning` and renders the missing field as the empty string;
`'error'` raises `HandlebarsRuntimeError`; `'ignore'` renders silently.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Defaults to inheriting from `VariablesOptions` / `LocalVariablesOptions`
(which default to `'warn'`). Pass an explicit value to override the
instance-level policy for this variable only — even to relax it.
"""
import re

Expand Down Expand Up @@ -2697,6 +2706,7 @@ class PromptInputs(BaseModel):
inputs_type=inputs_type,
description=description,
logfire_instance=self,
template_mismatch_policy=template_mismatch_policy,
)
self._variables[name] = variable

Expand Down
3 changes: 3 additions & 0 deletions logfire/variables/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
)
from logfire.variables.variable import (
ResolveFunction,
TemplateInputsMismatchError,
TemplateVariable,
Variable,
targeting_context,
Expand Down Expand Up @@ -84,6 +85,7 @@
'SyncMode',
'ValidationReport',
# Exceptions
'TemplateInputsMismatchError',
'VariableAlreadyExistsError',
'VariableCompositionCycleError',
'VariableCompositionError',
Expand Down Expand Up @@ -124,6 +126,7 @@ def __getattr__(name: str):
)
from logfire.variables.variable import (
ResolveFunction,
TemplateInputsMismatchError,
TemplateVariable,
Variable,
targeting_context,
Expand Down
5 changes: 3 additions & 2 deletions logfire/variables/template_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'TemplateValidationResult',
'validate_template_composition',
'detect_composition_cycles',
'extract_template_strings',
'find_template_fields',
)

Expand Down Expand Up @@ -65,7 +66,7 @@ def find_template_fields(text: str) -> set[str]:
return set(TEMPLATE_FIELD_PATTERN.findall(text))


def _extract_template_strings(serialized_json: str) -> list[str]:
def extract_template_strings(serialized_json: str) -> list[str]:
"""Extract all string values from serialized JSON that contain `{{...}}` templates."""
try:
decoded = json.loads(serialized_json)
Expand Down Expand Up @@ -124,7 +125,7 @@ def _collect(name: str, path: list[str], visited: frozenset[str]) -> None:
visited = visited | {name}

for label, serialized_value in get_all_serialized_values(name).items():
templates = _extract_template_strings(serialized_value)
templates = extract_template_strings(serialized_value)
if not templates:
for ref in find_references(serialized_value):
_collect(ref, path + [ref], visited)
Expand Down
90 changes: 90 additions & 0 deletions logfire/variables/variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)

if TYPE_CHECKING:
from logfire._internal.config import TemplateMismatchPolicy
from logfire.variables.abstract import VariableProvider
from logfire.variables.config import VariableConfig

Expand All @@ -40,9 +41,24 @@
'is_resolve_function',
'Variable',
'TemplateVariable',
'TemplateInputsMismatchError',
'targeting_context',
)


class TemplateInputsMismatchError(Exception):
"""Render-time `{{field}}` mismatch raised under the strict policy.

Raised by `TemplateVariable.get(inputs)` when the resolved template
references a `{{field}}` not declared in the variable's `inputs_type`
and the active `template_mismatch_policy` is `'error'`.

Distinct from `HandlebarsError` so it bypasses the SDK's composition-
failure fallback and propagates to the caller — the `'error'` policy
is meant to fail loudly, not silently degrade to the code default.
"""


T_co = TypeVar('T_co', covariant=True)
InputsT = TypeVar('InputsT')

Expand Down Expand Up @@ -251,6 +267,11 @@ def _resolve(
result.exception = serialized_result.exception
return result

except TemplateInputsMismatchError:
# The `'error'` template_mismatch_policy explicitly opts into a
# loud failure mode — bypass the default-fallback path and let
# the exception reach the caller.
raise
except Exception as e:
if span and serialized_result is not None: # pragma: no cover
span.set_attribute('invalid_serialized_label', serialized_result.label)
Expand Down Expand Up @@ -635,6 +656,14 @@ class TemplateVariable(Variable[T_co], Generic[T_co, InputsT]):
inputs_type: type[InputsT]
"""The type used for template inputs."""

template_mismatch_policy: TemplateMismatchPolicy | None
"""Per-variable override of the render-time `{{field}}` mismatch policy.

`None` means "inherit from `VariablesOptions` / `LocalVariablesOptions`"; an
explicit value overrides the instance-level policy for this variable only,
even when relaxing.
"""

def __init__(
self,
name: str,
Expand All @@ -644,6 +673,7 @@ def __init__(
inputs_type: type[InputsT],
description: str | None = None,
logfire_instance: logfire.Logfire,
template_mismatch_policy: TemplateMismatchPolicy | None = None,
):
"""Create a new template variable.

Expand All @@ -656,6 +686,9 @@ def __init__(
template inputs. Used for type-safe `get(inputs)` calls and JSON schema generation.
description: Optional human-readable description of what this variable controls.
logfire_instance: The Logfire instance this variable is associated with.
template_mismatch_policy: Per-variable override of the render-time
`{{field}}` mismatch policy. `None` (default) inherits from
`VariablesOptions` / `LocalVariablesOptions`.
"""
super().__init__(
name,
Expand All @@ -666,6 +699,7 @@ def __init__(
)
self.inputs_type = inputs_type
self._inputs_type_adapter: TypeAdapter[InputsT] = TypeAdapter(inputs_type)
self.template_mismatch_policy = template_mismatch_policy

def get_template_inputs_schema(self) -> dict[str, Any]:
"""Return the JSON schema derived from `inputs_type`."""
Expand Down Expand Up @@ -703,14 +737,70 @@ def get( # pyright: ignore[reportIncompatibleMethodOverride]

Returns:
A ResolvedVariable with the fully rendered and deserialized value.

Raises:
HandlebarsRuntimeError: When `template_mismatch_policy` resolves to
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
`'error'` and the post-composition template references a
`{{field}}` not declared in `inputs_type`.
"""
from logfire.variables.abstract import render_serialized_string

policy = self._effective_template_mismatch_policy()

def _render_fn(serialized_json: str) -> str:
if policy != 'ignore':
self._check_template_fields(serialized_json, policy)
return render_serialized_string(serialized_json, inputs)

return self._get_result_and_record_span(targeting_key, attributes, label, render_fn=_render_fn)

def _effective_template_mismatch_policy(self) -> TemplateMismatchPolicy:
"""Resolve the policy for this variable's next `get()` call.

Per-variable wins when set (even when relaxing). Otherwise reads the
instance-level `VariablesOptions` / `LocalVariablesOptions` setting,
falling back to `'warn'` if no managed-variables config is in use.
"""
if self.template_mismatch_policy is not None:
return self.template_mismatch_policy
from logfire._internal.config import LocalVariablesOptions, VariablesOptions

options = self.logfire_instance.config.variables
if isinstance(options, (VariablesOptions, LocalVariablesOptions)):
return options.template_mismatch_policy
return 'warn'

def _check_template_fields(self, serialized_value: str, policy: TemplateMismatchPolicy) -> None:
"""Apply the render-time mismatch policy.

Walks every `{{field}}` reference in the post-composition serialized
template through `pydantic_handlebars.check_template_compatibility`
against `inputs_type`'s JSON schema. Any error-severity issue
triggers the policy: `'error'` raises `TemplateInputsMismatchError`,
`'warn'` emits a `RuntimeWarning`. (`'ignore'` callers never reach
this path.)
"""
from logfire.variables._handlebars import check_template_compatibility
from logfire.variables.template_validation import extract_template_strings

templates = extract_template_strings(serialized_value)
if not templates:
return
schema = self.get_template_inputs_schema()
result = check_template_compatibility(templates, schema)
error_fields = [issue.field_path for issue in result.issues if issue.severity == 'error']
if not error_fields:
return

fields_str = ', '.join(repr(f) for f in dict.fromkeys(error_fields))
message = (
f"Variable '{self.name}': template references {fields_str} "
f'which are not declared in inputs_type {self.inputs_type.__name__!r}.'
)
if policy == 'error':
raise TemplateInputsMismatchError(message)
warnings.warn(message, category=RuntimeWarning, stacklevel=4)


def get_template_inputs_schema(variable: Variable[Any]) -> dict[str, Any] | None:
"""Return the template inputs JSON schema, or None for non-template variables."""
Expand Down
Loading
Loading