Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .changeset/variable-composition-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'logfire': minor
---

Add managed variable composition and template rendering to `logfire/vars`.

Variables can now reference other variables with `@{name}@`, expose composition metadata on resolved values, render `{{}}` placeholders through `ResolvedVariable.render()`, and use the new `defineTemplateVar()` / `templateVar` API for compose-and-render prompt/config values. Variable configs also support `template_inputs_schema` for validation and strict push checks.
25 changes: 25 additions & 0 deletions docs/managed-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ logfire.configure({

Do not expose API keys in browser bundles. Browser apps should use local variables or resolve variables through a trusted backend.

## Composition And Templates

String values can reference other variables with `@{variable_name}@`. Composition runs before the value is parsed by the variable codec, so references can be used inside JSON strings, objects, and arrays. Dotted paths and Handlebars block helpers are supported:

```ts
import { defineTemplateVar } from '@pydantic/logfire-node/vars'

const checkoutPrompt = defineTemplateVar<string, { customerName: string }>('checkout_prompt', {
default: 'Say hello to {{customerName}}.',
templateInputsSchema: {
properties: { customerName: { type: 'string' } },
required: ['customerName'],
type: 'object',
},
})

const resolved = await checkoutPrompt.get({ customerName: 'Ada' })
```

A remote or local value such as `"Use @{brand_voice}@. Customer: {{customerName}}"` first expands `@{brand_voice}@`, then `defineTemplateVar().get()` renders the remaining `{{customerName}}` placeholder.

Use `\@{name}@` for a literal `@{name}@`. Missing references remain literal and are reported by validation.

`templateInputsSchema` is explicit in JavaScript because TypeScript types are not available at runtime. It is used by `variablesValidate()` and strict push checks; `get(inputs)` trusts the caller and does not run JSON Schema validation.

## Baggage Context

Resolved variables can attach their selected label to OpenTelemetry baggage:
Expand Down
61 changes: 56 additions & 5 deletions examples/node/variables.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dotenv/config'
import * as logfire from '@pydantic/logfire-node'
import { defineVar, targetingContext, variablesPushConfig } from '@pydantic/logfire-node/vars'
import { defineTemplateVar, defineVar, targetingContext, variablesPushConfig } from '@pydantic/logfire-node/vars'
import type { VariablesConfig } from '@pydantic/logfire-node/vars'

const localVariablesConfig = {
Expand All @@ -19,6 +19,33 @@ const localVariablesConfig = {
],
rollout: { labels: { control: 1 } },
},
checkout_prompt: {
labels: {
production: {
serialized_value: '"Use @{brand_voice}@ Button copy: {{buttonCopy}}. Cart items: {{itemCount}}."',
version: 1,
},
},
name: 'checkout_prompt',
overrides: [],
rollout: { labels: { production: 1 } },
template_inputs_schema: {
properties: {
buttonCopy: { type: 'string' },
itemCount: { type: 'number' },
},
required: ['buttonCopy', 'itemCount'],
type: 'object',
},
},
brand_voice: {
labels: {
production: { serialized_value: '"clear, concise, and helpful"', version: 1 },
},
name: 'brand_voice',
overrides: [],
rollout: { labels: { production: 1 } },
},
request_timeout_ms: {
labels: {
default: { serialized_value: '2500', version: 1 },
Expand Down Expand Up @@ -46,18 +73,31 @@ logfire.configure({
console: false,
diagLogLevel: logfire.DiagLogLevel.NONE,
environment: 'local',
sendToLogfire: false,
serviceName: 'example-node-variables',
serviceVersion: '1.0.0',
variables: useRemoteVariables
? { blockBeforeFirstResolve: true, polling: false, sse: false }
? { blockBeforeFirstResolve: true, instrument: false, polling: false, sse: false }
: {
config: localVariablesConfig,
instrument: false,
},
})

console.log(useRemoteVariables ? `using remote variables from ${baseUrl}` : 'using local variables config')

const checkoutButtonCopy = defineVar('checkout_button_copy', { default: 'Continue' })
const checkoutPrompt = defineTemplateVar<string, { buttonCopy: string; itemCount: number }>('checkout_prompt', {
default: 'Button copy: {{buttonCopy}}. Cart items: {{itemCount}}.',
templateInputsSchema: {
properties: {
buttonCopy: { type: 'string' },
itemCount: { type: 'number' },
},
required: ['buttonCopy', 'itemCount'],
type: 'object',
},
})
const requestTimeoutMs = defineVar('request_timeout_ms', { default: 1000 })
const featureConfig = defineVar('feature_config', { default: { maxItems: 10, showBeta: false } })

Expand All @@ -76,6 +116,19 @@ console.log('enterprise copy:', enterpriseCopy.value, {
version: enterpriseCopy.version,
})

const prompt = await checkoutPrompt.get(
{ buttonCopy: enterpriseCopy.value, itemCount: 3 },
{
attributes: { plan: 'enterprise' },
targetingKey: 'user_123',
}
)
console.log('composed checkout prompt:', prompt.value, {
composedFrom: prompt.composedFrom.map((reference) => reference.name),
label: prompt.label,
reason: prompt.reason,
})

await targetingContext('user_456', async () => {
const timeout = await requestTimeoutMs.get({ attributes: { region: 'apac' } })
console.log('regional timeout:', timeout.value, {
Expand All @@ -94,7 +147,5 @@ const missingRemoteConfig = await featureConfig.get()
console.log('code default object:', missingRemoteConfig.value, { reason: missingRemoteConfig.reason })

await enterpriseCopy.withContext(async () => {
logfire.info('Resolved checkout copy is attached to baggage for this span')
console.log('resolved checkout copy is attached to baggage inside this callback')
})

await logfire.forceFlush()
17 changes: 17 additions & 0 deletions packages/logfire-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,22 @@ const featureEnabled = defineVar('feature_enabled', { default: false })
const resolved = await featureEnabled.get({ targetingKey: 'user-123' })
```

Variables can compose other variables with `@{name}@` and render runtime
Handlebars placeholders with `defineTemplateVar`:

```ts
import { defineTemplateVar } from 'logfire/vars'

const prompt = defineTemplateVar<string, { name: string }>('prompt', {
default: 'Hello {{name}}',
templateInputsSchema: {
properties: { name: { type: 'string' } },
type: 'object',
},
})

const resolvedPrompt = await prompt.get({ name: 'Ada' })
```

Remote variables require a Logfire API key and should be used from trusted
server-side runtimes. Do not expose API keys in browser bundles.
1 change: 1 addition & 0 deletions packages/logfire-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"test": "vp test"
},
"dependencies": {
"handlebars": "catalog:",
"js-yaml": "catalog:",
"p-retry": "catalog:",
"zod": "catalog:"
Expand Down
3 changes: 2 additions & 1 deletion packages/logfire-api/src/vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('managed variables', () => {
const count = defineVar('count', { default: 3 })
const missing = defineVar('missing', { default: 'fallback' })

await expect(count.get()).resolves.toMatchObject({ reason: 'validation_error', value: 3 })
await expect(count.get()).resolves.toMatchObject({ label: 'bad', reason: 'validation_error', value: 3, version: 1 })
await expect(missing.get()).resolves.toMatchObject({ reason: 'unrecognized_variable', value: 'fallback' })
})

Expand Down Expand Up @@ -236,6 +236,7 @@ describe('managed variables', () => {
name: 'feature',
overrides: [],
rollout: { labels: {} },
template_inputs_schema: null,
type_name: null,
},
},
Expand Down
205 changes: 205 additions & 0 deletions packages/logfire-api/src/vars/composition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'

import { expandReferences, findReferences, hasFatalCompositionError } from './composition'
import type { ResolvedReference } from './composition'

const resolved = (value: unknown, extra: Partial<ResolvedReference> = {}): ResolvedReference => ({
reason: 'resolved',
value: JSON.stringify(value),
...extra,
})

const missing = (name: string): ResolvedReference => ({ name, reason: 'unrecognized_variable', value: undefined })

const resolver =
(values: Record<string, ResolvedReference>) =>
(name: string): ResolvedReference =>
values[name] ?? missing(name)

describe('variable composition', () => {
it('finds simple, dotted, and block references in encounter order', () => {
expect(findReferences('@{greeting}@ @{brand.tagline}@ @{#if beta}@yes@{/if}@ @{greeting}@')).toEqual(['greeting', 'brand', 'beta'])
expect(findReferences('\\@{escaped}@ @{#if this}@yes@{/if}@ @{else}@')).toEqual([])
})

it('expands simple and duplicate references once in metadata', async () => {
const result = await expandReferences(
JSON.stringify('@{greeting}@, @{greeting}@ World'),
resolver({ greeting: resolved('Hello', { label: 'production', version: 1 }) })
)

expect(JSON.parse(result.serializedValue)).toBe('Hello, Hello World')
expect(result.composedFrom).toEqual([
{
label: 'production',
name: 'greeting',
reason: 'resolved',
value: '"Hello"',
version: 1,
},
])
})

it('expands nested references and preserves nested metadata', async () => {
const result = await expandReferences(
JSON.stringify('@{outer}@'),
resolver({
inner: resolved('inside'),
outer: resolved('@{inner}@'),
})
)

expect(JSON.parse(result.serializedValue)).toBe('inside')
expect(result.composedFrom[0]?.composedFrom?.[0]).toMatchObject({ name: 'inner', value: '"inside"' })
})

it('expands nested references inside structured referenced values', async () => {
const result = await expandReferences(
JSON.stringify('@{config.prompt}@ using @{config.model}@'),
resolver({
config: resolved({ model: 'gpt-4', prompt: 'Hello @{name}@' }),
name: resolved('Alice'),
})
)

expect(JSON.parse(result.serializedValue)).toBe('Hello Alice using gpt-4')
expect(result.composedFrom).toHaveLength(1)
expect(result.composedFrom[0]).toMatchObject({
composedFrom: [{ name: 'name', value: '"Alice"' }],
name: 'config',
value: '{"model":"gpt-4","prompt":"Hello Alice"}',
})
})

it('walks structured values and supports dotted references', async () => {
const result = await expandReferences(
JSON.stringify({ items: ['@{brand.tagline}@', 3], title: '@{brand.name}@' }),
resolver({ brand: resolved({ name: 'Logfire', tagline: 'Observe everything' }) })
)

expect(JSON.parse(result.serializedValue)).toEqual({
items: ['Observe everything', 3],
title: 'Logfire',
})
})

it('walks lists and leaves non-string values unchanged', async () => {
const result = await expandReferences(
JSON.stringify(['@{greeting}@ @{name}@', 42, { nested: '@{name}@' }]),
resolver({ greeting: resolved('Hello'), name: resolved('Alice') })
)

expect(JSON.parse(result.serializedValue)).toEqual(['Hello Alice', 42, { nested: 'Alice' }])
expect(result.composedFrom.map((reference) => reference.name)).toEqual(['greeting', 'name'])
})

it('supports block helpers without treating helper keywords as variables', async () => {
const result = await expandReferences(JSON.stringify('@{#if beta}@beta@{else}@stable@{/if}@'), resolver({ beta: resolved(true) }))

expect(JSON.parse(result.serializedValue)).toBe('beta')
expect(result.composedFrom).toHaveLength(1)
})

it('supports unless, each, and with block helper contexts', async () => {
const values = resolver({
beta: resolved(false),
brand: resolved({ tagline: 'Observe everything' }),
items: resolved(['a', 'b']),
})

await expect(expandReferences(JSON.stringify('@{#unless beta}@stable@{/unless}@'), values)).resolves.toMatchObject({
serializedValue: JSON.stringify('stable'),
})
await expect(expandReferences(JSON.stringify('@{#each items}@<@{this}@>@{/each}@'), values)).resolves.toMatchObject({
serializedValue: JSON.stringify('<a><b>'),
})
await expect(expandReferences(JSON.stringify('@{#with brand}@@{this.tagline}@@{/with}@'), values)).resolves.toMatchObject({
serializedValue: JSON.stringify('Observe everything'),
})
})

it('preserves runtime placeholders and escaped references', async () => {
const result = await expandReferences(JSON.stringify('\\@{name}@ @{name}@ {{runtime}}'), resolver({ name: resolved('Ada') }))

expect(JSON.parse(result.serializedValue)).toBe('@{name}@ Ada {{runtime}}')
expect(result.composedFrom).toHaveLength(1)
})

it('preserves referenced HTML entities and escaped reference syntax', async () => {
await expect(
expandReferences(JSON.stringify('@{ref}@'), resolver({ ref: resolved('literal &#123; and &#125;') }))
).resolves.toMatchObject({
serializedValue: JSON.stringify('literal &#123; and &#125;'),
})
await expect(expandReferences(JSON.stringify('@{ref}@'), resolver({ ref: resolved('\\@{not_a_ref}@') }))).resolves.toMatchObject({
serializedValue: JSON.stringify('\\@{not_a_ref}@'),
})
})

it('preserves JSON encoding for rendered reference values', async () => {
const value = 'line 1\n"quoted" \\ slash café'
const result = await expandReferences(JSON.stringify('Value: @{text}@'), resolver({ text: resolved(value) }))

expect(JSON.parse(result.serializedValue)).toBe(`Value: ${value}`)
})

it('keeps unresolved references literal and records metadata', async () => {
const result = await expandReferences(JSON.stringify('@{missing}@ @{present}@'), resolver({ present: resolved('ok') }))

expect(JSON.parse(result.serializedValue)).toBe('@{missing}@ ok')
expect(result.composedFrom).toEqual([
{ name: 'missing', reason: 'unrecognized_variable' },
{ name: 'present', reason: 'resolved', value: '"ok"' },
])
})

it('keeps unresolved dotted references literal', async () => {
await expect(expandReferences(JSON.stringify('Hello @{nonexistent.field}@'), resolver({}))).resolves.toMatchObject({
composedFrom: [{ name: 'nonexistent', reason: 'unrecognized_variable' }],
serializedValue: JSON.stringify('Hello @{nonexistent.field}@'),
})
await expect(
expandReferences(JSON.stringify('Hi @{known}@ @{missing.field}@'), resolver({ known: resolved('there') }))
).resolves.toMatchObject({
composedFrom: [
{ name: 'known', reason: 'resolved', value: '"there"' },
{ name: 'missing', reason: 'unrecognized_variable' },
],
serializedValue: JSON.stringify('Hi there @{missing.field}@'),
})
})

it('records invalid referenced JSON without replacing the reference', async () => {
const result = await expandReferences(JSON.stringify('@{bad}@'), resolver({ bad: { reason: 'resolved', value: 'not-json' } }))

expect(JSON.parse(result.serializedValue)).toBe('@{bad}@')
expect(result.composedFrom[0]?.error).toContain('non-JSON')
})

it('records cycles as fatal composition errors', async () => {
const result = await expandReferences(
JSON.stringify('@{b}@'),
resolver({
a: resolved('@{b}@'),
b: resolved('@{a}@'),
}),
{ rootName: 'a' }
)

expect(JSON.parse(result.serializedValue)).toBe('@{a}@')
expect(hasFatalCompositionError(result.composedFrom)).toBe(true)
expect(result.composedFrom[0]?.composedFrom?.[0]?.error).toContain('VariableCompositionCycleError')
})

it('records depth overflows as fatal composition errors', async () => {
const values: Record<string, ResolvedReference> = {}
for (let index = 0; index < 22; index += 1) {
values[`v${String(index)}`] = resolved(`@{v${String(index + 1)}}@`)
}
values['v22'] = resolved('done')

const result = await expandReferences(JSON.stringify('@{v0}@'), resolver(values), { rootName: 'root' })

expect(hasFatalCompositionError(result.composedFrom)).toBe(true)
})
})
Loading
Loading