Skip to content

Commit 46322c9

Browse files
alexmojakidmontagu
andauthored
Handle message formatting errors (#329)
Co-authored-by: David Montague <[email protected]>
1 parent 201994e commit 46322c9

File tree

5 files changed

+199
-64
lines changed

5 files changed

+199
-64
lines changed

logfire/_internal/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
from .exporters.tail_sampling import TailSamplingOptions, TailSamplingProcessor
7676
from .integrations.executors import instrument_executors
7777
from .metrics import ProxyMeterProvider, configure_metrics
78-
from .scrubbing import BaseScrubber, NoopScrubber, Scrubber, ScrubbingOptions, ScrubCallback
78+
from .scrubbing import NOOP_SCRUBBER, BaseScrubber, Scrubber, ScrubbingOptions, ScrubCallback
7979
from .stack_info import get_user_frame_and_stacklevel
8080
from .tracer import PendingSpanProcessor, ProxyTracerProvider
8181
from .utils import UnexpectedResponse, ensure_data_dir_exists, get_version, read_toml_file, suppress_instrumentation
@@ -402,7 +402,7 @@ def _load_configuration(
402402
scrubbing = ScrubbingOptions()
403403
self.scrubbing: ScrubbingOptions | Literal[False] = scrubbing
404404
self.scrubber: BaseScrubber = (
405-
Scrubber(scrubbing.extra_patterns, scrubbing.callback) if scrubbing else NoopScrubber()
405+
Scrubber(scrubbing.extra_patterns, scrubbing.callback) if scrubbing else NOOP_SCRUBBER
406406
)
407407

408408
if isinstance(console, dict):

logfire/_internal/formatter.py

Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from functools import lru_cache
99
from string import Formatter
1010
from types import CodeType
11-
from typing import Any, Final, Literal, Mapping
11+
from typing import Any, Final, Literal
1212

1313
import executing
1414
from typing_extensions import NotRequired, TypedDict
@@ -17,8 +17,8 @@
1717
from logfire._internal.stack_info import get_user_frame_and_stacklevel
1818

1919
from .constants import ATTRIBUTES_SCRUBBED_KEY, MESSAGE_FORMATTED_VALUE_LENGTH_LIMIT
20-
from .scrubbing import BaseScrubber, ScrubbedNote
21-
from .utils import truncate_string
20+
from .scrubbing import NOOP_SCRUBBER, BaseScrubber, ScrubbedNote
21+
from .utils import log_internal_error, truncate_string
2222

2323

2424
class LiteralChunk(TypedDict):
@@ -38,7 +38,7 @@ class ChunksFormatter(Formatter):
3838
def chunks(
3939
self,
4040
format_string: str,
41-
kwargs: Mapping[str, Any],
41+
kwargs: dict[str, Any],
4242
*,
4343
scrubber: BaseScrubber,
4444
fstring_frame: types.FrameType | None = None,
@@ -64,7 +64,7 @@ def chunks(
6464

6565
def _fstring_chunks(
6666
self,
67-
kwargs: Mapping[str, Any],
67+
kwargs: dict[str, Any],
6868
scrubber: BaseScrubber,
6969
frame: types.FrameType,
7070
) -> tuple[list[LiteralChunk | ArgChunk], dict[str, Any], str] | None:
@@ -236,18 +236,15 @@ def _fstring_chunks(
236236
def _vformat_chunks(
237237
self,
238238
format_string: str,
239-
kwargs: Mapping[str, Any],
239+
kwargs: dict[str, Any],
240240
*,
241241
scrubber: BaseScrubber,
242242
recursion_depth: int = 2,
243-
auto_arg_index: int = 0,
244243
) -> tuple[list[LiteralChunk | ArgChunk], dict[str, Any]]:
245244
"""Copied from `string.Formatter._vformat` https://github.com/python/cpython/blob/v3.11.4/Lib/string.py#L198-L247 then altered."""
246-
if recursion_depth < 0: # pragma: no cover
247-
raise ValueError('Max string recursion exceeded')
245+
if recursion_depth < 0:
246+
raise KnownFormattingError('Max format spec recursion exceeded')
248247
result: list[LiteralChunk | ArgChunk] = []
249-
# here just to satisfy the call to `_vformat` below
250-
used_args: set[str | int] = set()
251248
# We currently don't use positional arguments
252249
args = ()
253250
scrubbed: list[ScrubbedNote] = []
@@ -260,19 +257,8 @@ def _vformat_chunks(
260257
if field_name is not None:
261258
# this is some markup, find the object and do
262259
# the formatting
263-
264-
# handle arg indexing when empty field_names are given.
265-
if field_name == '': # pragma: no cover
266-
if auto_arg_index is False:
267-
raise ValueError('cannot switch from manual field specification to automatic field numbering')
268-
field_name = str(auto_arg_index)
269-
auto_arg_index += 1
270-
elif field_name.isdigit(): # pragma: no cover
271-
if auto_arg_index:
272-
raise ValueError('cannot switch from manual field to automatic field numbering')
273-
# disable auto arg incrementing, if it gets
274-
# used later on, then an exception will be raised
275-
auto_arg_index = False
260+
if field_name == '':
261+
raise KnownFormattingError('Empty curly brackets `{}` are not allowed. A field name is required.')
276262

277263
# ADDED BY US:
278264
if field_name.endswith('='):
@@ -282,42 +268,47 @@ def _vformat_chunks(
282268
result.append({'v': field_name, 't': 'lit'})
283269
field_name = field_name[:-1]
284270

285-
# we have lots of type ignores here since Formatter is typed as requiring kwargs to be a
286-
# dict, but we expect `Sequence[dict[str, Any]]` in get_value - effectively `Formatter` is really
287-
# generic over the type of the kwargs
288-
289271
# given the field_name, find the object it references
290272
# and the argument it came from
291273
try:
292274
obj, _arg_used = self.get_field(field_name, args, kwargs)
293-
except KeyError as exc:
275+
except IndexError:
276+
raise KnownFormattingError('Numeric field names are not allowed.')
277+
except KeyError as exc1:
278+
if str(exc1) == repr(field_name):
279+
raise KnownFormattingError(f'The field {{{field_name}}} is not defined.') from exc1
280+
294281
try:
295-
# fall back to getting a key with the dots in the name
282+
# field_name is something like 'a.b' or 'a[b]'
283+
# Evaluating that expression failed, so now just try getting the whole thing from kwargs.
284+
# In particular, OTEL attributes with dots in their names are normal and handled here.
296285
obj = kwargs[field_name]
297-
except KeyError:
298-
obj = '{' + field_name + '}'
299-
field = exc.args[0]
300-
_frame, stacklevel = get_user_frame_and_stacklevel()
301-
warnings.warn(f"The field '{field}' is not defined.", stacklevel=stacklevel)
286+
except KeyError as exc2:
287+
# e.g. neither 'a' nor 'a.b' is defined
288+
raise KnownFormattingError(f'The fields {exc1} and {exc2} are not defined.') from exc2
289+
except Exception as exc:
290+
raise KnownFormattingError(f'Error getting field {{{field_name}}}: {exc}') from exc
302291

303292
# do any conversion on the resulting object
304293
if conversion is not None:
305-
obj = self.convert_field(obj, conversion)
294+
try:
295+
obj = self.convert_field(obj, conversion)
296+
except Exception as exc:
297+
raise KnownFormattingError(f'Error converting field {{{field_name}}}: {exc}') from exc
306298

307299
# expand the format spec, if needed
308-
format_spec, auto_arg_index = self._vformat(
309-
format_spec, # type: ignore[arg-type]
310-
args,
311-
kwargs,
312-
used_args, # TODO(lig): using `_arg_used` from above seems logical here but needs more thorough testing
313-
recursion_depth - 1,
314-
auto_arg_index=auto_arg_index,
300+
format_spec_chunks, _ = self._vformat_chunks(
301+
format_spec or '', kwargs, scrubber=NOOP_SCRUBBER, recursion_depth=recursion_depth - 1
315302
)
303+
format_spec = ''.join(chunk['v'] for chunk in format_spec_chunks)
316304

317305
if obj is None:
318306
value = self.NONE_REPR
319307
else:
320-
value = self.format_field(obj, format_spec)
308+
try:
309+
value = self.format_field(obj, format_spec)
310+
except Exception as exc:
311+
raise KnownFormattingError(f'Error formatting field {{{field_name}}}: {exc}') from exc
321312
value, value_scrubbed = self._clean_value(field_name, value, scrubber)
322313
scrubbed += value_scrubbed
323314
d: ArgChunk = {'v': value, 't': 'arg'}
@@ -361,13 +352,23 @@ def logfire_format_with_magic(
361352
# 2. A dictionary of extra attributes to add to the span/log.
362353
# These can come from evaluating values in f-strings.
363354
# 3. The final message template, which may differ from `format_string` if it was an f-string.
364-
chunks, extra_attrs, new_template = chunks_formatter.chunks(
365-
format_string,
366-
kwargs,
367-
scrubber=scrubber,
368-
fstring_frame=fstring_frame,
369-
)
370-
return ''.join(chunk['v'] for chunk in chunks), extra_attrs, new_template
355+
try:
356+
chunks, extra_attrs, new_template = chunks_formatter.chunks(
357+
format_string,
358+
kwargs,
359+
scrubber=scrubber,
360+
fstring_frame=fstring_frame,
361+
)
362+
return ''.join(chunk['v'] for chunk in chunks), extra_attrs, new_template
363+
except KnownFormattingError as e:
364+
warn_formatting(str(e) or str(e.__cause__))
365+
except Exception:
366+
# This is an unexpected error that likely indicates a bug in our logic.
367+
# Handle it here so that the span/log still gets created, just without a nice message.
368+
log_internal_error()
369+
370+
# Formatting failed, so just use the original format string as the message.
371+
return format_string, {}, format_string
371372

372373

373374
@lru_cache
@@ -450,3 +451,29 @@ def warn_inspect_arguments(msg: str, stacklevel: int):
450451
) + msg
451452
warnings.warn(msg, InspectArgumentsFailedWarning, stacklevel=stacklevel)
452453
logfire.log('warn', msg)
454+
455+
456+
class KnownFormattingError(Exception):
457+
"""An error raised when there's something wrong with a format string or the field values.
458+
459+
In other words this should correspond to errors that would be raised when using `str.format`,
460+
and generally indicate a user error, most likely that they weren't trying to pass a template string at all.
461+
"""
462+
463+
464+
class FormattingFailedWarning(UserWarning):
465+
pass
466+
467+
468+
def warn_formatting(msg: str):
469+
_frame, stacklevel = get_user_frame_and_stacklevel()
470+
warnings.warn(
471+
f'\n'
472+
f' Ensure you are either:\n'
473+
' (1) passing an f-string directly, with inspect_arguments enabled and working, or\n'
474+
' (2) passing a literal `str.format`-style template, not a preformatted string.\n'
475+
' See https://docs.pydantic.dev/logfire/guides/onboarding_checklist/add_manual_tracing/#messages-and-span-names.\n'
476+
f' The problem was: {msg}',
477+
stacklevel=stacklevel,
478+
category=FormattingFailedWarning,
479+
)

logfire/_internal/scrubbing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ def scrub_value(self, path: JsonPath, value: Any) -> tuple[Any, list[ScrubbedNot
141141
return value, []
142142

143143

144+
NOOP_SCRUBBER = NoopScrubber()
145+
146+
144147
class Scrubber(BaseScrubber):
145148
"""Redacts potentially sensitive data."""
146149

tests/test_formatter.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import contextlib
12
from collections import ChainMap
3+
from types import SimpleNamespace
24
from typing import Any, Mapping
35

6+
import pytest
47
from inline_snapshot import snapshot
58

6-
from logfire._internal.formatter import chunks_formatter, logfire_format
7-
from logfire._internal.scrubbing import Scrubber
9+
from logfire._internal.formatter import FormattingFailedWarning, chunks_formatter, logfire_format
10+
from logfire._internal.scrubbing import NOOP_SCRUBBER, JsonPath, Scrubber
811

912

1013
def chunks(format_string: str, kwargs: Mapping[str, Any]):
11-
result, _extra_attrs, _span_name = chunks_formatter.chunks(format_string, kwargs, scrubber=Scrubber([]))
14+
result, _extra_attrs, _span_name = chunks_formatter.chunks(format_string, dict(kwargs), scrubber=Scrubber([]))
1215
return result
1316

1417

@@ -81,3 +84,97 @@ def test_truncate():
8184
' 3'
8285
)
8386
assert len(message) == snapshot(261)
87+
88+
89+
@contextlib.contextmanager
90+
def warns_failed(msg: str):
91+
with pytest.warns(FormattingFailedWarning) as record:
92+
yield
93+
[warning] = record
94+
assert str(warning.message).endswith(f'The problem was: {msg}')
95+
96+
97+
class BadRepr:
98+
def __repr__(self):
99+
raise ValueError('bad repr')
100+
101+
102+
def test_conversion_error():
103+
with warns_failed('Error converting field {a}: bad repr'):
104+
logfire_format('{a!r}', {'a': BadRepr()}, NOOP_SCRUBBER)
105+
106+
107+
def test_formatting_error():
108+
with warns_failed('Error formatting field {a}: bad repr'):
109+
logfire_format('{a}', {'a': BadRepr()}, NOOP_SCRUBBER)
110+
111+
112+
def test_formatting_error_with_spec():
113+
with warns_failed("Error formatting field {a}: Unknown format code 'c' for object of type 'str'"):
114+
logfire_format('{a:c}', {'a': 'b'}, NOOP_SCRUBBER)
115+
116+
117+
def test_format_spec_error():
118+
with warns_failed("Error formatting field {b}: Unknown format code 'c' for object of type 'str'"):
119+
logfire_format('{a:{b:c}}', {'a': '1', 'b': '2'}, NOOP_SCRUBBER)
120+
121+
122+
def test_recursion_error():
123+
with warns_failed('Max format spec recursion exceeded'):
124+
logfire_format('{a:{a:{a:{a:5}}}}', {'a': 1}, NOOP_SCRUBBER)
125+
126+
127+
def test_missing_field():
128+
with warns_failed('The field {a} is not defined.'):
129+
logfire_format('{a}', {}, NOOP_SCRUBBER)
130+
131+
132+
def test_missing_field_with_dot():
133+
assert logfire_format('{a.b}', {'a.b': 123}, NOOP_SCRUBBER) == '123'
134+
assert logfire_format('{a.b}', {'a': SimpleNamespace(b=456), 'a.b': 123}, NOOP_SCRUBBER) == '456'
135+
136+
with warns_failed("The fields 'a' and 'a.b' are not defined."):
137+
logfire_format('{a.b}', {'b': 1}, NOOP_SCRUBBER)
138+
139+
140+
def test_missing_field_with_brackets():
141+
assert logfire_format('{a[b]}', {'a[b]': 123}, NOOP_SCRUBBER) == '123'
142+
assert logfire_format('{a[b]}', {'a': {'b': 456}, 'b': 1}, NOOP_SCRUBBER) == '456'
143+
144+
with warns_failed("The fields 'a' and 'a[b]' are not defined."):
145+
logfire_format('{a[b]}', {'b': 1}, NOOP_SCRUBBER)
146+
147+
148+
def test_missing_key_in_brackets():
149+
with warns_failed("The fields 'b' and 'a[b]' are not defined."):
150+
logfire_format('{a[b]}', {'a': {}, 'b': 1}, NOOP_SCRUBBER)
151+
152+
153+
def test_empty_braces():
154+
with warns_failed('Empty curly brackets `{}` are not allowed. A field name is required.'):
155+
logfire_format('{}', {}, NOOP_SCRUBBER)
156+
157+
158+
def test_empty_braces_in_brackets():
159+
with warns_failed('Error getting field {a[]}: Empty attribute in format string'):
160+
logfire_format('{a[]}', {'a': {}}, NOOP_SCRUBBER)
161+
162+
163+
def test_numbered_fields():
164+
with warns_failed('Numeric field names are not allowed.'):
165+
logfire_format('{1}', {'1': 'a'}, NOOP_SCRUBBER)
166+
with warns_failed('Numeric field names are not allowed.'):
167+
logfire_format('{2.3}', {'2': 'a'}, NOOP_SCRUBBER)
168+
169+
170+
class BadScrubber(Scrubber):
171+
def scrub_value(self, path: JsonPath, value: Any):
172+
raise ValueError('bad scrubber')
173+
174+
175+
def test_internal_exception_formatting(caplog: pytest.LogCaptureFixture):
176+
assert logfire_format('{a}', {'a': 'b'}, BadScrubber([])) == '{a}'
177+
178+
assert len(caplog.records) == 1
179+
assert caplog.records[0].message == 'Internal error in Logfire'
180+
assert str(caplog.records[0].exc_info[1]) == 'bad scrubber' # type: ignore

0 commit comments

Comments
 (0)