Skip to content

Commit fd6b734

Browse files
committed
feat(logger): support log message templates
Allow `emit_log()` and severity helpers to interpolate named parameters with Python format strings. Preserve the original template and parameter values in `braintrust.template` metadata so repeated messages remain queryable by their stable structure. Missing placeholders and malformed templates remain unchanged so logging does not disrupt application code.
1 parent 29dcbc0 commit fd6b734

3 files changed

Lines changed: 119 additions & 26 deletions

File tree

‎py/src/braintrust/logger.py‎

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@
142142
}
143143

144144

145+
class _LogTemplateParameters(dict[str, object]):
146+
"""Preserve placeholders whose values were not provided."""
147+
148+
def __missing__(self, key: str) -> str:
149+
return "{" + key + "}"
150+
151+
145152
@dataclasses.dataclass
146153
class Logs3OverflowInputRow:
147154
object_ids: dict[str, Any]
@@ -5947,24 +5954,48 @@ def emit_log(
59475954
self,
59485955
body: Any,
59495956
level: LogLevel,
5950-
metadata: Metadata | None = None,
5957+
metadata: dict[str, Any] | None = None,
5958+
**parameters: object,
59515959
) -> str:
59525960
"""Capture a log record, associating it with the active span when one exists.
59535961
59545962
The log is stored as an independent row. If a Braintrust or OpenTelemetry
59555963
span is active, the row reuses its span and trace IDs for correlation.
59565964
Otherwise, the row uses this logger's baseline trace ID.
59575965
5958-
:param body: The log body. May be any JSON-serializable value.
5966+
String bodies may contain ``str.format``-style placeholders. Keyword
5967+
parameters are interpolated into the body and retained in metadata along
5968+
with the original template. Missing parameters remain as placeholders.
5969+
5970+
:param body: The log body. May be any JSON-serializable value when no
5971+
template parameters are provided.
59595972
:param level: The OpenTelemetry log severity: ``trace``, ``debug``,
59605973
``info``, ``warn``, ``error``, or ``fatal``.
59615974
:param metadata: Optional JSON-serializable attributes for the log.
5975+
:param parameters: Values for named placeholders in a string body.
59625976
:returns: The unique ID of the captured log row.
59635977
"""
59645978
if level not in _OTEL_LOG_LEVELS:
59655979
valid_levels = ", ".join(_OTEL_LOG_LEVELS)
59665980
raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}")
59675981

5982+
rendered_body = body
5983+
rendered_metadata = metadata
5984+
if parameters:
5985+
if not isinstance(body, str):
5986+
raise TypeError("Log body must be a string when template parameters are provided")
5987+
rendered_metadata = dict(metadata) if metadata is not None else {}
5988+
rendered_metadata.update(
5989+
{f"braintrust.template.parameter.{key}": value for key, value in parameters.items()}
5990+
)
5991+
rendered_metadata["braintrust.template"] = body
5992+
try:
5993+
rendered_body = body.format_map(_LogTemplateParameters(parameters))
5994+
except Exception:
5995+
# Logging should not disrupt the application because a template
5996+
# contains malformed braces or an unsupported format specifier.
5997+
rendered_body = body
5998+
59685999
captured_at = time.time()
59696000
span_info = self.state.context_manager.get_current_span_info()
59706001
severity_number = _OTEL_LOG_LEVELS[level]
@@ -5976,9 +6007,13 @@ def emit_log(
59766007
span_id=span_info.span_id if span_info else None,
59776008
root_span_id=span_info.trace_id if span_info else self._baseline_trace_id,
59786009
lookup_span_parent=False,
5979-
output=body,
5980-
error=body if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(body, str) else None,
5981-
metadata=metadata,
6010+
output=rendered_body,
6011+
error=(
6012+
rendered_body
6013+
if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(rendered_body, str)
6014+
else None
6015+
),
6016+
metadata=rendered_metadata,
59826017
context={
59836018
"otel": {
59846019
"signal": "logs",
@@ -5997,29 +6032,29 @@ def emit_log(
59976032

59986033
return span.id
59996034

6000-
def trace(self, body: Any, metadata: Metadata | None = None) -> str:
6035+
def trace(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60016036
"""Capture a log at OpenTelemetry TRACE severity."""
6002-
return self.emit_log(body=body, level="trace", metadata=metadata)
6037+
return self.emit_log(body=body, level="trace", metadata=metadata, **parameters)
60036038

6004-
def debug(self, body: Any, metadata: Metadata | None = None) -> str:
6039+
def debug(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60056040
"""Capture a log at OpenTelemetry DEBUG severity."""
6006-
return self.emit_log(body=body, level="debug", metadata=metadata)
6041+
return self.emit_log(body=body, level="debug", metadata=metadata, **parameters)
60076042

6008-
def info(self, body: Any, metadata: Metadata | None = None) -> str:
6043+
def info(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60096044
"""Capture a log at OpenTelemetry INFO severity."""
6010-
return self.emit_log(body=body, level="info", metadata=metadata)
6045+
return self.emit_log(body=body, level="info", metadata=metadata, **parameters)
60116046

6012-
def warn(self, body: Any, metadata: Metadata | None = None) -> str:
6047+
def warn(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60136048
"""Capture a log at OpenTelemetry WARN severity."""
6014-
return self.emit_log(body=body, level="warn", metadata=metadata)
6049+
return self.emit_log(body=body, level="warn", metadata=metadata, **parameters)
60156050

6016-
def error(self, body: Any, metadata: Metadata | None = None) -> str:
6051+
def error(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60176052
"""Capture a log at OpenTelemetry ERROR severity."""
6018-
return self.emit_log(body=body, level="error", metadata=metadata)
6053+
return self.emit_log(body=body, level="error", metadata=metadata, **parameters)
60196054

6020-
def fatal(self, body: Any, metadata: Metadata | None = None) -> str:
6055+
def fatal(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str:
60216056
"""Capture a log at OpenTelemetry FATAL severity."""
6022-
return self.emit_log(body=body, level="fatal", metadata=metadata)
6057+
return self.emit_log(body=body, level="fatal", metadata=metadata, **parameters)
60236058

60246059
def log_feedback(
60256060
self,

‎py/src/braintrust/test_logger.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,57 @@ def test_logger_log_level_helpers(with_memory_logger, method_name, severity_numb
14881488
assert row["context"]["otel"]["log"]["severity_number"] == severity_number
14891489

14901490

1491+
def test_logger_log_helpers_render_template_parameters(with_memory_logger):
1492+
test_logger = init_test_logger(__name__)
1493+
1494+
log_id = test_logger.info(
1495+
"User {user_id} paid {amount:.2f} with {method}",
1496+
metadata={"source": "checkout"},
1497+
user_id="user-123",
1498+
amount=12.5,
1499+
)
1500+
1501+
[row] = with_memory_logger.pop()
1502+
assert row["id"] == log_id
1503+
assert row["output"] == "User user-123 paid 12.50 with {method}"
1504+
assert row["metadata"] == {
1505+
"source": "checkout",
1506+
"braintrust.template.parameter.user_id": "user-123",
1507+
"braintrust.template.parameter.amount": 12.5,
1508+
"braintrust.template": "User {user_id} paid {amount:.2f} with {method}",
1509+
}
1510+
1511+
1512+
def test_logger_error_uses_rendered_template_as_error(with_memory_logger):
1513+
test_logger = init_test_logger(__name__)
1514+
1515+
test_logger.error("Payment {payment_id} failed", payment_id="pay-123")
1516+
1517+
[row] = with_memory_logger.pop()
1518+
assert row["output"] == "Payment pay-123 failed"
1519+
assert row["error"] == "Payment pay-123 failed"
1520+
1521+
1522+
def test_logger_log_helpers_do_not_format_without_parameters(with_memory_logger):
1523+
test_logger = init_test_logger(__name__)
1524+
1525+
test_logger.info('{"key": "{value}"}')
1526+
1527+
[row] = with_memory_logger.pop()
1528+
assert row["output"] == '{"key": "{value}"}'
1529+
assert not row.get("metadata")
1530+
1531+
1532+
def test_logger_log_template_parameters_are_safely_serialized(with_memory_logger):
1533+
test_logger = init_test_logger(__name__)
1534+
1535+
test_logger.warn("Request failed: {error}", error=ValueError("bad request"))
1536+
1537+
[row] = with_memory_logger.pop()
1538+
assert row["output"] == "Request failed: bad request"
1539+
assert row["metadata"]["braintrust.template.parameter.error"] == "bad request"
1540+
1541+
14911542
def test_logger_emit_log_rejects_invalid_level(with_memory_logger):
14921543
test_logger = init_test_logger(__name__)
14931544

‎py/src/braintrust/type_tests/test_metadata_types.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,27 @@ def dict(self, *, exclude_none: bool = False) -> Mapping[str, Any]:
1818

1919
def accepts_logger_metadata(logger: Logger) -> None:
2020
mapping_metadata: Mapping[str, Any] = {"user_id": "user-1"}
21+
dict_metadata: dict[str, Any] = {"user_id": "user-1"}
2122

2223
logger.log(metadata=mapping_metadata)
2324
logger.log(metadata=PydanticV2Metadata())
2425
logger.log(metadata=PydanticV1Metadata())
2526

26-
logger.emit_log(body="message", level="info", metadata=mapping_metadata)
27-
logger.emit_log(body="message", level="info", metadata=PydanticV2Metadata())
28-
logger.emit_log(body="message", level="info", metadata=PydanticV1Metadata())
29-
logger.trace("message", metadata=mapping_metadata)
30-
logger.debug("message", metadata=PydanticV2Metadata())
31-
logger.info("message", metadata=PydanticV1Metadata())
32-
logger.warn("message", metadata=mapping_metadata)
33-
logger.error("message", metadata=PydanticV2Metadata())
34-
logger.fatal("message", metadata=PydanticV1Metadata())
27+
logger.emit_log(body="message", level="info", metadata=dict_metadata)
28+
logger.trace("message", metadata=dict_metadata)
29+
logger.debug("message", metadata=dict_metadata)
30+
logger.info("message", metadata=dict_metadata)
31+
logger.warn("message", metadata=dict_metadata)
32+
logger.error("message", metadata=dict_metadata)
33+
logger.fatal("message", metadata=dict_metadata)
34+
35+
logger.emit_log("User {user_id}", "info", metadata=dict_metadata, user_id="user-1")
36+
logger.trace("User {user_id}", metadata=dict_metadata, user_id="user-1")
37+
logger.debug("User {user_id}", metadata=dict_metadata, user_id="user-1")
38+
logger.info("User {user_id}", metadata=dict_metadata, user_id="user-1")
39+
logger.warn("User {user_id}", metadata=dict_metadata, user_id="user-1")
40+
logger.error("User {user_id}", metadata=dict_metadata, user_id="user-1")
41+
logger.fatal("User {user_id}", metadata=dict_metadata, user_id="user-1")
3542

3643
logger.log_feedback(id="event-id", metadata=mapping_metadata)
3744
logger.log_feedback(id="event-id", metadata=PydanticV2Metadata())

0 commit comments

Comments
 (0)