From d14b5d45c5604c33654f3a43e17fe9a5ca914390 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 15:24:35 +0300 Subject: [PATCH 1/2] Add variable composition templates Port the Python SDK variable composition work into the JS SDK: managed variable references can compose with @{...}@ syntax, template variables render runtime Handlebars inputs, and validation/push paths understand template input schemas. Also adds focused composition/template validation tests, docs, the Node example, and a changeset so the public API change is tracked. --- .changeset/variable-composition-templates.md | 7 + docs/managed-variables.md | 25 + examples/node/variables.ts | 61 ++- packages/logfire-api/README.md | 17 + packages/logfire-api/package.json | 1 + packages/logfire-api/src/vars.test.ts | 3 +- .../logfire-api/src/vars/composition.test.ts | 205 +++++++++ packages/logfire-api/src/vars/composition.ts | 366 +++++++++++++++ packages/logfire-api/src/vars/errors.ts | 15 + packages/logfire-api/src/vars/index.ts | 282 +++++++++++- .../src/vars/instrumentation.test.ts | 66 +++ .../src/vars/referenceSyntax.test.ts | 22 + .../logfire-api/src/vars/referenceSyntax.ts | 25 + .../logfire-api/src/vars/template.test.ts | 146 ++++++ packages/logfire-api/src/vars/template.ts | 58 +++ .../src/vars/templateValidation.test.ts | 151 ++++++ .../src/vars/templateValidation.ts | 229 +++++++++ packages/logfire-api/vite.config.ts | 2 +- plans/002-variable-composition-templates.md | 433 ++++++++++++++++++ pnpm-lock.yaml | 49 ++ pnpm-workspace.yaml | 1 + 21 files changed, 2138 insertions(+), 26 deletions(-) create mode 100644 .changeset/variable-composition-templates.md create mode 100644 packages/logfire-api/src/vars/composition.test.ts create mode 100644 packages/logfire-api/src/vars/composition.ts create mode 100644 packages/logfire-api/src/vars/errors.ts create mode 100644 packages/logfire-api/src/vars/instrumentation.test.ts create mode 100644 packages/logfire-api/src/vars/referenceSyntax.test.ts create mode 100644 packages/logfire-api/src/vars/referenceSyntax.ts create mode 100644 packages/logfire-api/src/vars/template.test.ts create mode 100644 packages/logfire-api/src/vars/template.ts create mode 100644 packages/logfire-api/src/vars/templateValidation.test.ts create mode 100644 packages/logfire-api/src/vars/templateValidation.ts create mode 100644 plans/002-variable-composition-templates.md diff --git a/.changeset/variable-composition-templates.md b/.changeset/variable-composition-templates.md new file mode 100644 index 0000000..6530ced --- /dev/null +++ b/.changeset/variable-composition-templates.md @@ -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. diff --git a/docs/managed-variables.md b/docs/managed-variables.md index 7aa2612..13e57b6 100644 --- a/docs/managed-variables.md +++ b/docs/managed-variables.md @@ -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('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: diff --git a/examples/node/variables.ts b/examples/node/variables.ts index a41ab76..b1c2761 100644 --- a/examples/node/variables.ts +++ b/examples/node/variables.ts @@ -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 = { @@ -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 }, @@ -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('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 } }) @@ -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, { @@ -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() diff --git a/packages/logfire-api/README.md b/packages/logfire-api/README.md index 5b0855e..be4fb44 100644 --- a/packages/logfire-api/README.md +++ b/packages/logfire-api/README.md @@ -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('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. diff --git a/packages/logfire-api/package.json b/packages/logfire-api/package.json index 123680b..93d89c4 100644 --- a/packages/logfire-api/package.json +++ b/packages/logfire-api/package.json @@ -75,6 +75,7 @@ "test": "vp test" }, "dependencies": { + "handlebars": "catalog:", "js-yaml": "catalog:", "p-retry": "catalog:", "zod": "catalog:" diff --git a/packages/logfire-api/src/vars.test.ts b/packages/logfire-api/src/vars.test.ts index 34cc84e..a2cba24 100644 --- a/packages/logfire-api/src/vars.test.ts +++ b/packages/logfire-api/src/vars.test.ts @@ -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' }) }) @@ -236,6 +236,7 @@ describe('managed variables', () => { name: 'feature', overrides: [], rollout: { labels: {} }, + template_inputs_schema: null, type_name: null, }, }, diff --git a/packages/logfire-api/src/vars/composition.test.ts b/packages/logfire-api/src/vars/composition.test.ts new file mode 100644 index 0000000..da769e3 --- /dev/null +++ b/packages/logfire-api/src/vars/composition.test.ts @@ -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 => ({ + reason: 'resolved', + value: JSON.stringify(value), + ...extra, +}) + +const missing = (name: string): ResolvedReference => ({ name, reason: 'unrecognized_variable', value: undefined }) + +const resolver = + (values: Record) => + (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(''), + }) + 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 { and }') })) + ).resolves.toMatchObject({ + serializedValue: JSON.stringify('literal { and }'), + }) + 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 = {} + 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) + }) +}) diff --git a/packages/logfire-api/src/vars/composition.ts b/packages/logfire-api/src/vars/composition.ts new file mode 100644 index 0000000..4fbf392 --- /dev/null +++ b/packages/logfire-api/src/vars/composition.ts @@ -0,0 +1,366 @@ +import { VariableCompositionCycleError, VariableCompositionDepthError, VariableCompositionError } from './errors' +import { BLOCK_REF, HAS_REFERENCE, REFERENCE_TAG, SIMPLE_REF, renderOnce } from './referenceSyntax' +import type { VariableResolutionReason } from './index' + +export const MAX_COMPOSITION_DEPTH = 20 +export const HBS_KEYWORDS: ReadonlySet = new Set(['else', 'this']) +const BLOCK_WITH_BODY_REF = /(? Promise | ResolvedReference + +export interface ExpandReferencesOptions { + rootName?: string +} + +export interface ExpandReferencesResult { + composedFrom: ComposedReference[] + serializedValue: string +} + +interface ExpandedValue { + composedFrom: ComposedReference[] + value: unknown +} + +interface Range { + end: number + start: number +} + +export function hasReferences(value: unknown): boolean { + if (typeof value === 'string') { + return HAS_REFERENCE.test(value) + } + if (Array.isArray(value)) { + return value.some(hasReferences) + } + if (isRecord(value)) { + return Object.values(value).some(hasReferences) + } + return false +} + +export function findReferences(value: unknown): string[] { + const references: string[] = [] + const seen = new Set() + collectReferences(value, references, seen) + return references +} + +export async function expandReferences( + serializedValue: string, + resolveReference: ResolveReference, + options: ExpandReferencesOptions = {} +): Promise { + let value: unknown + try { + value = JSON.parse(serializedValue) + } catch { + return { composedFrom: [], serializedValue } + } + + const expanded = await expandValue(value, resolveReference, options.rootName === undefined ? [] : [options.rootName], 0) + return { + composedFrom: dedupeComposedReferences(expanded.composedFrom), + serializedValue: JSON.stringify(expanded.value), + } +} + +export function hasFatalCompositionError(composedFrom: ComposedReference[]): boolean { + return composedFrom.some( + (item) => + item.error?.includes('VariableCompositionCycleError') === true || + item.error?.includes('VariableCompositionDepthError') === true || + (item.composedFrom !== undefined && hasFatalCompositionError(item.composedFrom)) + ) +} + +function collectReferences(value: unknown, references: string[], seen: Set): void { + if (typeof value === 'string') { + for (const reference of findReferencesInString(value)) { + if (!seen.has(reference)) { + seen.add(reference) + references.push(reference) + } + } + return + } + if (Array.isArray(value)) { + for (const item of value) { + collectReferences(item, references, seen) + } + return + } + if (isRecord(value)) { + for (const item of Object.values(value)) { + collectReferences(item, references, seen) + } + } +} + +function findReferencesInString(value: string): string[] { + const references: string[] = [] + const seen = new Set() + collectRegexReferences(value, SIMPLE_REF, references, seen) + collectRegexReferences(value, BLOCK_REF, references, seen) + return references +} + +function collectRegexReferences(value: string, regex: RegExp, references: string[], seen: Set): void { + regex.lastIndex = 0 + for (const match of value.matchAll(regex)) { + const path = match[1] + if (path === undefined) { + continue + } + const name = path.split('.')[0] + if (name === undefined || HBS_KEYWORDS.has(name) || seen.has(name)) { + continue + } + seen.add(name) + references.push(name) + } +} + +async function expandValue(value: unknown, resolveReference: ResolveReference, stack: string[], depth: number): Promise { + if (typeof value === 'string') { + return expandString(value, resolveReference, stack, depth) + } + if (Array.isArray(value)) { + const expandedItems = await Promise.all( + value.map(async (item) => { + const expanded = await expandValue(item, resolveReference, stack, depth) + return expanded + }) + ) + return { + composedFrom: expandedItems.flatMap((expanded) => expanded.composedFrom), + value: expandedItems.map((expanded) => expanded.value), + } + } + if (isRecord(value)) { + const expandedEntries = await Promise.all( + Object.entries(value).map(async ([key, item]) => [key, await expandValue(item, resolveReference, stack, depth)] as const) + ) + const entries = expandedEntries.map(([key, expanded]) => [key, expanded.value] as const) + const composedFrom = expandedEntries.flatMap(([, expanded]) => expanded.composedFrom) + return { composedFrom, value: Object.fromEntries(entries) } + } + return { composedFrom: [], value } +} + +async function expandString(value: string, resolveReference: ResolveReference, stack: string[], depth: number): Promise { + const referenceNames = findReferencesInString(value) + if (referenceNames.length === 0) { + return { composedFrom: [], value: value.includes('\\@{') ? renderOnce(value, {}) : value } + } + + const context: Record = {} + const resolvedReferences = await Promise.all( + referenceNames.map(async (name) => { + const expanded = await expandNamedReference(name, resolveReference, stack, depth) + return expanded + }) + ) + const composedFrom: ComposedReference[] = [] + for (const resolved of resolvedReferences) { + composedFrom.push(resolved.reference) + if (resolved.value !== undefined) { + context[resolved.reference.name] = resolved.value + } + } + + const protectedValue = protectUnresolvedReferences(value, context) + try { + return { + composedFrom, + value: restoreProtected(renderOnce(protectedValue.template, context), protectedValue.sentinel), + } + } catch (error) { + throw new VariableCompositionError(`Failed to render composed variable: ${formatError(error)}`) + } +} + +async function expandNamedReference( + name: string, + resolveReference: ResolveReference, + stack: string[], + depth: number +): Promise<{ reference: ComposedReference; value?: unknown }> { + if (stack.includes(name)) { + const error = new VariableCompositionCycleError(`Circular variable reference: ${[...stack, name].join(' -> ')}`) + return { + reference: { error: formatCompositionError(error), name, reason: 'other_error' }, + value: undefined, + } + } + if (depth >= MAX_COMPOSITION_DEPTH) { + const error = new VariableCompositionDepthError(`Variable composition exceeded maximum depth of ${String(MAX_COMPOSITION_DEPTH)}`) + return { + reference: { error: formatCompositionError(error), name, reason: 'other_error' }, + value: undefined, + } + } + + const resolved = await resolveReference(name) + const reference: ComposedReference = { + name, + reason: resolved.reason, + } + if (resolved.label !== undefined) { + reference.label = resolved.label + } + if (resolved.version !== undefined) { + reference.version = resolved.version + } + + if (resolved.value === undefined) { + return { reference, value: undefined } + } + + const nested = await expandReferenceSerializedValue(name, resolved.value, resolveReference, stack, depth) + if (nested.error !== undefined) { + reference.error = nested.error + return { reference, value: undefined } + } + reference.value = nested.serializedValue + if (nested.composedFrom.length > 0) { + reference.composedFrom = nested.composedFrom + } + return { reference, value: nested.value } +} + +async function expandReferenceSerializedValue( + name: string, + serializedValue: string, + resolveReference: ResolveReference, + stack: string[], + depth: number +): Promise<{ composedFrom: ComposedReference[]; error?: string; serializedValue: string; value?: unknown }> { + let parsed: unknown + try { + parsed = JSON.parse(serializedValue) + } catch (error) { + return { + composedFrom: [], + error: `Referenced variable '${name}' resolved to non-JSON value: ${formatError(error)}`, + serializedValue, + } + } + + if (!HAS_REFERENCE.test(serializedValue)) { + return { composedFrom: [], serializedValue, value: parsed } + } + + const expanded = await expandValue(parsed, resolveReference, [...stack, name], depth + 1) + const expandedSerializedValue = JSON.stringify(expanded.value) + return { composedFrom: dedupeComposedReferences(expanded.composedFrom), serializedValue: expandedSerializedValue, value: expanded.value } +} + +function protectUnresolvedReferences(value: string, context: Record): { sentinel: string; template: string } { + const sentinel = `LOGFIRE_UNRESOLVED_REFERENCE_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}_LOGFIRE` + const protectedBlocks = value.replace(BLOCK_WITH_BODY_REF, (match: string, _helper: string, name: string) => { + return Object.hasOwn(context, name) && !HBS_KEYWORDS.has(name) ? match : encodeProtected(match, sentinel) + }) + const resolvedBlockRanges = collectResolvedBlockRanges(protectedBlocks, context) + return { + sentinel, + template: protectedBlocks.replace(REFERENCE_TAG, (match, expression, offset) => { + const baseName = getExpressionBaseName(String(expression)) + if (baseName === undefined) { + return match + } + if (HBS_KEYWORDS.has(baseName)) { + return isOffsetInRanges(Number(offset), resolvedBlockRanges) ? match : encodeProtected(match, sentinel) + } + return !Object.hasOwn(context, baseName) ? encodeProtected(match, sentinel) : match + }), + } +} + +function collectResolvedBlockRanges(value: string, context: Record): Range[] { + const ranges: Range[] = [] + BLOCK_WITH_BODY_REF.lastIndex = 0 + for (const match of value.matchAll(BLOCK_WITH_BODY_REF)) { + const name = match[2] + if (name !== undefined && Object.hasOwn(context, name) && !HBS_KEYWORDS.has(name)) { + ranges.push({ end: match.index + match[0].length, start: match.index }) + } + } + return ranges +} + +function isOffsetInRanges(offset: number, ranges: Range[]): boolean { + return ranges.some((range) => offset >= range.start && offset < range.end) +} + +function encodeProtected(value: string, sentinel: string): string { + return sentinel + Array.from(value, (char) => (char.codePointAt(0) ?? 0).toString(16).padStart(6, '0')).join('') + sentinel +} + +function restoreProtected(value: string, sentinel: string): string { + return value.replaceAll(new RegExp(`${escapeRegExp(sentinel)}([0-9a-f]+)${escapeRegExp(sentinel)}`, 'g'), (_match, hex) => { + const chunks = String(hex).match(/.{1,6}/g) ?? [] + return chunks.map((chunk) => String.fromCodePoint(Number.parseInt(chunk, 16))).join('') + }) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getExpressionBaseName(expression: string): string | undefined { + const trimmed = expression.trim() + if (trimmed === '' || trimmed === 'else' || trimmed.startsWith('/')) { + return undefined + } + const blockMatch = /^#\w+\s+([a-zA-Z_][a-zA-Z0-9_]*)/.exec(trimmed) + if (blockMatch?.[1] !== undefined) { + return blockMatch[1] + } + const simpleMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)(?:\.|$)/.exec(trimmed) + return simpleMatch?.[1] +} + +function dedupeComposedReferences(references: ComposedReference[]): ComposedReference[] { + const deduped: ComposedReference[] = [] + const seen = new Set() + for (const reference of references) { + const key = JSON.stringify(reference) + if (!seen.has(key)) { + seen.add(key) + deduped.push(reference) + } + } + return deduped +} + +function formatCompositionError(error: Error): string { + return `${error.name}: ${error.message}` +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/logfire-api/src/vars/errors.ts b/packages/logfire-api/src/vars/errors.ts new file mode 100644 index 0000000..4288559 --- /dev/null +++ b/packages/logfire-api/src/vars/errors.ts @@ -0,0 +1,15 @@ +export class VariableCompositionError extends Error { + override name: string = 'VariableCompositionError' +} + +export class VariableCompositionCycleError extends VariableCompositionError { + override name: string = 'VariableCompositionCycleError' +} + +export class VariableCompositionDepthError extends VariableCompositionError { + override name: string = 'VariableCompositionDepthError' +} + +export class VariableRenderError extends Error { + override name: string = 'VariableRenderError' +} diff --git a/packages/logfire-api/src/vars/index.ts b/packages/logfire-api/src/vars/index.ts index 56e9417..090d715 100644 --- a/packages/logfire-api/src/vars/index.ts +++ b/packages/logfire-api/src/vars/index.ts @@ -2,6 +2,16 @@ import { context as ContextAPI, createContextKey, propagation, trace as TraceAPI import { murmurhash3x64128 } from '../murmurhash' import { startSpan } from '../index' +import { expandReferences, hasFatalCompositionError } from './composition' +import type { ComposedReference, ResolvedReference } from './composition' +import { VariableCompositionError, VariableRenderError } from './errors' +import { renderSerializedTemplate } from './template' +import { collectReferenceValidationIssues, validateTemplateInputs } from './templateValidation' +import type { ReferenceValidationIssue, TemplateInputValidationIssue } from './templateValidation' + +export { findReferences, hasReferences, MAX_COMPOSITION_DEPTH, type ComposedReference } from './composition' +export { VariableCompositionCycleError, VariableCompositionDepthError, VariableCompositionError, VariableRenderError } from './errors' +export { renderOnce } from './referenceSyntax' export type JsonSchema = Record @@ -18,12 +28,14 @@ export interface VariableOptions { codec?: VariableCodec default: ResolveFunction | T description?: string + templateInputsSchema?: JsonSchema } export interface VariableDefinition { codec: Pick, 'parse'> description: string | undefined name: string + templateInputsSchema?: JsonSchema toConfig(): VariableConfig } @@ -38,10 +50,13 @@ export interface VariableGetOptions { } export interface ResolvedVariableInit { + composedFrom?: ComposedReference[] + deserializer?: (serializedValue: string) => T exception?: unknown label?: string name: string reason: VariableResolutionReason + serializedValue?: string value: T version?: number } @@ -56,22 +71,35 @@ export type VariableResolutionReason = | 'validation_error' export class ResolvedVariable { + composedFrom: ComposedReference[] + private readonly deserializer: ((serializedValue: string) => T) | undefined exception: unknown label: string | undefined name: string reason: VariableResolutionReason + serializedValue: string | undefined value: T version: number | undefined constructor(init: ResolvedVariableInit) { + this.composedFrom = init.composedFrom ?? [] + this.deserializer = init.deserializer this.exception = init.exception this.label = init.label this.name = init.name this.reason = init.reason + this.serializedValue = init.serializedValue this.value = init.value this.version = init.version } + render(inputs: Record = {}): T { + if (this.serializedValue === undefined || this.deserializer === undefined) { + throw new VariableRenderError(`Resolved variable '${this.name}' does not have a serialized value to render`) + } + return this.deserializer(renderSerializedTemplate(this.serializedValue, inputs)) + } + async withContext(callback: () => Promise | R): Promise { const active = ContextAPI.active() const baggage = propagation.getBaggage(active) ?? propagation.createBaggage() @@ -180,6 +208,7 @@ export interface VariableConfig { name: string overrides: RolloutOverride[] rollout: Rollout + template_inputs_schema?: JsonSchema | null type_name?: string | null } @@ -210,6 +239,8 @@ export interface ValidationReport { descriptionDifferences: DescriptionDifference[] errors: LabelValidationError[] isValid: boolean + referenceWarnings: ReferenceValidationIssue[] + templateInputWarnings: TemplateInputValidationIssue[] variablesChecked: number variablesNotOnServer: string[] } @@ -721,6 +752,7 @@ export class Variable { defaultValue: ResolveFunction | T description: string | undefined name: string + templateInputsSchema?: JsonSchema constructor(name: string, options: VariableOptions) { validateVariableName(name) @@ -728,6 +760,9 @@ export class Variable { this.description = options.description this.defaultValue = options.default this.codec = options.codec ?? inferCodec(options.default) + if (options.templateInputsSchema !== undefined) { + this.templateInputsSchema = options.templateInputsSchema + } } async get(options: VariableGetOptions = {}): Promise> { @@ -773,6 +808,9 @@ export class Variable { if (result.version !== undefined) { span.setAttribute('version', result.version) } + if (result.composedFrom.length > 0) { + span.setAttribute('composed_from', JSON.stringify(result.composedFrom)) + } try { span.setAttribute('value', serializeWithCodec(this.codec, result.value)) } catch { @@ -817,11 +855,62 @@ export class Variable { return await resolvedWithDefault(this, serialized, targetingKey, attributes) } + let serializedValue = serialized.value + let composedFrom: ComposedReference[] = [] + if (serializedValue.includes('@{')) { + try { + const expanded = await expandReferences( + serializedValue, + async (name): Promise => { + const resolved = await provider.getSerializedValue(name, targetingKey, attributes) + return serializedResolvedToReference(resolved) + }, + { rootName: this.name } + ) + serializedValue = expanded.serializedValue + composedFrom = expanded.composedFrom + if (hasFatalCompositionError(composedFrom)) { + const init: ResolvedVariableInit = { + composedFrom, + exception: new VariableCompositionError(`Failed to compose variable '${this.name}'`), + name: this.name, + reason: 'other_error', + value: await resolveMaybeFunction(this.defaultValue, targetingKey, attributes), + } + if (serialized.label !== undefined) { + init.label = serialized.label + } + if (serialized.version !== undefined) { + init.version = serialized.version + } + return new ResolvedVariable(init) + } + } catch (error) { + const init: ResolvedVariableInit = { + exception: error, + name: this.name, + reason: 'other_error', + value: await resolveMaybeFunction(this.defaultValue, targetingKey, attributes), + } + if (serialized.label !== undefined) { + init.label = serialized.label + } + if (serialized.version !== undefined) { + init.version = serialized.version + } + return new ResolvedVariable(init) + } + } + try { - const parsed = this.codec.parse(JSON.parse(serialized.value)) + const deserializer = (value: string): T => this.codec.parse(JSON.parse(value)) + const parsed = deserializer(serializedValue) const init: ResolvedVariableInit = { + composedFrom, + deserializer, name: serialized.name, reason: 'resolved', + serializedValue, value: parsed, } if (serialized.label !== undefined) { @@ -832,12 +921,19 @@ export class Variable { } return new ResolvedVariable(init) } catch (error) { - return new ResolvedVariable({ + const init: ResolvedVariableInit = { exception: error, name: this.name, reason: isSyntaxOrValidationError(error) ? 'validation_error' : 'other_error', value: await resolveMaybeFunction(this.defaultValue, targetingKey, attributes), - }) + } + if (serialized.label !== undefined) { + init.label = serialized.label + } + if (serialized.version !== undefined) { + init.version = serialized.version + } + return new ResolvedVariable(init) } } catch (error) { return new ResolvedVariable({ @@ -850,6 +946,54 @@ export class Variable { } } +export type TemplateVariableOptions> = + InputsT extends Record ? VariableOptions : never + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- InputsT provides the typed input contract for get(). +export class TemplateVariable> extends Variable { + override async get(inputs: InputsT, options: VariableGetOptions = {}): Promise> { + const resolved = await super.get(options) + try { + const serializedValue = resolved.serializedValue ?? serializeWithCodec(this.codec, resolved.value) + const renderedSerializedValue = renderSerializedTemplate(serializedValue, inputs) + const renderedValue = this.codec.parse(JSON.parse(renderedSerializedValue)) + const init: ResolvedVariableInit = { + composedFrom: resolved.composedFrom, + deserializer: (value) => this.codec.parse(JSON.parse(value)), + name: resolved.name, + reason: resolved.reason, + serializedValue: renderedSerializedValue, + value: renderedValue, + } + if (resolved.exception !== undefined) { + init.exception = resolved.exception + } + if (resolved.label !== undefined) { + init.label = resolved.label + } + if (resolved.version !== undefined) { + init.version = resolved.version + } + return new ResolvedVariable(init) + } catch (error) { + const init: ResolvedVariableInit = { + composedFrom: resolved.composedFrom, + exception: toVariableRenderError(error, this.name), + name: resolved.name, + reason: 'other_error', + value: await resolveDefaultForVariable(this, options), + } + if (resolved.label !== undefined) { + init.label = resolved.label + } + if (resolved.version !== undefined) { + init.version = resolved.version + } + return new ResolvedVariable(init) + } + } +} + const registeredVariables = new Map() interface VariableRuntimeState { @@ -877,15 +1021,26 @@ const runtimeState: VariableRuntimeState = { } export function defineVar(name: string, options: VariableOptions): Variable { + return registerVariable(name, new Variable(name, options)) +} + +export function defineTemplateVar = Record>( + name: string, + options: TemplateVariableOptions +): TemplateVariable { + return registerVariable(name, new TemplateVariable(name, options)) +} + +function registerVariable(name: string, variable: T): T { if (registeredVariables.has(name)) { throw new Error(`A variable with name '${name}' has already been registered`) } - const variable = new Variable(name, options) registeredVariables.set(name, variable) return variable } export { defineVar as var } +export { defineTemplateVar as templateVar } export function variablesClear(): void { registeredVariables.clear() @@ -907,7 +1062,7 @@ export async function variablesValidate(variables: VariableDefinition[] = variab const provider = getVariableProvider() await provider.refresh?.(true) const config = (await provider.getAllVariablesConfig?.()) ?? { variables: {} } - return validateVariablesAgainstConfig(variables, config) + return await validateVariablesAgainstConfig(variables, config) } export async function variablesPush( @@ -917,9 +1072,11 @@ export async function variablesPush( const provider = getWritableProvider() await provider.refresh?.(true) const serverConfig = (await provider.getAllVariablesConfig?.()) ?? { variables: {} } - const report = validateVariablesAgainstConfig(variables, serverConfig) + const report = await validateVariablesAgainstConfig(variables, serverConfig) if (options.strict === true && !report.isValid) { - throw new VariableWriteError('Cannot push variables: provider values are incompatible with local variable codecs') + throw new VariableWriteError( + 'Cannot push variables: provider values are incompatible with local variable codecs, references, or template input schemas' + ) } const updates: Record = {} @@ -937,6 +1094,7 @@ export async function variablesPush( description: local.description ?? null, example: local.example ?? null, json_schema: local.json_schema ?? null, + template_inputs_schema: local.template_inputs_schema ?? null, type_name: local.type_name ?? null, } if (JSON.stringify(merged) !== JSON.stringify(existing)) { @@ -1112,8 +1270,10 @@ export async function targetingContext( return withTargetingContext(next, callback) } -function validateVariablesAgainstConfig(variables: VariableDefinition[], config: VariablesConfig): ValidationReport { +async function validateVariablesAgainstConfig(variables: VariableDefinition[], config: VariablesConfig): Promise { const errors: LabelValidationError[] = [] + const referenceWarnings: ReferenceValidationIssue[] = [] + const templateInputWarnings: TemplateInputValidationIssue[] = [] const variablesNotOnServer: string[] = [] const descriptionDifferences: DescriptionDifference[] = [] for (const variable of variables) { @@ -1135,29 +1295,69 @@ function validateVariablesAgainstConfig(variables: VariableDefinition[], config: if (!isLabeledValue(labeled)) { continue } - try { - variable.codec.parse(JSON.parse(labeled.serialized_value)) - } catch (error) { - errors.push({ error, label, variableName: variable.name }) - } + // eslint-disable-next-line no-await-in-loop -- validation warnings follow provider label order. + await validateSerializedVariableValue( + variable, + labeled.serialized_value, + label, + config, + errors, + referenceWarnings, + templateInputWarnings + ) } if (serverVariable.latest_version !== null && serverVariable.latest_version !== undefined) { - try { - variable.codec.parse(JSON.parse(serverVariable.latest_version.serialized_value)) - } catch (error) { - errors.push({ error, label: 'latest', variableName: variable.name }) - } + // eslint-disable-next-line no-await-in-loop -- validation warnings follow provider variable order. + await validateSerializedVariableValue( + variable, + serverVariable.latest_version.serialized_value, + 'latest', + config, + errors, + referenceWarnings, + templateInputWarnings + ) } } return { descriptionDifferences, errors, - isValid: errors.length === 0 && variablesNotOnServer.length === 0, + isValid: + errors.length === 0 && variablesNotOnServer.length === 0 && referenceWarnings.length === 0 && templateInputWarnings.length === 0, + referenceWarnings: dedupeByJson(referenceWarnings), + templateInputWarnings: dedupeByJson(templateInputWarnings), variablesChecked: variables.length, variablesNotOnServer, } } +async function validateSerializedVariableValue( + variable: VariableDefinition, + serializedValue: string, + label: string | undefined, + config: VariablesConfig, + errors: LabelValidationError[], + referenceWarnings: ReferenceValidationIssue[], + templateInputWarnings: TemplateInputValidationIssue[] +): Promise { + let valueToParse = serializedValue + if (serializedValue.includes('@{')) { + const expanded = await expandReferences( + serializedValue, + (name) => serializedResolvedToReference(resolveSerializedValue(config, name)), + { rootName: variable.name } + ) + valueToParse = expanded.serializedValue + referenceWarnings.push(...collectReferenceValidationIssues(variable.name, label, expanded.composedFrom)) + } + try { + variable.codec.parse(JSON.parse(valueToParse)) + } catch (error) { + errors.push({ error, label, variableName: variable.name }) + } + templateInputWarnings.push(...validateTemplateInputs(valueToParse, variable.templateInputsSchema, variable.name, label)) +} + function getWritableProvider(): Required> & VariableProvider { const provider = getVariableProvider() if (typeof provider.batchUpdate !== 'function') { @@ -1181,6 +1381,21 @@ async function getSerializedValueForLabel( return resolveVariableConfigForLabel(config, label) } +function serializedResolvedToReference(serialized: SerializedResolvedVariable): ResolvedReference { + const reference: ResolvedReference = { + name: serialized.name, + reason: serialized.reason, + value: serialized.value, + } + if (serialized.label !== undefined) { + reference.label = serialized.label + } + if (serialized.version !== undefined) { + reference.version = serialized.version + } + return reference +} + async function resolvedWithDefault( variable: Variable, serialized: SerializedResolvedVariable, @@ -1215,6 +1430,19 @@ async function resolveMaybeFunction( return value } +async function resolveDefaultForVariable(variable: Variable, options: VariableGetOptions): Promise { + const attributes = getMergedAttributes(options.attributes) + const targetingKey = options.targetingKey ?? getContextTargetingKey(variable.name) ?? getActiveTraceTargetingKey() + return await resolveMaybeFunction(variable.defaultValue, targetingKey, attributes) +} + +function toVariableRenderError(error: unknown, variableName: string): VariableRenderError { + if (error instanceof VariableRenderError) { + return error + } + return new VariableRenderError(`Failed to render variable '${variableName}': ${formatUnknown(error)}`) +} + function inferCodec(defaultValue: ResolveFunction | T): VariableCodec { if (typeof defaultValue === 'function') { throw new TypeError('Variables with function defaults require an explicit codec') @@ -1299,6 +1527,7 @@ function variableToConfig(variable: Variable): VariableConfig { name: variable.name, overrides: [], rollout: { labels: {} }, + template_inputs_schema: variable.templateInputsSchema ?? null, type_name: variable.codec.typeName ?? null, } } @@ -1516,6 +1745,7 @@ function normalizeVariableConfig(data: unknown): VariableConfig { name, overrides, rollout, + template_inputs_schema: isRecord(data['template_inputs_schema']) ? data['template_inputs_schema'] : null, type_name: optionalString(data['type_name']), } const aliases = data['aliases'] @@ -1684,6 +1914,7 @@ function configToApiBody(config: VariableConfig): Record { rollout: { labels: override.rollout.labels }, })), rollout: { labels: config.rollout.labels }, + template_inputs_schema: config.template_inputs_schema ?? null, } if (Object.keys(config.labels).length > 0) { body['labels'] = Object.fromEntries(Object.entries(config.labels).map(([label, value]) => [label, labelToApiData(value)])) @@ -1869,6 +2100,19 @@ function withoutKey(record: Record, keyToRemove: string): Record key !== keyToRemove)) } +function dedupeByJson(values: T[]): T[] { + const deduped: T[] = [] + const seen = new Set() + for (const value of values) { + const key = JSON.stringify(value) + if (!seen.has(key)) { + seen.add(key) + deduped.push(value) + } + } + return deduped +} + function assertNever(value: never): never { throw new Error(`Unexpected value: ${formatUnknown(value)}`) } diff --git a/packages/logfire-api/src/vars/instrumentation.test.ts b/packages/logfire-api/src/vars/instrumentation.test.ts new file mode 100644 index 0000000..a1bfee5 --- /dev/null +++ b/packages/logfire-api/src/vars/instrumentation.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { configureVariables, defineVar, getVariableProvider, variablesClear } from './index' +import type { VariablesConfig } from './index' + +const { spanMock, startSpanMock } = vi.hoisted(() => { + const spanMock = { + end: vi.fn<() => void>(), + recordException: vi.fn<() => void>(), + setAttribute: vi.fn<(_name: string, _value: unknown) => void>(), + } + return { + spanMock, + startSpanMock: vi.fn<() => typeof spanMock>(() => spanMock), + } +}) + +vi.mock('../index', () => ({ + startSpan: startSpanMock, +})) + +const config = (variables: VariablesConfig['variables']): VariablesConfig => ({ variables }) + +describe('variable composition instrumentation', () => { + beforeEach(() => { + vi.clearAllMocks() + variablesClear() + configureVariables(false) + }) + + afterEach(async () => { + await getVariableProvider().shutdown?.() + variablesClear() + configureVariables(false) + }) + + it('records composed references on variable resolution spans', async () => { + configureVariables({ + config: config({ + greeting: { + labels: { prod: { serialized_value: JSON.stringify('Hello'), version: 1 } }, + name: 'greeting', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + prompt: { + labels: { prod: { serialized_value: JSON.stringify('@{greeting}@ there'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + }) + const prompt = defineVar('prompt', { default: '' }) + + const resolved = await prompt.get() + + expect(resolved.value).toBe('Hello there') + expect(startSpanMock).toHaveBeenCalledWith('Resolve variable prompt', { + attributes: {}, + name: 'prompt', + targeting_key: undefined, + }) + expect(spanMock.setAttribute).toHaveBeenCalledWith('composed_from', JSON.stringify(resolved.composedFrom)) + }) +}) diff --git a/packages/logfire-api/src/vars/referenceSyntax.test.ts b/packages/logfire-api/src/vars/referenceSyntax.test.ts new file mode 100644 index 0000000..0e99ff8 --- /dev/null +++ b/packages/logfire-api/src/vars/referenceSyntax.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { renderOnce } from './referenceSyntax' + +describe('variable reference syntax', () => { + it('renders @{} references while preserving runtime placeholders', () => { + expect(renderOnce('Hello @{name}@, keep {{runtime}}', { name: 'Ada' })).toBe('Hello Ada, keep {{runtime}}') + }) + + it('turns escaped references into literal references', () => { + expect(renderOnce('\\@{name}@ and @{name}@', { name: 'Ada' })).toBe('@{name}@ and Ada') + }) + + it('supports block helpers', () => { + expect(renderOnce('@{#if enabled}@on@{else}@off@{/if}@', { enabled: true })).toBe('on') + expect(renderOnce('@{#if enabled}@on@{else}@off@{/if}@', { enabled: false })).toBe('off') + }) + + it('does not HTML-escape safe context string leaves', () => { + expect(renderOnce('@{name}@', { name: '' })).toBe('') + }) +}) diff --git a/packages/logfire-api/src/vars/referenceSyntax.ts b/packages/logfire-api/src/vars/referenceSyntax.ts new file mode 100644 index 0000000..666cfb7 --- /dev/null +++ b/packages/logfire-api/src/vars/referenceSyntax.ts @@ -0,0 +1,25 @@ +import Handlebars from 'handlebars' + +import { createSafeHandlebarsContext } from './template' + +export const HAS_REFERENCE: RegExp = /(?): string { + const unique = `${Date.now().toString(36)}-${(sentinelCounter++).toString(36)}` + const leftRuntimePlaceholder = `LOGFIRE_LEFT_RUNTIME_PLACEHOLDER_${unique}_LOGFIRE` + const rightRuntimePlaceholder = `LOGFIRE_RIGHT_RUNTIME_PLACEHOLDER_${unique}_LOGFIRE` + const escapedReferenceStart = `LOGFIRE_ESCAPED_REFERENCE_START_${unique}_LOGFIRE` + + const protectedTemplate = template + .replaceAll('\\@{', escapedReferenceStart) + .replaceAll('{{', leftRuntimePlaceholder) + .replaceAll('}}', rightRuntimePlaceholder) + const handlebarsTemplate = protectedTemplate.replace(REFERENCE_TAG, '{{$1}}') + const rendered = Handlebars.compile(handlebarsTemplate)(createSafeHandlebarsContext(context)) + return rendered.replaceAll(leftRuntimePlaceholder, '{{').replaceAll(rightRuntimePlaceholder, '}}').replaceAll(escapedReferenceStart, '@{') +} diff --git a/packages/logfire-api/src/vars/template.test.ts b/packages/logfire-api/src/vars/template.test.ts new file mode 100644 index 0000000..6a5bb67 --- /dev/null +++ b/packages/logfire-api/src/vars/template.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + configureVariables, + defineTemplateVar, + defineVar, + getVariableProvider, + ResolvedVariable, + VariableRenderError, + variablesClear, +} from './index' +import type { VariablesConfig } from './index' + +const config = (variables: VariablesConfig['variables']): VariablesConfig => ({ variables }) + +describe('variable template rendering', () => { + beforeEach(() => { + variablesClear() + configureVariables(false) + }) + + afterEach(async () => { + await getVariableProvider().shutdown?.() + variablesClear() + configureVariables(false) + }) + + it('renders resolved provider values through ResolvedVariable.render()', async () => { + configureVariables({ + config: config({ + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Hello {{name}}'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineVar('prompt', { default: 'Hello' }) + + const resolved = await prompt.get() + + expect(resolved.render({ name: 'Ada' })).toBe('Hello Ada') + }) + + it('renders string leaves in objects and arrays without HTML escaping inputs', async () => { + configureVariables({ + config: config({ + prompt_config: { + labels: { prod: { serialized_value: JSON.stringify({ list: ['{{name}}'], text: 'Hi {{name}}' }), version: 1 } }, + name: 'prompt_config', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const promptConfig = defineVar('prompt_config', { default: { list: [''], text: '' } }) + + const resolved = await promptConfig.get() + + expect(resolved.render({ name: '' })).toEqual({ list: [''], text: 'Hi ' }) + }) + + it('throws VariableRenderError when no serialized value is available', () => { + const resolved = new ResolvedVariable({ name: 'local_only', reason: 'context_override', value: 'value' }) + + expect(() => resolved.render({})).toThrow(VariableRenderError) + }) + + it('defineTemplateVar composes, renders, and parses in one get call', async () => { + configureVariables({ + config: config({ + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Write @{tone}@ to {{user.name}}'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + tone: { + labels: { prod: { serialized_value: JSON.stringify('kindly'), version: 1 } }, + name: 'tone', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineTemplateVar('prompt', { + default: 'Hello {{user.name}}', + templateInputsSchema: { + properties: { user: { properties: { name: { type: 'string' } }, type: 'object' } }, + type: 'object', + }, + }) + + const resolved = await prompt.get({ user: { name: 'Ada' } }) + + expect(resolved.value).toBe('Write kindly to Ada') + expect(resolved.composedFrom).toMatchObject([{ name: 'tone' }]) + }) + + it('defineTemplateVar falls back and records invalid remote template errors', async () => { + configureVariables({ + config: config({ + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Hello {{#if name}}'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineTemplateVar('prompt', { default: 'fallback' }) + + const resolved = await prompt.get({ name: 'Ada' }) + + expect(resolved).toMatchObject({ label: 'prod', reason: 'other_error', value: 'fallback', version: 1 }) + expect(resolved.exception).toBeInstanceOf(VariableRenderError) + }) + + it('defineTemplateVar falls back and records invalid default template errors', async () => { + configureVariables(false) + const prompt = defineTemplateVar('bad_default_prompt', { default: 'Hello {{#if name}}' }) + + const resolved = await prompt.get({ name: 'Ada' }) + + expect(resolved).toMatchObject({ reason: 'other_error', value: 'Hello {{#if name}}' }) + expect(resolved.exception).toBeInstanceOf(VariableRenderError) + }) + + it('renders template defaults and trusts schema-mismatched inputs at runtime', async () => { + configureVariables(false) + const prompt = defineTemplateVar('fallback_prompt', { + default: 'Hello {{name}}', + templateInputsSchema: { + properties: { name: { type: 'string' } }, + type: 'object', + }, + }) + + await expect(prompt.get({ missing: 'Ada' })).resolves.toMatchObject({ reason: 'no_provider', value: 'Hello ' }) + }) +}) diff --git a/packages/logfire-api/src/vars/template.ts b/packages/logfire-api/src/vars/template.ts new file mode 100644 index 0000000..9336379 --- /dev/null +++ b/packages/logfire-api/src/vars/template.ts @@ -0,0 +1,58 @@ +import Handlebars from 'handlebars' + +import { VariableRenderError } from './errors' + +export function createSafeHandlebarsContext(value: unknown): unknown { + if (typeof value === 'string') { + return new Handlebars.SafeString(value) + } + if (Array.isArray(value)) { + return value.map(createSafeHandlebarsContext) + } + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, createSafeHandlebarsContext(item)])) + } + return value +} + +export function renderSerializedTemplate(serializedValue: string, inputs: Record = {}): string { + let value: unknown + try { + value = JSON.parse(serializedValue) + } catch (error) { + throw new VariableRenderError(`Failed to parse serialized template value: ${formatError(error)}`) + } + + try { + return JSON.stringify(renderTemplateValue(value, createSafeHandlebarsContext(inputs))) + } catch (error) { + if (error instanceof VariableRenderError) { + throw error + } + throw new VariableRenderError(`Failed to render template: ${formatError(error)}`) + } +} + +function renderTemplateValue(value: unknown, context: unknown): unknown { + if (typeof value === 'string') { + if (!value.includes('{{')) { + return value + } + return Handlebars.compile(value)(context) + } + if (Array.isArray(value)) { + return value.map((item) => renderTemplateValue(item, context)) + } + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderTemplateValue(item, context)])) + } + return value +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/logfire-api/src/vars/templateValidation.test.ts b/packages/logfire-api/src/vars/templateValidation.test.ts new file mode 100644 index 0000000..3627883 --- /dev/null +++ b/packages/logfire-api/src/vars/templateValidation.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + configureVariables, + defineTemplateVar, + defineVar, + getVariableProvider, + variablesClear, + variablesPush, + variablesValidate, +} from './index' +import type { VariablesConfig } from './index' +import { extractTemplatePaths } from './templateValidation' + +const config = (variables: VariablesConfig['variables']): VariablesConfig => ({ variables }) + +describe('variable template validation', () => { + beforeEach(() => { + variablesClear() + configureVariables(false) + }) + + afterEach(async () => { + await getVariableProvider().shutdown?.() + variablesClear() + configureVariables(false) + }) + + it('extracts common Handlebars paths from templates', () => { + expect(extractTemplatePaths('Hello {{user.name}} {{#if beta}}yes{{/if}}')).toEqual(['user.name', 'beta']) + }) + + it('reports template paths missing from template_inputs_schema', async () => { + configureVariables({ + config: config({ + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Hello {{missing}}'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineTemplateVar('prompt', { + default: 'Hello {{name}}', + templateInputsSchema: { + properties: { name: { type: 'string' } }, + type: 'object', + }, + }) + + const report = await variablesValidate([prompt]) + + expect(report.isValid).toBe(false) + expect(report.templateInputWarnings).toEqual([ + { + label: 'prod', + message: "Template path 'missing' is not present in template_inputs_schema", + path: 'missing', + variableName: 'prompt', + }, + ]) + }) + + it('validates templates after transitive composition', async () => { + configureVariables({ + config: config({ + fragment: { + labels: { prod: { serialized_value: JSON.stringify('{{unknown}}'), version: 1 } }, + name: 'fragment', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Use @{fragment}@'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineTemplateVar('prompt', { + default: 'Use {{known}}', + templateInputsSchema: { + properties: { known: { type: 'string' } }, + type: 'object', + }, + }) + + const report = await variablesValidate([prompt]) + + expect(report.templateInputWarnings).toMatchObject([{ path: 'unknown', variableName: 'prompt' }]) + }) + + it('reports missing references and cycles', async () => { + configureVariables({ + config: config({ + cyclic: { + labels: { prod: { serialized_value: JSON.stringify('@{cyclic}@'), version: 1 } }, + name: 'cyclic', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + prompt: { + labels: { prod: { serialized_value: JSON.stringify('@{missing}@'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineVar('prompt', { default: '' }) + const cyclic = defineVar('cyclic', { default: '' }) + + const report = await variablesValidate([prompt, cyclic]) + + expect(report.referenceWarnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reference: 'missing', type: 'missing_reference', variableName: 'prompt' }), + expect.objectContaining({ reference: 'cyclic', type: 'composition_cycle', variableName: 'cyclic' }), + ]) + ) + expect(report.isValid).toBe(false) + }) + + it('strict push fails for reference and template validation warnings', async () => { + configureVariables({ + config: config({ + prompt: { + labels: { prod: { serialized_value: JSON.stringify('Hello {{missing}} @{unknown}@'), version: 1 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + instrument: false, + }) + const prompt = defineTemplateVar('prompt', { + default: 'Hello {{name}}', + templateInputsSchema: { + properties: { name: { type: 'string' } }, + type: 'object', + }, + }) + + await expect(variablesPush([prompt], { strict: true })).rejects.toThrow('template input schemas') + }) +}) diff --git a/packages/logfire-api/src/vars/templateValidation.ts b/packages/logfire-api/src/vars/templateValidation.ts new file mode 100644 index 0000000..bf2e98c --- /dev/null +++ b/packages/logfire-api/src/vars/templateValidation.ts @@ -0,0 +1,229 @@ +import Handlebars from 'handlebars' + +import type { ComposedReference } from './composition' +import type { JsonSchema } from './index' + +export interface ReferenceValidationIssue { + label?: string + message: string + reference?: string + type: 'composition_cycle' | 'composition_depth' | 'invalid_reference' | 'missing_reference' + variableName: string +} + +export interface TemplateInputValidationIssue { + label?: string + message: string + path: string + variableName: string +} + +export function collectReferenceValidationIssues( + variableName: string, + label: string | undefined, + composedFrom: ComposedReference[] +): ReferenceValidationIssue[] { + const issues: ReferenceValidationIssue[] = [] + for (const reference of composedFrom) { + if (reference.error !== undefined) { + const issue: ReferenceValidationIssue = { + message: reference.error, + reference: reference.name, + type: reference.error.includes('VariableCompositionCycleError') + ? 'composition_cycle' + : reference.error.includes('VariableCompositionDepthError') + ? 'composition_depth' + : 'invalid_reference', + variableName, + } + if (label !== undefined) { + issue.label = label + } + issues.push(issue) + } else if (reference.value === undefined) { + const issue: ReferenceValidationIssue = { + message: `Variable '${variableName}' references missing variable '${reference.name}'`, + reference: reference.name, + type: 'missing_reference', + variableName, + } + if (label !== undefined) { + issue.label = label + } + issues.push(issue) + } + if (reference.composedFrom !== undefined) { + issues.push(...collectReferenceValidationIssues(variableName, label, reference.composedFrom)) + } + } + return dedupeIssues(issues) +} + +export function validateTemplateInputs( + serializedValue: string, + templateInputsSchema: JsonSchema | null | undefined, + variableName: string, + label: string | undefined +): TemplateInputValidationIssue[] { + if (templateInputsSchema === undefined || templateInputsSchema === null) { + return [] + } + let value: unknown + try { + value = JSON.parse(serializedValue) + } catch { + return [] + } + + const issues: TemplateInputValidationIssue[] = [] + for (const template of collectStringLeaves(value)) { + for (const path of extractTemplatePaths(template)) { + if (!isSchemaPathKnown(templateInputsSchema, path)) { + const issue: TemplateInputValidationIssue = { + message: `Template path '${path}' is not present in template_inputs_schema`, + path, + variableName, + } + if (label !== undefined) { + issue.label = label + } + issues.push(issue) + } + } + } + return dedupeIssues(issues) +} + +export function extractTemplatePaths(template: string): string[] { + let ast: unknown + try { + ast = Handlebars.parse(template) + } catch { + return [] + } + const paths: string[] = [] + const seen = new Set() + collectPathsFromAst(ast, paths, seen) + return paths +} + +function collectPathsFromAst(node: unknown, paths: string[], seen: Set): void { + if (!isRecord(node)) { + return + } + + const type = node['type'] + if (type === 'MustacheStatement' || type === 'SubExpression') { + const params = Array.isArray(node['params']) ? node['params'] : [] + if (params.length === 0) { + addPathNode(node['path'], paths, seen) + } else { + for (const param of params) { + addPathNode(param, paths, seen) + } + } + collectHashPaths(node['hash'], paths, seen) + } else if (type === 'BlockStatement') { + const params = Array.isArray(node['params']) ? node['params'] : [] + for (const param of params) { + addPathNode(param, paths, seen) + } + collectHashPaths(node['hash'], paths, seen) + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + collectPathsFromAst(item, paths, seen) + } + } else if (isRecord(value)) { + collectPathsFromAst(value, paths, seen) + } + } +} + +function collectHashPaths(hash: unknown, paths: string[], seen: Set): void { + if (!isRecord(hash) || !Array.isArray(hash['pairs'])) { + return + } + for (const pair of hash['pairs']) { + if (isRecord(pair)) { + addPathNode(pair['value'], paths, seen) + } + } +} + +function addPathNode(node: unknown, paths: string[], seen: Set): void { + if (!isRecord(node) || node['type'] !== 'PathExpression') { + if (isRecord(node) && node['type'] === 'SubExpression') { + collectPathsFromAst(node, paths, seen) + } + return + } + const original = node['original'] + if (typeof original !== 'string' || !shouldValidatePath(original) || seen.has(original)) { + return + } + seen.add(original) + paths.push(original) +} + +function shouldValidatePath(path: string): boolean { + return ( + path !== '' && + path !== '.' && + path !== 'this' && + path !== 'else' && + !path.startsWith('@') && + !path.startsWith('../') && + !path.includes('/') + ) +} + +function isSchemaPathKnown(schema: JsonSchema, path: string): boolean { + let current: unknown = schema + for (const segment of path.split('.')) { + if (!isRecord(current)) { + return false + } + const properties = current['properties'] + if (!isRecord(properties)) { + return true + } + if (!Object.hasOwn(properties, segment)) { + return current['additionalProperties'] === true + } + current = properties[segment] + } + return true +} + +function collectStringLeaves(value: unknown): string[] { + if (typeof value === 'string') { + return [value] + } + if (Array.isArray(value)) { + return value.flatMap(collectStringLeaves) + } + if (isRecord(value)) { + return Object.values(value).flatMap(collectStringLeaves) + } + return [] +} + +function dedupeIssues(issues: T[]): T[] { + const seen = new Set() + const deduped: T[] = [] + for (const issue of issues) { + const key = JSON.stringify(issue) + if (!seen.has(key)) { + seen.add(key) + deduped.push(issue) + } + } + return deduped +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/logfire-api/vite.config.ts b/packages/logfire-api/vite.config.ts index 236373f..5bd005b 100644 --- a/packages/logfire-api/vite.config.ts +++ b/packages/logfire-api/vite.config.ts @@ -23,7 +23,7 @@ const config: ReturnType = defineConfig({ resolver: 'tsc', }, deps: { - neverBundle: [/^@opentelemetry/, /^node:/, 'js-yaml', 'p-retry', 'zod'], + neverBundle: [/^@opentelemetry/, /^node:/, 'handlebars', 'js-yaml', 'p-retry', 'zod'], }, entry: { evals: 'src/evals/index.ts', diff --git a/plans/002-variable-composition-templates.md b/plans/002-variable-composition-templates.md new file mode 100644 index 0000000..1d1c95c --- /dev/null +++ b/plans/002-variable-composition-templates.md @@ -0,0 +1,433 @@ +## Goal + +Port the managed-variable composition/reference feature from [pydantic/logfire#1731](https://github.com/pydantic/logfire/pull/1731) into `logfire-js`. + +The JS SDK should support remote/local variable values that reference other managed variables with `@{variable_name}@` syntax, preserve runtime `{{placeholder}}` templates during composition, and provide a typed convenience API for rendering Handlebars templates with runtime inputs. + +This PRP is scoped to the runtime-agnostic `logfire/vars` implementation plus Node re-export docs/examples. Browser-specific remote credential handling remains out of scope; browser users can still use local variable configs through `logfire/vars`. + +## Why + +- Prompt and AI configuration variables often need reusable fragments such as safety rules, brand voice, output schema guidance, and per-environment copy. +- Python Logfire is adding composition and template rendering in PR 1731. JS should stay wire-compatible with the same variable config shape and syntax. +- Composition lets the Logfire UI update shared fragments without redeploying every app that consumes a composed variable. +- Template rendering gives JS users the same one-step "resolve, compose, render, parse" workflow for prompt variables that Python users get from `template_var()`. + +## Success Criteria + +- [ ] `@{var}@` references in JSON-serialized variable string values are expanded before `codec.parse()` runs. +- [ ] References support dotted access such as `@{brand.tagline}@` and block helpers such as `@{#if beta}@...@{else}@...@{/if}@`. +- [ ] Escaped references written as `\@{name}@` are preserved as literal `@{name}@`. +- [ ] Runtime `{{placeholder}}` templates are preserved during `@{}@` composition. +- [ ] Composition handles nested references, missing references, invalid referenced JSON, circular references, and depth limits deterministically. +- [ ] `ResolvedVariable` exposes composition metadata and records it on variable resolution spans. +- [ ] `ResolvedVariable.render(inputs)` renders `{{}}` templates in the post-composition serialized value and reparses through the variable codec. +- [ ] A JS-style `defineTemplateVar()` API, plus `templateVar` alias, resolves, composes, renders, and parses in one `get(inputs, options?)` call. +- [ ] `VariableConfig` supports `template_inputs_schema` and syncs it through build, normalize, validate, push, pull, and remote write bodies. +- [ ] Validation reports reference warnings, composition cycles, and common Handlebars template paths that are incompatible with `template_inputs_schema`. +- [ ] `handlebars` is a direct `logfire` dependency but remains isolated to the `logfire/vars` entrypoint. +- [ ] Docs, examples, tests, and a changeset cover the public API addition. + +## Clarifications + +### Session 2026-05-08 + +- Q: Which public API shape should the PRP lock in for template variables? -> A: Use JS-style `defineTemplateVar()` plus `templateVar` alias, matching existing `defineVar()` conventions. +- Q: How strict should first-pass template input validation be? -> A: Use pragmatic Handlebars AST parsing plus JSON Schema property/path checks, and document unsupported edge cases instead of requiring full Python parity in the first pass. +- Q: How should the Handlebars runtime be introduced? -> A: Add `handlebars` as a direct `logfire` dependency and keep it isolated to the `logfire/vars` entrypoint. +- Q: Should `templateVar.get(inputs)` validate `inputs` against `templateInputsSchema` at runtime? -> A: No. `templateInputsSchema` is consulted only by `variablesValidate()` and strict push; `get(inputs)` trusts the caller and avoids pulling a JSON Schema validator (e.g., `ajv`) into the `vars` entry. +- Q: How should composition and render errors be exposed? -> A: Introduce new typed error classes mirroring the existing `VariableWriteError` family - `VariableCompositionError`, `VariableCompositionCycleError`, `VariableCompositionDepthError`, and `VariableRenderError` - so consumers can handle them via `instanceof`. +- Q: How should the `vars` module and tests be organized? -> A: Split `src/vars/index.ts` now into focused modules (`composition.ts`, `referenceSyntax.ts`, `template.ts`, `templateValidation.ts`), keep `index.ts` as the public barrel, and split tests to match (`composition.test.ts`, `template.test.ts`, `templateValidation.test.ts`); existing `vars.test.ts` may stay as the home for legacy behavior or be migrated alongside. +- Q: What should the `InputsT` generic for `defineTemplateVar` look like? -> A: `InputsT extends Record = Record` - object-shaped inputs only, matching Handlebars' top-level context model. + +## Context + +### Key Files + +- `packages/logfire-api/src/vars/index.ts` - all current managed-variable types, providers, resolution, sync, validation, and public exports live in one file. +- `packages/logfire-api/src/vars.test.ts` - current managed-variable behavior tests. New tests can either extend this file or split into focused `src/vars/*.test.ts` files if the implementation is split. +- `packages/logfire-api/vite.config.ts` - package entries include `vars`; add any new runtime dependency to pack dependency handling if needed. +- `packages/logfire-api/package.json` - add `handlebars` if the port uses the standard JS Handlebars runtime. +- `packages/logfire-node/src/vars.ts` - re-exports `logfire/vars`; no separate implementation expected. +- `docs/managed-variables.md` - main managed-variable docs for JS users. +- `packages/logfire-api/README.md` - package README includes a short managed-variable section. +- `examples/node/variables.ts` - existing runnable managed-variable example; can be extended or paired with a new composition example. +- `.changeset/` - public API and dependency changes need release metadata. + +### External References + +- [pydantic/logfire#1731](https://github.com/pydantic/logfire/pull/1731) - source feature and intended Python behavior. +- [Python composition.py](https://raw.githubusercontent.com/pydantic/logfire/10d2d5207dc8f885cff15f052766693789498d7c/logfire/variables/composition.py) - reference expansion, reference discovery, cycle/depth handling, and metadata shape. +- [Python reference_syntax.py](https://raw.githubusercontent.com/pydantic/logfire/10d2d5207dc8f885cff15f052766693789498d7c/logfire/variables/reference_syntax.py) - conversion of `@{}@` tags to Handlebars while protecting `{{}}`. +- [Python variable.py](https://raw.githubusercontent.com/pydantic/logfire/10d2d5207dc8f885cff15f052766693789498d7c/logfire/variables/variable.py) - resolution pipeline and `TemplateVariable` behavior. +- [Python abstract.py](https://raw.githubusercontent.com/pydantic/logfire/10d2d5207dc8f885cff15f052766693789498d7c/logfire/variables/abstract.py) - `ResolvedVariable.render()`, sync diff behavior, and validation report extensions. +- [Python tests](https://github.com/pydantic/logfire/pull/1731/files) - use `test_variable_composition.py`, `test_variable_templates.py`, and `test_template_validation.py` as behavior checklists. +- [Handlebars compile API](https://handlebarsjs.com/api-reference/compilation.html) - `Handlebars.compile(template, { noEscape: true })` avoids HTML escaping prompt/config values. +- [Handlebars built-in helpers](https://handlebarsjs.com/guide/builtin-helpers.html) - expected semantics for `if`, `unless`, `each`, `with`, and `else`. +- [handlebars npm package](https://www.npmjs.com/package/handlebars) - latest checked version was `4.7.9`; the package ships its own TypeScript declarations. + +### Gotchas + +- JS cannot infer a JSON Schema from a TypeScript `InputsT` generic at runtime. Use an explicit `templateInputsSchema?: JsonSchema` option for JS, even though Python derives the schema from a Pydantic model. +- Current JS variable resolution is async because providers can fetch remotely. Composition helpers must be async when they call `provider.getSerializedValue()`. +- Do not compose code defaults. Python PR 1731 explicitly returns code defaults containing `@{...}@` as-is when no serialized provider value exists. Template defaults for `defineTemplateVar()` should still render `{{}}` inputs. +- Render templates against decoded JSON values, then re-encode. Rendering raw JSON strings can break JSON when inputs contain quotes, backslashes, or newlines. +- Use `noEscape: true` or equivalent safe values. Handlebars defaults to HTML escaping, which is wrong for prompt/config templates. +- Keep unresolved references literal, for example `@{missing}@`, while still recording a `ComposedReference` entry with an unresolved reason. +- `handlebars` should be a direct `logfire` dependency, but avoid introducing it into the main tracing entry. Verify bundle output after implementation. +- Existing `VariableOptions.codec` is the only runtime type validator. `defineTemplateVar()` should reuse that codec rather than adding a second parsing path. +- First-pass template validation is intentionally pragmatic: parse Handlebars AST paths and validate them against JSON Schema object properties, but document unsupported helpers/schema constructs instead of blocking on full Python parity. +- Reuse the existing `JsonSchema` export at `packages/logfire-api/src/vars/index.ts:6` (`type JsonSchema = Record`); do not redeclare it. +- `MAX_COMPOSITION_DEPTH = 20` (Python `composition.py:46`). +- Pin `handlebars` by querying `pnpm info handlebars version` at install time rather than hard-coding 4.7.x; do not rely on the version mentioned in this PRP. +- `templateVar.get(inputs)` does NOT validate `inputs` against `templateInputsSchema` at runtime. Schema validation only fires through `variablesValidate()` and strict push, mirroring the JS-side decision to keep the `vars` entry free of an `ajv`-style validator dependency. +- HTML escaping: use per-leaf `Handlebars.SafeString` wrapping (matches Python `_protect_value`), NOT `noEscape: true`. Per-leaf wrapping disables escaping only for trusted context values, not for anything the template author might inject. +- Block-helper references: `@{#if foo}@...@{/if}@` collects `foo` (not `if`) as the resolvable name. The `#if`/`#each`/`#unless`/`#with` keywords are Handlebars built-ins; only the _condition/iterable identifier_ needs resolving. Filter `else` and `this` out of collected refs as well. +- The `_REFERENCE_TAG` regex (used for substitution) and `_SIMPLE_REF`/`_BLOCK_REF` (used for collection) are NOT redundant. The substitution regex is permissive (matches anything between `@{` and `}@` lazily) because Handlebars itself parses the resulting `{{...}}`; the collection regexes are strict because we use them to drive resolution. + +## Implementation Blueprint + +### Data Models + +Add composition metadata and render state: + +```ts +export interface ComposedReference { + composedFrom?: ComposedReference[] + error?: string + label?: string + name: string + reason: VariableResolutionReason + value?: string + version?: number +} + +export interface ResolvedVariableInit { + composedFrom?: ComposedReference[] + deserializer?: (serialized: string) => T + exception?: unknown + label?: string + name: string + reason: VariableResolutionReason + serializedValue?: string + value: T + version?: number +} +``` + +Extend variable config and options. Reuse the existing `JsonSchema` export at `packages/logfire-api/src/vars/index.ts:6` (`type JsonSchema = Record`); do not redeclare it: + +```ts +export interface VariableConfig { + template_inputs_schema?: JsonSchema | null +} + +export interface VariableOptions { + templateInputsSchema?: JsonSchema +} + +export interface TemplateVariableOptions> extends VariableOptions { + default: ResolveFunction | T +} +``` + +Expose a JS-style template variable API. `InputsT` is constrained to an object so Handlebars contexts are well-formed; the default keeps untyped call sites ergonomic: + +```ts +export class TemplateVariable> extends Variable { + get(inputs: InputsT, options?: VariableGetOptions): Promise> +} + +export function defineTemplateVar = Record>( + name: string, + options: TemplateVariableOptions +): TemplateVariable + +export { defineTemplateVar as templateVar } +``` + +Add typed error classes alongside the existing `VariableWriteError` family at `vars/index.ts:278`: + +```ts +export class VariableCompositionError extends Error {} +export class VariableCompositionCycleError extends VariableCompositionError {} +export class VariableCompositionDepthError extends VariableCompositionError {} +export class VariableRenderError extends Error {} +``` + +`get(inputs)` does NOT validate `inputs` against `templateInputsSchema` at runtime; the schema is consumed only by `variablesValidate()` / strict push. This keeps the `vars` entrypoint free of a JSON Schema validator dependency. + +### Tasks + +```yaml +Task 1: Split managed-variable internals into focused modules + MODIFY packages/logfire-api/src/vars/index.ts and CREATE files under packages/logfire-api/src/vars/: + - Keep `src/vars/index.ts` as the public barrel; move helpers into focused modules. + - Suggested split: `resolution.ts` (Variable, ResolvedVariable, providers), `config.ts` (VariableConfig normalization, push/pull), `validation.ts` (variablesValidate and helpers), plus the new files added in later tasks (`referenceSyntax.ts`, `composition.ts`, `template.ts`, `templateValidation.ts`). + - Re-export every previously public symbol from `index.ts` so existing imports keep working. + - Preserve `defineVar`, `var`, providers, codecs, and existing config behavior. No behavior changes in this task. + - Add the new typed error classes (`VariableCompositionError`, `VariableCompositionCycleError`, `VariableCompositionDepthError`, `VariableRenderError`) next to `VariableWriteError` and re-export them. + PATTERN: Mirror `VariableWriteError`/`VariableNotFoundError`/`VariableAlreadyExistsError` declarations at `vars/index.ts:278-286` for the new error classes. Tests already import from `./vars`, which now resolves to the barrel. + +Task 2: Add Handlebars dependency and render helpers + MODIFY pnpm-workspace.yaml: + - Add a catalog entry for `handlebars`. + MODIFY packages/logfire-api/package.json: + - Add `handlebars` to dependencies (resolve version with `pnpm info handlebars version`). + MODIFY packages/logfire-api/vite.config.ts: + - Add `handlebars` to `pack.deps.neverBundle` if needed after checking build output. + CREATE packages/logfire-api/src/vars/template.ts: + - Convert supported inputs to a plain context object. + - Render only string leaves within decoded JSON values. + - Recursively wrap context string leaves with `new Handlebars.SafeString(value)` to disable HTML escaping per value (matches Python `_protect_value` strategy in `reference_syntax.py`). Do NOT use `Handlebars.compile(template, { noEscape: true })` - per-leaf `SafeString` is more conservative and is the documented Python behavior. + - Pass through numbers/booleans/null unchanged; Handlebars will stringify them (null/undefined → empty string). + - Re-encode rendered values with `JSON.stringify`. + PATTERN: Python `render_serialized_string()` plus `_protect_value()` in `reference_syntax.py` decode JSON, recursively wrap string leaves with `SafeString`, render, and re-encode. + +Task 3: Implement `@{}@` reference syntax + CREATE packages/logfire-api/src/vars/referenceSyntax.ts: + - Implement `renderOnce(template: string, context: Record): string`. + - Algorithm (must match Python `reference_syntax.render_once` step-for-step): + 1. Generate three per-template sentinels of the form `\x00logfire--\x00` for `left-runtime-placeholder`, `right-runtime-placeholder`, `escaped-reference-start`. Use a monotonic counter or `crypto.randomUUID()` in place of Python's `id(template)`; the sentinel just needs to be collision-free with user content. + 2. Replace `\@{` with the escaped-reference sentinel BEFORE replacing `{{`/`}}` with the runtime-placeholder sentinels. + 3. Substitute `@{...}@` with `{{...}}` using `_REFERENCE_TAG` (see regex set below). + 4. Recursively wrap context string leaves with `new Handlebars.SafeString(...)` (Task 2). + 5. Render with `Handlebars.compile(handlebarsTemplate)(safeContext)` - no `noEscape` flag needed because string leaves are pre-wrapped. + 6. Restore in reverse order: runtime-placeholder sentinels back to `{{`/`}}`, then escaped-reference sentinel back to `@{`. + - Export the regex set used across composition (one source of truth): + - `HAS_REFERENCE = /(?`; mirrors Python tuple `(serialized, label, version, reason)` at `composition.py:82`). + - Recurse into referenced serialized values before rendering the root value, incrementing depth on each recursion; throw `VariableCompositionDepthError` when depth exceeds `MAX_COMPOSITION_DEPTH`. + - Detect cycles by tracking the current resolution chain; on revisit throw `VariableCompositionCycleError`. Internal recursion catches both and converts them to `ComposedReference` entries with `error` set - the public `expandReferences()` returns `(expanded, composed[])` rather than throwing, matching Python. + - Preserve unresolved refs as literal text (do not substitute) and record a `ComposedReference` with `reason: 'unrecognized_variable'` and `error: null`. + - Invalid referenced JSON: keep the literal `@{name}@` in output and record `error: 'non-JSON ...'` on the `ComposedReference` (Python test at `test_invalid_json_reference` asserts `'non-JSON' in composed[0].error`). + PATTERN: Python `composition.py`; adapt `ResolveFn` to async JS providers. + +Task 5: Integrate composition into resolution + MODIFY packages/logfire-api/src/vars/index.ts: + - In `Variable.resolve()`, after serialized provider lookup and before `JSON.parse()` / `codec.parse()`, call `expandReferences()` when `hasReferences(serialized.value)`. + - Resolve referenced variables through the same provider, targeting key, and merged attributes. + - On composition errors, fall back to the code default with `reason: 'other_error'`, preserving original label/version where useful. + - Populate `ResolvedVariable.composedFrom`. + - Store post-composition `serializedValue` and a codec-backed deserializer for later `render()`. + PATTERN: Python `_expand_and_deserialize()`. + +Task 6: Add `ResolvedVariable.render()` + MODIFY the resolution module (post-split) that owns `ResolvedVariable`: + - Add `serializedValue`, `deserializer`, and `composedFrom` fields to `ResolvedVariable`. + - Implement `render(inputs?: Record): T`. + - Throw `VariableRenderError` if no serialized value or deserializer is available. + - Render post-composition serialized JSON through Task 2, then deserialize with the original codec. + - Ensure provider values, explicit label values, and serializable defaults can be rendered. + PATTERN: Python `ResolvedVariable.render()`. + +Task 7: Add template variable API + CREATE packages/logfire-api/src/vars/template.ts (alongside the render helpers from Task 2): + - Add `TemplateVariable>` extending `Variable`. + - Add `defineTemplateVar = Record>()` as the primary public API and `templateVar` alias for convenience. + - Store `templateInputsSchema` on the variable definition. + - Make `get(inputs, options?)` run the pipeline: resolve -> compose -> render -> parse. Do NOT validate `inputs` against `templateInputsSchema` here; runtime validation is intentionally out of scope (see Clarifications). + - Keep duplicate-name checks shared with `defineVar()` (re-use the existing registry). + - Re-export from `vars/index.ts` barrel. + PATTERN: Python `TemplateVariable.get(inputs)`, adjusted to JS async and explicit schema; mirror `defineVar()` registration semantics already in the resolution module. + +Task 8: Sync `template_inputs_schema` + MODIFY packages/logfire-api/src/vars/index.ts: + - Add `template_inputs_schema?: JsonSchema | null` to `VariableConfig`. + - Normalize it from local and remote configs. + - Include it in `variableToConfig()`. + - Include it in `variablesPush()` merge comparisons. + - Include it in `configToApiBody()`. + - Preserve it in local provider create/update/batch flows. + PATTERN: Python `VariableConfig.template_inputs_schema` and `VariablesConfig.from_variables()`. + +Task 9: Add validation for references and templates + CREATE packages/logfire-api/src/vars/templateValidation.ts: + - Extract template strings from serialized JSON values. + - Walk composition graphs to include referenced variable values; reuse `findReferences()` from `composition.ts`. + - Detect missing references and composition cycles (without throwing - report as validation issues). + - Parse Handlebars templates and validate common `{{field}}` / dotted paths against `template_inputs_schema` object properties when present. + - Document unsupported helpers and JSON Schema constructs instead of attempting full Python parity in the first pass. + - Add warning/error fields to `ValidationReport` without breaking existing consumers. + MODIFY validation module (post-split) to wire variablesValidate() and variablesPush(): + - Include reference warnings in validation output. + - Make `strict: true` fail when references are cyclic, missing, or template inputs are incompatible. + - This is the only surface that consults `template_inputs_schema` against actual templates; runtime `templateVar.get(inputs)` calls deliberately skip validation. + PATTERN: Python `template_validation.py` and `_check_reference_warnings()`. + +Task 10: Record composition on spans + MODIFY packages/logfire-api/src/vars/index.ts: + - When variables are instrumented, add a serialized `composed_from` span attribute if composition occurred. + - Include referenced name, label, version, reason, and error. + - Preserve existing span attributes `name`, `reason`, `label`, `version`, and `value`. + PATTERN: Python span attribute recording in `_get_result_and_record_span()`. + +Task 11: Add tests (one file per new module) + CREATE packages/logfire-api/src/vars/referenceSyntax.test.ts: + - Cover sentinel protection of `{{ }}` and `\@{`, conversion of `@{...}@` to Handlebars tags, and round-trip restoration. + CREATE packages/logfire-api/src/vars/composition.test.ts: + - Cover no refs, simple refs, multiple refs, duplicate refs, nested refs, structured JSON, lists, dotted fields, block helpers, escaped refs, unresolved refs, invalid referenced JSON, cycles (assert `VariableCompositionCycleError`), and depth limit (assert `VariableCompositionDepthError`). + CREATE packages/logfire-api/src/vars/template.test.ts: + - Cover `ResolvedVariable.render()`, structured values, object/list string leaves, `VariableRenderError` when no serialized value, template variable `get(inputs)`, override rendering, default rendering, and render errors. Add a test that confirms `get(inputs)` does NOT throw on schema-mismatched inputs (no runtime validation). + CREATE packages/logfire-api/src/vars/templateValidation.test.ts: + - Cover `template_inputs_schema`, unknown fields, transitive referenced templates, cycles, duplicate issue deduping, and `strict` push behavior. + KEEP packages/logfire-api/src/vars.test.ts for the existing legacy behavior tests; only migrate cases when they overlap with the new modules. + PATTERN: Use Python PR 1731 tests as the behavior checklist, but adapt assertions to JS strings and async APIs. + +Task 12: Update docs, example, and release metadata + MODIFY docs/managed-variables.md: + - Add sections for variable composition, template rendering, and `defineTemplateVar()`. + - Explain JS requires explicit `templateInputsSchema` for sync validation. + MODIFY packages/logfire-api/README.md: + - Add a concise composition/template example. + MODIFY examples/node/variables.ts or CREATE examples/node/variable-composition.ts: + - Demonstrate local config with reusable fragments, a composed prompt, and template inputs. + CREATE .changeset/.md: + - Minor bump for `logfire`. + - Patch or no bump for `@pydantic/logfire-node` only if docs/re-export metadata changes require it. +``` + +### Integration Points + +```yaml +PUBLIC API: + - logfire/vars exports `defineTemplateVar`, `templateVar`, `TemplateVariable`, `ComposedReference`, `VariableCompositionError`, `VariableCompositionCycleError`, `VariableCompositionDepthError`, and `VariableRenderError`. + - @pydantic/logfire-node/vars re-exports the same API automatically. + +RESOLUTION: + - `Variable.get()` composes serialized provider values before parsing. + - `ResolvedVariable.render()` renders post-composition serialized values on demand. + - `TemplateVariable.get()` renders automatically. + +CONFIG SYNC: + - `variablesBuildConfig()` includes `template_inputs_schema`. + - `variablesPush()` and `variablesPushConfig()` write `template_inputs_schema`. + - `variablesPullConfig()` normalizes `template_inputs_schema` from remote configs. + +OBSERVABILITY: + - Variable resolution spans include composition metadata without changing existing baggage behavior. +``` + +## Validation + +Run focused checks first: + +```bash +vp run logfire#test -- vars +vp run logfire#typecheck +vp run @pydantic/logfire-node#typecheck +``` + +Run package-level validation after dependency/config/docs changes: + +```bash +pnpm run build +pnpm run format-check +``` + +Run the example manually if a new example is added: + +```bash +cd examples/node +pnpm run variables +``` + +### Required Test Coverage + +- [ ] Composition happy path: local config value `"Hello @{name}@"` resolves another variable and parses to the expected value. +- [ ] Nested composition: A references B references C, with `composedFrom` preserving nested metadata. +- [ ] Structured values: refs inside object/list string leaves expand without changing non-string fields. +- [ ] Dotted access: `@{brand.tagline}@` reads a property from an object variable. +- [ ] Block helpers: `if`, `unless`, `each`, `with`, and `else` work for `@{}@` composition. +- [ ] Escaping: `\@{name}@` becomes literal `@{name}@`; `{{runtime}}` survives composition. +- [ ] Error handling: missing refs stay literal; invalid referenced JSON records an error; cycles/depth errors fall back. +- [ ] Rendering: `ResolvedVariable.render()` fills `{{}}`, handles object/list leaves, disables HTML escaping, and reparses with the codec. +- [ ] Template variable: `defineTemplateVar().get(inputs)` composes then renders in one call. +- [ ] Sync: `template_inputs_schema` appears in built configs and remote create/update bodies. +- [ ] Validation: strict push catches incompatible template fields and reference cycles. +- [ ] Span metadata: composed references are visible on variable resolution spans. + +## Unknowns & Risks + +- Handlebars package size may matter for browser consumers of `logfire/vars`. Verify the pack output and keep the dependency isolated to the `vars` entry if possible. +- Template schema validation is deliberately pragmatic in the first pass: Handlebars AST/path extraction plus JSON Schema object-property checks. Full helper-aware Python parity can be a follow-up if needed. +- The Logfire Variables API must accept `template_inputs_schema` from JS write bodies. The Python PR suggests this is the intended wire field, but implementation should verify against a local or test API before release. +- Public naming is settled for this PRP: `defineTemplateVar` is the primary JS-style API and `templateVar` is a convenience alias. Avoid snake_case exports unless maintainers explicitly request Python parity later. +- New typed errors (`VariableCompositionError` and subclasses, `VariableRenderError`) become part of the public API surface; document them in the README and ensure they re-export from the barrel. +- Existing `variablesValidate()` returns a small report. Adding reference/template issues should be additive and should not change current `isValid` behavior except for new invalid cases. +- Composition of code defaults is deliberately out of scope to match Python PR 1731, even though docs/examples must be careful not to imply otherwise. + +## Execution Notes + +### Session 2026-05-08 + +- Implemented the feature with focused new modules for reference syntax, composition, template rendering, template validation, and typed errors. The existing resolution/config registry remains in `src/vars/index.ts`; a full mechanical split into `resolution.ts`/`config.ts` was deferred to avoid a broad refactor unrelated to the port. +- `TemplateVariable` is implemented in `src/vars/index.ts` rather than `template.ts` because `Variable` still lives in `index.ts`; moving it first would create a runtime ESM cycle. The public API and behavior match the PRP. +- JavaScript Handlebars rejects NUL sentinels during parsing, so the port uses collision-resistant plain-text sentinels instead of Python's `\x00...` markers while preserving the same protect/restore algorithm. +- The Node variables example now disables telemetry export and variable instrumentation so it can run as a local variables demo without requiring a running OTLP endpoint. + +## Reference Syntax Port Checklist + +Direct translation of Python `reference_syntax.py` and `composition.py` (lines 32-46): + +```ts +// referenceSyntax.ts — exported regex set (single source of truth) +export const HAS_REFERENCE = /(?-\x00` for `left-runtime-placeholder`, `right-runtime-placeholder`, `escaped-reference-start`. Use a module-level counter or `crypto.randomUUID()` for uniqueness. +2. `protectedTemplate = template.replaceAll('\\@{', escapedRefSentinel).replaceAll('{{', leftSentinel).replaceAll('}}', rightSentinel)` - escape replacement runs first. +3. `handlebarsTemplate = protectedTemplate.replace(REFERENCE_TAG, '{{$1}}')`. +4. Recursively wrap context string leaves in `new Handlebars.SafeString(value)` (preserve dict/list structure; pass numbers/booleans/null through). +5. `result = Handlebars.compile(handlebarsTemplate)(safeContext)` - no `noEscape` flag. +6. Restore: `result.replaceAll(leftSentinel, '{{').replaceAll(rightSentinel, '}}').replaceAll(escapedRefSentinel, '@{')`. + +### Parity tests to translate from Python + +From `tests/test_variable_composition.py` in the Python repo, port these cases (assertion shape adjusts to JS strings + async APIs): + +- `test_invalid_serialized_value_is_returned_unchanged` - non-JSON input passes through. +- `test_no_references` - input without `@{}@` is unchanged, `composed === []`. +- `test_simple_string_reference` - `"@{greeting}@ World"` → `"Hello World"`, `composed[0]` has `name`, `value`, `label='production'`, `version=1`, `reason='resolved'`, `error=null`. +- `test_multiple_references`, `test_duplicate_references`, `test_nested_references`. +- `test_dotted_field_access` - `@{config.prompt}@` reads property from object variable. +- `test_cycle_detection` - mutually-referencing vars produce `error` on `composed`, no infinite loop. +- `test_self_reference` - var referring to itself records cycle error. +- `test_max_depth_overflow` - chain longer than 20 produces `VariableCompositionDepthError` recorded as `error`. +- `test_unresolved_simple_ref` and `test_unresolved_dotted_ref` - missing refs stay literal in output. +- `test_unresolved_with_other_resolved` - mixed resolved/unresolved. +- `test_unresolved_only` - all-unresolved input is unchanged. +- `test_number_reference`, `test_boolean_reference`, `test_object_reference` - non-string variable values render via Handlebars stringification. +- `test_structured_type_with_references` - refs inside object string values. +- `test_list_with_references` - refs walk into lists; non-string entries pass through. +- `test_keyword_block_references_are_ignored` - `@{#if this}@yes@{/if}@` is left unchanged when no var named `this`. +- `test_json_encoding_newlines`, `test_json_encoding_quotes`, `test_json_encoding_unicode`, `test_json_encoding_backslashes` - rendered values reparse cleanly. +- `test_escape_sequence` - `\@{ref}@` becomes literal `@{ref}@`; the second unescaped `@{ref}@` resolves. +- `test_escape_only` - input with only escaped refs returns no `composed` entries. +- `test_invalid_json_reference` - `composed[0].error` contains the substring `'non-JSON'`. +- `TestFindReferences` block - simple/dotted/block/duplicate/escape cases for `findReferences()`. + +**Confidence: 8/10** for one-pass implementation success. Reference-syntax algorithm, regex set, depth constant, keyword filter, and HTML-escaping strategy are now pinned to source-truth values; the residual uncertainty is wire-format reciprocity for `template_inputs_schema` (needs a real-API round-trip), unspecified integration glue (fallback `composedFrom`, span attribute key, `ValidationReport` field names), the pre-feature module split, and codec re-parse semantics around rendered string values. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfcec1..402e13b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ catalogs: '@vercel/otel': specifier: ^2.1.2 version: 2.1.2 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -425,6 +428,9 @@ importers: packages/logfire-api: dependencies: + handlebars: + specifier: 'catalog:' + version: 4.7.9 js-yaml: specifier: 'catalog:' version: 4.1.1 @@ -2937,6 +2943,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3207,6 +3218,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -3233,6 +3247,9 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next@16.1.7: resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==} engines: {node: '>=20.9.0'} @@ -3564,6 +3581,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -3665,6 +3686,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3800,6 +3826,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20260317.1: resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==} engines: {node: '>=16'} @@ -6195,6 +6224,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -6439,6 +6477,8 @@ snapshots: - bufferutil - utf-8-validate + minimist@1.2.8: {} + module-details-from-path@1.0.4: {} mri@1.2.0: {} @@ -6453,6 +6493,8 @@ snapshots: negotiator@0.6.3: {} + neo-async@2.6.2: {} + next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 @@ -6870,6 +6912,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -6945,6 +6989,9 @@ snapshots: typescript@6.0.3: {} + uglify-js@3.19.3: + optional: true + undici-types@6.21.0: {} undici@7.24.4: {} @@ -7077,6 +7124,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + workerd@1.20260317.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260317.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a500e01..a618e56 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ catalog: '@opentelemetry/semantic-conventions': ^1.40.0 '@types/js-yaml': ^4.0.9 '@vercel/otel': ^2.1.2 + handlebars: ^4.7.9 'js-yaml': ^4.1.0 'p-retry': ^6.2.1 typescript: ^6.0.3 From e9d04c4dec7a15209232704fc6eefe804c7acedf Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Fri, 8 May 2026 15:57:46 +0300 Subject: [PATCH 2/2] Match Python composed_from span attribute shape The composed_from span attribute is the only piece of composition metadata that crosses the SDK boundary into trace exporters. Python emits a flat, snake_cased projection per top-level reference; the JS SDK was stringifying the full ComposedReference tree, which both duplicated potentially large `value` payloads onto every resolution span and used a camelCase nested key, so any consumer walking the chain across SDKs would see a different shape. Project the same five fields (name, version, label, reason, error) Python emits, and drop the recursive nesting so the wire format matches across SDKs. --- packages/logfire-api/src/vars/index.ts | 12 +++++- .../src/vars/instrumentation.test.ts | 39 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/logfire-api/src/vars/index.ts b/packages/logfire-api/src/vars/index.ts index 090d715..2510bf1 100644 --- a/packages/logfire-api/src/vars/index.ts +++ b/packages/logfire-api/src/vars/index.ts @@ -809,7 +809,7 @@ export class Variable { span.setAttribute('version', result.version) } if (result.composedFrom.length > 0) { - span.setAttribute('composed_from', JSON.stringify(result.composedFrom)) + span.setAttribute('composed_from', JSON.stringify(result.composedFrom.map(toComposedFromAttribute))) } try { span.setAttribute('value', serializeWithCodec(this.codec, result.value)) @@ -1381,6 +1381,16 @@ async function getSerializedValueForLabel( return resolveVariableConfigForLabel(config, label) } +function toComposedFromAttribute(reference: ComposedReference): Record { + return { + name: reference.name, + version: reference.version ?? null, + label: reference.label ?? null, + reason: reference.reason, + error: reference.error ?? null, + } +} + function serializedResolvedToReference(serialized: SerializedResolvedVariable): ResolvedReference { const reference: ResolvedReference = { name: serialized.name, diff --git a/packages/logfire-api/src/vars/instrumentation.test.ts b/packages/logfire-api/src/vars/instrumentation.test.ts index a1bfee5..591d3c8 100644 --- a/packages/logfire-api/src/vars/instrumentation.test.ts +++ b/packages/logfire-api/src/vars/instrumentation.test.ts @@ -61,6 +61,43 @@ describe('variable composition instrumentation', () => { name: 'prompt', targeting_key: undefined, }) - expect(spanMock.setAttribute).toHaveBeenCalledWith('composed_from', JSON.stringify(resolved.composedFrom)) + expect(spanMock.setAttribute).toHaveBeenCalledWith( + 'composed_from', + JSON.stringify([{ name: 'greeting', version: 1, label: 'prod', reason: 'resolved', error: null }]) + ) + }) + + it('flattens nested composition chains for the composed_from span attribute', async () => { + configureVariables({ + config: config({ + outer: { + labels: { prod: { serialized_value: JSON.stringify('@{inner}@'), version: 2 } }, + name: 'outer', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + inner: { + labels: { prod: { serialized_value: JSON.stringify('inside'), version: 1 } }, + name: 'inner', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + prompt: { + labels: { prod: { serialized_value: JSON.stringify('@{outer}@'), version: 3 } }, + name: 'prompt', + overrides: [], + rollout: { labels: { prod: 1 } }, + }, + }), + }) + const prompt = defineVar('prompt', { default: '' }) + + const resolved = await prompt.get() + + expect(resolved.value).toBe('inside') + expect(spanMock.setAttribute).toHaveBeenCalledWith( + 'composed_from', + JSON.stringify([{ name: 'outer', version: 2, label: 'prod', reason: 'resolved', error: null }]) + ) }) })