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
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