Skip to content

Commit ecdfcf1

Browse files
Fix nested field access in message template formatting (#175)
Co-authored-by: ayaangazali <ayaan.gazly@gmail.com>
1 parent 38fb2d4 commit ecdfcf1

3 files changed

Lines changed: 106 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'logfire': patch
3+
---
4+
5+
Fix nested field access in message templates. `{a.b}` previously resolved every path segment against the top-level attribute record, so it either fell back to the raw template or silently rendered an unrelated top-level attribute that shared the trailing segment name. Nested paths now walk into the attribute value, matching Python Logfire, and literal dotted attribute keys like `http.method` keep their existing precedence. Field lookups now use `Object.hasOwn`, so prototype members like `{user.toString}` no longer resolve. Index-style bracket syntax such as `{a[0]}` was never supported; when no literal attribute key matches, it previously rendered the string `undefined` and now warns and falls back to the raw template. An attribute whose literal key contains brackets (e.g. `'a[0]': value`) keeps resolving, as it did before.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, describe, expect, test, vi } from 'vite-plus/test'
2+
3+
import { NoopScrubber } from './AttributeScrubber'
4+
import { logfireFormatWithExtras } from './formatter'
5+
6+
function format(template: string, record: Record<string, unknown>): string {
7+
return logfireFormatWithExtras(template, record, NoopScrubber).formattedMessage
8+
}
9+
10+
describe('message template nested field access', () => {
11+
afterEach(() => {
12+
vi.restoreAllMocks()
13+
})
14+
15+
test('resolves a nested field', () => {
16+
expect(format('hello {user.name}', { user: { name: 'Alice' } })).toBe('hello Alice')
17+
})
18+
19+
test('resolves a deeply nested field', () => {
20+
expect(format('request by {req.user.id}', { req: { user: { id: 1 } } })).toBe('request by 1')
21+
})
22+
23+
test('nested traversal ignores unrelated top-level keys with the same trailing name', () => {
24+
expect(format('value is {a.b}', { a: { b: 'nested' }, b: 'top' })).toBe('value is nested')
25+
})
26+
27+
test('a literal dotted attribute key wins over nested traversal', () => {
28+
expect(format('{http.method} request', { http: { method: 'nested' }, 'http.method': 'GET' })).toBe('GET request')
29+
})
30+
31+
test('supports the debug format with nested fields', () => {
32+
expect(format('{user.name=}', { user: { name: 'Alice' } })).toBe('user.name=Alice')
33+
})
34+
35+
test('falls back to the raw template when the nested field is missing', () => {
36+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
37+
expect(format('hello {user.name}', { user: {} })).toBe('hello {user.name}')
38+
expect(warn).toHaveBeenCalledWith('Formatting error: The field user.name is not defined.')
39+
})
40+
41+
test('falls back to the raw template when the root of a nested field is missing', () => {
42+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
43+
expect(format('hello {user.name}', { other: 1 })).toBe('hello {user.name}')
44+
expect(warn).toHaveBeenCalledWith('Formatting error: The field user.name is not defined.')
45+
})
46+
47+
test('falls back to the raw template when an intermediate value is not an object', () => {
48+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
49+
expect(format('{a.b}', { a: 5 })).toBe('{a.b}')
50+
expect(warn).toHaveBeenCalledWith('Formatting error: The field a.b is not defined.')
51+
})
52+
53+
test('plain top-level fields keep working', () => {
54+
expect(format('hello {name}', { name: 'Bob' })).toBe('hello Bob')
55+
})
56+
57+
test('nested traversal does not resolve prototype members', () => {
58+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
59+
expect(format('{user.toString}', { user: {} })).toBe('{user.toString}')
60+
expect(warn).toHaveBeenCalledWith('Formatting error: The field user.toString is not defined.')
61+
})
62+
63+
test('top-level lookup does not resolve prototype members', () => {
64+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
65+
expect(format('{toString}', { name: 'Bob' })).toBe('{toString}')
66+
expect(warn).toHaveBeenCalledWith('Formatting error: The field toString is not defined.')
67+
})
68+
69+
test('bracket syntax with no matching literal key falls back to the raw template', () => {
70+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
71+
expect(format('first item is {a[0]}', { a: ['zero'] })).toBe('first item is {a[0]}')
72+
expect(warn).toHaveBeenCalledWith('Formatting error: The field a[0] is not defined.')
73+
})
74+
75+
test('a literal attribute key containing brackets keeps resolving', () => {
76+
expect(format('first item is {a[0]}', { 'a[0]': 'zero' })).toBe('first item is zero')
77+
})
78+
})

packages/logfire-api/src/formatter.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,35 +56,33 @@ class ChunksFormatter {
5656

5757
// Equivalent to Python's getField method
5858
getField(fieldName: string, record: Record<string, unknown>): [unknown, string] {
59-
if (fieldName.includes('.') || fieldName.includes('[')) {
60-
// Handle nested field access like "a.b" or "a[b]"
61-
try {
62-
// Simple nested property access (this is a simplification)
63-
const parts = fieldName.split('.')
64-
let obj = record[parts[0] ?? '']
65-
for (let i = 1; i < parts.length; i++) {
66-
const key = parts[i] ?? ''
67-
if (key in record) {
68-
obj = record[key]
69-
} else {
70-
throw new KnownFormattingError(`The field ${fieldName} is not an object.`)
71-
}
72-
}
73-
return [obj, parts[0] ?? '']
74-
} catch {
75-
// Try getting the whole thing from object
76-
if (fieldName in record) {
77-
return [record[fieldName], fieldName]
78-
}
59+
// A literal attribute key always wins, so OTel-style dotted names like
60+
// "http.method" keep resolving even when nested traversal is possible.
61+
// Object.hasOwn keeps prototype members like `toString` from resolving.
62+
if (Object.hasOwn(record, fieldName)) {
63+
return [record[fieldName], fieldName]
64+
}
65+
66+
if (fieldName.includes('.')) {
67+
// Handle nested field access like "a.b" by walking into the record value
68+
const parts = fieldName.split('.')
69+
const firstKey = parts[0] ?? ''
70+
if (!Object.hasOwn(record, firstKey)) {
7971
throw new KnownFormattingError(`The field ${fieldName} is not defined.`)
8072
}
81-
} else {
82-
// Simple field access
83-
if (fieldName in record) {
84-
return [record[fieldName], fieldName]
73+
let obj: unknown = record[firstKey]
74+
for (let i = 1; i < parts.length; i++) {
75+
const key = parts[i] ?? ''
76+
if (typeof obj === 'object' && obj !== null && Object.hasOwn(obj, key)) {
77+
obj = (obj as Record<string, unknown>)[key]
78+
} else {
79+
throw new KnownFormattingError(`The field ${fieldName} is not defined.`)
80+
}
8581
}
86-
throw new KnownFormattingError(`The field ${fieldName} is not defined.`)
82+
return [obj, firstKey]
8783
}
84+
85+
throw new KnownFormattingError(`The field ${fieldName} is not defined.`)
8886
}
8987

9088
parse(formatString: string): [string, null | string, null | string, null | string][] {

0 commit comments

Comments
 (0)