From f878134c9250c3fdc973cb49e775df58f7144a04 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 11:06:20 -0400 Subject: [PATCH 1/6] feat(logger): add OpenTelemetry-compatible log emission Add `Logger.emit_log()` so applications can emit independent `type="log"` rows without constructing spans manually. Correlate rows with the active Braintrust or OpenTelemetry context when available. Seed each logger with a baseline trace ID for unscoped logs. This keeps consecutive logs from one logger together while preserving unique row and span IDs, and avoids grouping logs emitted by separate logger instances. Map the six base OpenTelemetry severities into `context.otel.log` and add `trace()`, `debug()`, `info()`, `warn()`, `error()`, and `fatal()` helpers. logger.error("Payment failed", metadata={"payment_id": "pay_123"}) helper -> emit_log -> `type="log"` row |-- active span: reuse trace/span IDs `-- no span: reuse logger trace, generate span ID --- py/src/braintrust/logger.py | 89 +++++++++++++++ .../otel/test_otel_bt_integration.py | 16 +++ py/src/braintrust/span_types.py | 1 + py/src/braintrust/test_logger.py | 101 ++++++++++++++++++ .../type_tests/test_metadata_types.py | 10 ++ 5 files changed, 217 insertions(+) diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index f3c854ebb..8178d6d34 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -131,6 +131,16 @@ # 6 MB for the AWS lambda gateway (from our own testing). DEFAULT_MAX_REQUEST_SIZE = 6 * 1024 * 1024 +LogLevel = Literal["trace", "debug", "info", "warn", "error", "fatal"] +_OTEL_LOG_LEVELS: dict[LogLevel, int] = { + "trace": 1, + "debug": 5, + "info": 9, + "warn": 13, + "error": 17, + "fatal": 21, +} + @dataclasses.dataclass class Logs3OverflowInputRow: @@ -5896,6 +5906,7 @@ def __init__( # fallbacks when generating links self._link_args = link_args self.state = state or _state + self._baseline_trace_id = self.state.id_generator.get_trace_id() @property def org_id(self) -> str: @@ -5974,6 +5985,84 @@ def log( return span.id + def emit_log( + self, + body: Any, + level: LogLevel, + metadata: Metadata | None = None, + ) -> str: + """Capture a log record, associating it with the active span when one exists. + + The log is stored as an independent row. If a Braintrust or OpenTelemetry + span is active, the row reuses its span and trace IDs for correlation. + Otherwise, the row uses this logger's baseline trace ID. + + :param body: The log body. May be any JSON-serializable value. + :param level: The OpenTelemetry log severity: ``trace``, ``debug``, + ``info``, ``warn``, ``error``, or ``fatal``. + :param metadata: Optional JSON-serializable attributes for the log. + :returns: The unique ID of the captured log row. + """ + if level not in _OTEL_LOG_LEVELS: + valid_levels = ", ".join(_OTEL_LOG_LEVELS) + raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}") + + captured_at = time.time() + span_info = self.state.context_manager.get_current_span_info() + severity_number = _OTEL_LOG_LEVELS[level] + span = self._start_span_impl( + name="Log", + type=SpanTypeAttribute.LOG, + start_time=captured_at, + set_current=False, + span_id=span_info.span_id if span_info else None, + root_span_id=span_info.trace_id if span_info else self._baseline_trace_id, + lookup_span_parent=False, + output=body, + error=body if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(body, str) else None, + metadata=metadata, + context={ + "otel": { + "signal": "logs", + "log": { + "time_unix_nano": str(round(captured_at * 1_000_000_000)), + "severity_number": severity_number, + "severity_text": level.upper(), + }, + } + }, + ) + span.end(end_time=captured_at) + + if not self.async_flush: + self.flush() + + return span.id + + def trace(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry TRACE severity.""" + return self.emit_log(body=body, level="trace", metadata=metadata) + + def debug(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry DEBUG severity.""" + return self.emit_log(body=body, level="debug", metadata=metadata) + + def info(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry INFO severity.""" + return self.emit_log(body=body, level="info", metadata=metadata) + + def warn(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry WARN severity.""" + return self.emit_log(body=body, level="warn", metadata=metadata) + + def error(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry ERROR severity.""" + return self.emit_log(body=body, level="error", metadata=metadata) + + def fatal(self, body: Any, metadata: Metadata | None = None) -> str: + """Capture a log at OpenTelemetry FATAL severity.""" + return self.emit_log(body=body, level="fatal", metadata=metadata) + def log_feedback( self, id: str, diff --git a/py/src/braintrust/otel/test_otel_bt_integration.py b/py/src/braintrust/otel/test_otel_bt_integration.py index 579082d92..b6d9ebfff 100644 --- a/py/src/braintrust/otel/test_otel_bt_integration.py +++ b/py/src/braintrust/otel/test_otel_bt_integration.py @@ -122,6 +122,22 @@ def test_mixed_otel_bt_tracing_with_bt_logger_first(otel_fixture): assert s2_span_id in s3["span_parents"] +def test_emit_log_uses_active_otel_span(otel_fixture): + logger = init_test_logger(__name__) + tracer = otel_fixture.tracer + memory_logger = otel_fixture.memory_logger + + with tracer.start_as_current_span("owner") as owner: + log_id = logger.emit_log(body="Inside OTel span", level="info") + owner_context = owner.get_span_context() + + [log_row] = memory_logger.pop() + assert log_row["id"] == log_id + assert log_row["span_id"] == format(owner_context.span_id, "016x") + assert log_row["root_span_id"] == format(owner_context.trace_id, "032x") + assert not log_row.get("span_parents") + + def test_mixed_otel_bt_tracing_with_experiment_parent(otel_fixture): experiment = init_test_exp("otel-bt-mixed", "test-mixed-tracing-experiment") tracer = otel_fixture.tracer diff --git a/py/src/braintrust/span_types.py b/py/src/braintrust/span_types.py index b82a6f894..59ef74d6c 100644 --- a/py/src/braintrust/span_types.py +++ b/py/src/braintrust/span_types.py @@ -19,6 +19,7 @@ class SpanTypeAttribute(str, Enum): CLASSIFIER = "classifier" REVIEW = "review" QUESTION = "question" + LOG = "log" class SpanPurpose(str, Enum): diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index 7356a2732..d6255f192 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -1364,6 +1364,107 @@ def test_logger_log_accepts_model_dump_metadata(with_memory_logger): assert logs[0]["metadata"] == {"foo": "bar"} +def test_logger_emit_log_without_active_span(with_memory_logger): + test_logger = init_test_logger(__name__) + + first_id = test_logger.emit_log( + body="Payment failed", + level="error", + metadata={"payment_id": "pay_123"}, + ) + second_id = test_logger.emit_log(body="Retrying payment", level="info") + + logs = with_memory_logger.pop() + assert len(logs) == 2 + first, second = logs + assert first_id == first["id"] + assert second_id == second["id"] + assert first["id"] != second["id"] + assert first["span_id"] != second["span_id"] + assert first["root_span_id"] == second["root_span_id"] + assert not first.get("span_parents") + assert first["output"] == "Payment failed" + assert first["error"] == "Payment failed" + assert first["metadata"] == {"payment_id": "pay_123"} + assert first["span_attributes"]["name"] == "Log" + assert first["span_attributes"]["type"] == "log" + assert first["metrics"]["start"] == first["metrics"]["end"] + assert first["context"]["otel"]["signal"] == "logs" + assert first["context"]["otel"]["log"] == { + "time_unix_nano": str(round(first["metrics"]["start"] * 1_000_000_000)), + "severity_number": 17, + "severity_text": "ERROR", + } + assert "error" not in second + assert second["context"]["otel"]["log"]["severity_number"] == 9 + + +def test_logger_emit_log_uses_distinct_baseline_trace_per_logger(with_memory_logger): + first_logger = init_test_logger(f"{__name__}-first") + second_logger = init_test_logger(f"{__name__}-second") + + first_logger.info("first") + second_logger.info("second") + + first, second = with_memory_logger.pop() + assert first["root_span_id"] != second["root_span_id"] + + +def test_logger_emit_log_uses_active_span(with_memory_logger): + test_logger = init_test_logger(__name__) + + with test_logger.start_span(name="owner") as owner: + log_id = test_logger.emit_log(body="Inside span", level="debug", metadata={"attempt": 1}) + + rows = with_memory_logger.pop() + log_row = next(row for row in rows if row["id"] == log_id) + owner_row = next(row for row in rows if row["span_attributes"]["name"] == "owner") + assert log_row["id"] != owner_row["id"] + assert log_row["span_id"] == owner_row["span_id"] + assert log_row["root_span_id"] == owner_row["root_span_id"] + assert not log_row.get("span_parents") + assert log_row["context"]["otel"]["log"]["severity_number"] == 5 + + +@pytest.mark.parametrize( + ("level", "severity_number"), + [("trace", 1), ("debug", 5), ("info", 9), ("warn", 13), ("error", 17), ("fatal", 21)], +) +def test_logger_emit_log_maps_otel_log_levels(with_memory_logger, level, severity_number): + test_logger = init_test_logger(__name__) + + test_logger.emit_log(body="message", level=level) + + [row] = with_memory_logger.pop() + assert row["context"]["otel"]["log"]["severity_number"] == severity_number + assert row["context"]["otel"]["log"]["severity_text"] == level.upper() + + +@pytest.mark.parametrize( + ("method_name", "severity_number"), + [("trace", 1), ("debug", 5), ("info", 9), ("warn", 13), ("error", 17), ("fatal", 21)], +) +def test_logger_log_level_helpers(with_memory_logger, method_name, severity_number): + test_logger = init_test_logger(__name__) + + log_id = getattr(test_logger, method_name)("message", metadata={"source": method_name}) + + [row] = with_memory_logger.pop() + assert row["id"] == log_id + assert row["output"] == "message" + assert row["metadata"] == {"source": method_name} + assert row["context"]["otel"]["log"]["severity_number"] == severity_number + + +def test_logger_emit_log_rejects_invalid_level(with_memory_logger): + test_logger = init_test_logger(__name__) + + with pytest.raises(ValueError, match="Invalid log level"): + test_logger.emit_log(body="message", level="warning") + + assert with_memory_logger.pop() == [] + + def test_experiment_log_accepts_model_dump_metadata(with_memory_logger): experiment = init_test_exp("test-experiment", "test-project") diff --git a/py/src/braintrust/type_tests/test_metadata_types.py b/py/src/braintrust/type_tests/test_metadata_types.py index f00a8d1d0..a5bb799dd 100644 --- a/py/src/braintrust/type_tests/test_metadata_types.py +++ b/py/src/braintrust/type_tests/test_metadata_types.py @@ -23,6 +23,16 @@ def accepts_logger_metadata(logger: Logger) -> None: logger.log(metadata=PydanticV2Metadata()) logger.log(metadata=PydanticV1Metadata()) + logger.emit_log(body="message", level="info", metadata=mapping_metadata) + logger.emit_log(body="message", level="info", metadata=PydanticV2Metadata()) + logger.emit_log(body="message", level="info", metadata=PydanticV1Metadata()) + logger.trace("message", metadata=mapping_metadata) + logger.debug("message", metadata=PydanticV2Metadata()) + logger.info("message", metadata=PydanticV1Metadata()) + logger.warn("message", metadata=mapping_metadata) + logger.error("message", metadata=PydanticV2Metadata()) + logger.fatal("message", metadata=PydanticV1Metadata()) + logger.log_feedback(id="event-id", metadata=mapping_metadata) logger.log_feedback(id="event-id", metadata=PydanticV2Metadata()) logger.log_feedback(id="event-id", metadata=PydanticV1Metadata()) From 9a6a487939573d6c32c9ef80fd03c9630d56b699 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Sep 2026 11:22:17 -0400 Subject: [PATCH 2/6] 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. --- py/src/braintrust/logger.py | 69 ++++++++++++++----- py/src/braintrust/test_logger.py | 51 ++++++++++++++ .../type_tests/test_metadata_types.py | 25 ++++--- 3 files changed, 119 insertions(+), 26 deletions(-) diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index 8178d6d34..ad700d33c 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -142,6 +142,13 @@ } +class _LogTemplateParameters(dict[str, object]): + """Preserve placeholders whose values were not provided.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + @dataclasses.dataclass class Logs3OverflowInputRow: object_ids: dict[str, Any] @@ -5989,7 +5996,8 @@ def emit_log( self, body: Any, level: LogLevel, - metadata: Metadata | None = None, + metadata: dict[str, Any] | None = None, + **parameters: object, ) -> str: """Capture a log record, associating it with the active span when one exists. @@ -5997,16 +6005,39 @@ def emit_log( span is active, the row reuses its span and trace IDs for correlation. Otherwise, the row uses this logger's baseline trace ID. - :param body: The log body. May be any JSON-serializable value. + String bodies may contain ``str.format``-style placeholders. Keyword + parameters are interpolated into the body and retained in metadata along + with the original template. Missing parameters remain as placeholders. + + :param body: The log body. May be any JSON-serializable value when no + template parameters are provided. :param level: The OpenTelemetry log severity: ``trace``, ``debug``, ``info``, ``warn``, ``error``, or ``fatal``. :param metadata: Optional JSON-serializable attributes for the log. + :param parameters: Values for named placeholders in a string body. :returns: The unique ID of the captured log row. """ if level not in _OTEL_LOG_LEVELS: valid_levels = ", ".join(_OTEL_LOG_LEVELS) raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}") + rendered_body = body + rendered_metadata = metadata + if parameters: + if not isinstance(body, str): + raise TypeError("Log body must be a string when template parameters are provided") + rendered_metadata = dict(metadata) if metadata is not None else {} + rendered_metadata.update( + {f"braintrust.template.parameter.{key}": value for key, value in parameters.items()} + ) + rendered_metadata["braintrust.template"] = body + try: + rendered_body = body.format_map(_LogTemplateParameters(parameters)) + except Exception: + # Logging should not disrupt the application because a template + # contains malformed braces or an unsupported format specifier. + rendered_body = body + captured_at = time.time() span_info = self.state.context_manager.get_current_span_info() severity_number = _OTEL_LOG_LEVELS[level] @@ -6018,9 +6049,13 @@ def emit_log( span_id=span_info.span_id if span_info else None, root_span_id=span_info.trace_id if span_info else self._baseline_trace_id, lookup_span_parent=False, - output=body, - error=body if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(body, str) else None, - metadata=metadata, + output=rendered_body, + error=( + rendered_body + if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(rendered_body, str) + else None + ), + metadata=rendered_metadata, context={ "otel": { "signal": "logs", @@ -6039,29 +6074,29 @@ def emit_log( return span.id - def trace(self, body: Any, metadata: Metadata | None = None) -> str: + def trace(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry TRACE severity.""" - return self.emit_log(body=body, level="trace", metadata=metadata) + return self.emit_log(body=body, level="trace", metadata=metadata, **parameters) - def debug(self, body: Any, metadata: Metadata | None = None) -> str: + def debug(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry DEBUG severity.""" - return self.emit_log(body=body, level="debug", metadata=metadata) + return self.emit_log(body=body, level="debug", metadata=metadata, **parameters) - def info(self, body: Any, metadata: Metadata | None = None) -> str: + def info(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry INFO severity.""" - return self.emit_log(body=body, level="info", metadata=metadata) + return self.emit_log(body=body, level="info", metadata=metadata, **parameters) - def warn(self, body: Any, metadata: Metadata | None = None) -> str: + def warn(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry WARN severity.""" - return self.emit_log(body=body, level="warn", metadata=metadata) + return self.emit_log(body=body, level="warn", metadata=metadata, **parameters) - def error(self, body: Any, metadata: Metadata | None = None) -> str: + def error(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry ERROR severity.""" - return self.emit_log(body=body, level="error", metadata=metadata) + return self.emit_log(body=body, level="error", metadata=metadata, **parameters) - def fatal(self, body: Any, metadata: Metadata | None = None) -> str: + def fatal(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: """Capture a log at OpenTelemetry FATAL severity.""" - return self.emit_log(body=body, level="fatal", metadata=metadata) + return self.emit_log(body=body, level="fatal", metadata=metadata, **parameters) def log_feedback( self, diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index d6255f192..158de910f 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -1456,6 +1456,57 @@ def test_logger_log_level_helpers(with_memory_logger, method_name, severity_numb assert row["context"]["otel"]["log"]["severity_number"] == severity_number +def test_logger_log_helpers_render_template_parameters(with_memory_logger): + test_logger = init_test_logger(__name__) + + log_id = test_logger.info( + "User {user_id} paid {amount:.2f} with {method}", + metadata={"source": "checkout"}, + user_id="user-123", + amount=12.5, + ) + + [row] = with_memory_logger.pop() + assert row["id"] == log_id + assert row["output"] == "User user-123 paid 12.50 with {method}" + assert row["metadata"] == { + "source": "checkout", + "braintrust.template.parameter.user_id": "user-123", + "braintrust.template.parameter.amount": 12.5, + "braintrust.template": "User {user_id} paid {amount:.2f} with {method}", + } + + +def test_logger_error_uses_rendered_template_as_error(with_memory_logger): + test_logger = init_test_logger(__name__) + + test_logger.error("Payment {payment_id} failed", payment_id="pay-123") + + [row] = with_memory_logger.pop() + assert row["output"] == "Payment pay-123 failed" + assert row["error"] == "Payment pay-123 failed" + + +def test_logger_log_helpers_do_not_format_without_parameters(with_memory_logger): + test_logger = init_test_logger(__name__) + + test_logger.info('{"key": "{value}"}') + + [row] = with_memory_logger.pop() + assert row["output"] == '{"key": "{value}"}' + assert not row.get("metadata") + + +def test_logger_log_template_parameters_are_safely_serialized(with_memory_logger): + test_logger = init_test_logger(__name__) + + test_logger.warn("Request failed: {error}", error=ValueError("bad request")) + + [row] = with_memory_logger.pop() + assert row["output"] == "Request failed: bad request" + assert row["metadata"]["braintrust.template.parameter.error"] == "bad request" + + def test_logger_emit_log_rejects_invalid_level(with_memory_logger): test_logger = init_test_logger(__name__) diff --git a/py/src/braintrust/type_tests/test_metadata_types.py b/py/src/braintrust/type_tests/test_metadata_types.py index a5bb799dd..fa9234192 100644 --- a/py/src/braintrust/type_tests/test_metadata_types.py +++ b/py/src/braintrust/type_tests/test_metadata_types.py @@ -18,20 +18,27 @@ def dict(self, *, exclude_none: bool = False) -> Mapping[str, Any]: def accepts_logger_metadata(logger: Logger) -> None: mapping_metadata: Mapping[str, Any] = {"user_id": "user-1"} + dict_metadata: dict[str, Any] = {"user_id": "user-1"} logger.log(metadata=mapping_metadata) logger.log(metadata=PydanticV2Metadata()) logger.log(metadata=PydanticV1Metadata()) - logger.emit_log(body="message", level="info", metadata=mapping_metadata) - logger.emit_log(body="message", level="info", metadata=PydanticV2Metadata()) - logger.emit_log(body="message", level="info", metadata=PydanticV1Metadata()) - logger.trace("message", metadata=mapping_metadata) - logger.debug("message", metadata=PydanticV2Metadata()) - logger.info("message", metadata=PydanticV1Metadata()) - logger.warn("message", metadata=mapping_metadata) - logger.error("message", metadata=PydanticV2Metadata()) - logger.fatal("message", metadata=PydanticV1Metadata()) + logger.emit_log(body="message", level="info", metadata=dict_metadata) + logger.trace("message", metadata=dict_metadata) + logger.debug("message", metadata=dict_metadata) + logger.info("message", metadata=dict_metadata) + logger.warn("message", metadata=dict_metadata) + logger.error("message", metadata=dict_metadata) + logger.fatal("message", metadata=dict_metadata) + + logger.emit_log("User {user_id}", "info", metadata=dict_metadata, user_id="user-1") + logger.trace("User {user_id}", metadata=dict_metadata, user_id="user-1") + logger.debug("User {user_id}", metadata=dict_metadata, user_id="user-1") + logger.info("User {user_id}", metadata=dict_metadata, user_id="user-1") + logger.warn("User {user_id}", metadata=dict_metadata, user_id="user-1") + logger.error("User {user_id}", metadata=dict_metadata, user_id="user-1") + logger.fatal("User {user_id}", metadata=dict_metadata, user_id="user-1") logger.log_feedback(id="event-id", metadata=mapping_metadata) logger.log_feedback(id="event-id", metadata=PydanticV2Metadata()) From fbd78aac1ffa81a925daa98f9a63cc2406d958b5 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 17 Sep 2026 08:54:20 -0400 Subject: [PATCH 3/6] emit single row --- py/src/braintrust/logger.py | 2 +- py/src/braintrust/test_logger.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index ad700d33c..588dbc3d4 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -6056,6 +6056,7 @@ def emit_log( else None ), metadata=rendered_metadata, + metrics={"end": captured_at}, context={ "otel": { "signal": "logs", @@ -6067,7 +6068,6 @@ def emit_log( } }, ) - span.end(end_time=captured_at) if not self.async_flush: self.flush() diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index 158de910f..f679b3e71 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -1399,6 +1399,17 @@ def test_logger_emit_log_without_active_span(with_memory_logger): assert second["context"]["otel"]["log"]["severity_number"] == 9 +def test_logger_emit_log_enqueues_single_row(with_memory_logger): + test_logger = init_test_logger(__name__) + + test_logger.info("Payment completed", metadata={"payment_id": "pay_123"}) + + assert len(with_memory_logger.logs) == 1 + [row] = with_memory_logger.pop() + assert row["metrics"]["start"] == row["metrics"]["end"] + assert row["_is_merge"] is False + + def test_logger_emit_log_uses_distinct_baseline_trace_per_logger(with_memory_logger): first_logger = init_test_logger(f"{__name__}-first") second_logger = init_test_logger(f"{__name__}-second") From 2ba9dc655da0d65b027894ffe467e909174a9e98 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 17 Sep 2026 09:18:52 -0400 Subject: [PATCH 4/6] feat(logger): add Python logging handler Expose an opt-in standard-library logging handler that forwards formatted records, source metadata, timestamps, and template parameters through the Braintrust logging API while avoiding recursive SDK transport logs. --- py/src/braintrust/__init__.py | 1 + py/src/braintrust/logger.py | 34 ++++--- py/src/braintrust/logs.py | 88 ++++++++++++++++++ py/src/braintrust/test_logs.py | 92 +++++++++++++++++++ .../type_tests/test_public_exports.py | 2 + 5 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 py/src/braintrust/logs.py create mode 100644 py/src/braintrust/test_logs.py diff --git a/py/src/braintrust/__init__.py b/py/src/braintrust/__init__.py index 1e66fced9..2513a0e75 100644 --- a/py/src/braintrust/__init__.py +++ b/py/src/braintrust/__init__.py @@ -81,6 +81,7 @@ def is_equal(expected, output): _internal_reset_global_state, # noqa: F401 # type: ignore[reportUnusedImport] _internal_with_custom_background_logger, # noqa: F401 # type: ignore[reportUnusedImport] ) +from .logs import BraintrustLogHandler as BraintrustLogHandler from .sandbox import RegisteredSandboxFunction as RegisteredSandboxFunction from .sandbox import RegisterSandboxResult as RegisterSandboxResult from .sandbox import SandboxConfig as SandboxConfig diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index 588dbc3d4..9a2255308 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -6017,10 +6017,6 @@ def emit_log( :param parameters: Values for named placeholders in a string body. :returns: The unique ID of the captured log row. """ - if level not in _OTEL_LOG_LEVELS: - valid_levels = ", ".join(_OTEL_LOG_LEVELS) - raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}") - rendered_body = body rendered_metadata = metadata if parameters: @@ -6038,7 +6034,24 @@ def emit_log( # contains malformed braces or an unsupported format specifier. rendered_body = body - captured_at = time.time() + return self._emit_log_record( + body=rendered_body, + level=level, + metadata=rendered_metadata, + captured_at=time.time(), + ) + + def _emit_log_record( + self, + body: Any, + level: LogLevel, + metadata: dict[str, Any] | None, + captured_at: float, + ) -> str: + if level not in _OTEL_LOG_LEVELS: + valid_levels = ", ".join(_OTEL_LOG_LEVELS) + raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}") + span_info = self.state.context_manager.get_current_span_info() severity_number = _OTEL_LOG_LEVELS[level] span = self._start_span_impl( @@ -6049,14 +6062,11 @@ def emit_log( span_id=span_info.span_id if span_info else None, root_span_id=span_info.trace_id if span_info else self._baseline_trace_id, lookup_span_parent=False, - output=rendered_body, - error=( - rendered_body - if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(rendered_body, str) - else None - ), - metadata=rendered_metadata, + output=body, + error=(body if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(body, str) else None), + metadata=metadata, metrics={"end": captured_at}, + created=datetime.datetime.fromtimestamp(captured_at, datetime.timezone.utc).isoformat(), context={ "otel": { "signal": "logs", diff --git a/py/src/braintrust/logs.py b/py/src/braintrust/logs.py new file mode 100644 index 000000000..9571091e5 --- /dev/null +++ b/py/src/braintrust/logs.py @@ -0,0 +1,88 @@ +"""Adapters for forwarding standard-library log records to Braintrust.""" + +import logging +from typing import Any + +from .logger import Logger, LogLevel + + +_STANDARD_LOG_RECORD_ATTRIBUTES = frozenset(vars(logging.LogRecord("", logging.NOTSET, "", 0, "", (), None))) | { + "asctime", + "message", +} +_IGNORED_LOGGER_PREFIXES = ("braintrust", "urllib3") + + +def _log_level(level: int) -> LogLevel: + if level >= logging.CRITICAL: + return "fatal" + if level >= logging.ERROR: + return "error" + if level >= logging.WARNING: + return "warn" + if level >= logging.INFO: + return "info" + if level >= logging.DEBUG: + return "debug" + return "trace" + + +def _is_ignored_logger(name: str) -> bool: + return any(name == prefix or name.startswith(f"{prefix}.") for prefix in _IGNORED_LOGGER_PREFIXES) + + +def _record_metadata(record: logging.LogRecord) -> dict[str, Any]: + metadata = { + key: value + for key, value in vars(record).items() + if key not in _STANDARD_LOG_RECORD_ATTRIBUTES and not key.startswith("_") + } + + if record.args and isinstance(record.msg, str): + metadata["braintrust.template"] = record.msg + parameters = record.args.items() if isinstance(record.args, dict) else enumerate(record.args) + metadata.update({f"braintrust.template.parameter.{key}": value for key, value in parameters}) + + metadata.update( + { + "logger.name": record.name, + "code.file.path": record.pathname, + "code.function.name": record.funcName, + "code.line.number": record.lineno, + } + ) + + return metadata + + +class BraintrustLogHandler(logging.Handler): + """Forward Python ``logging`` records to a Braintrust logger. + + Attach this handler explicitly with ``logging.Logger.addHandler``. Records + emitted by Braintrust and its HTTP transport are ignored to prevent logging + recursion. + """ + + def __init__(self, logger: Logger, level: int | str = logging.NOTSET): + super().__init__(level=level) + self._logger = logger + + def emit(self, record: logging.LogRecord) -> None: + if _is_ignored_logger(record.name): + return + + try: + self._logger._emit_log_record( + body=self.format(record), + level=_log_level(record.levelno), + metadata=_record_metadata(record), + captured_at=record.created, + ) + except Exception: + self.handleError(record) + + def flush(self) -> None: + self._logger.flush() + + +__all__ = ["BraintrustLogHandler"] diff --git a/py/src/braintrust/test_logs.py b/py/src/braintrust/test_logs.py new file mode 100644 index 000000000..5254c4426 --- /dev/null +++ b/py/src/braintrust/test_logs.py @@ -0,0 +1,92 @@ +import logging +import sys + +import pytest +from braintrust.logs import BraintrustLogHandler +from braintrust.test_helpers import init_test_logger, with_memory_logger # noqa: F401 + + +def test_handler_forwards_log_record(with_memory_logger): + handler = BraintrustLogHandler(init_test_logger(__name__)) + record = logging.LogRecord( + name="payments.checkout", + level=logging.WARNING, + pathname="/app/checkout.py", + lineno=42, + msg="Payment %s failed", + args=("pay_123",), + exc_info=None, + func="charge", + ) + record.created = 1234.5 + record.customer_id = "cus_123" + + handler.handle(record) + + assert len(with_memory_logger.logs) == 1 + [row] = with_memory_logger.pop() + assert row["output"] == "Payment pay_123 failed" + assert row["created"] == "1970-01-01T00:20:34.500000+00:00" + assert row["metrics"] == {"start": 1234.5, "end": 1234.5} + assert row["context"]["otel"]["log"] == { + "time_unix_nano": "1234500000000", + "severity_number": 13, + "severity_text": "WARN", + } + assert row["metadata"] == { + "braintrust.template": "Payment %s failed", + "braintrust.template.parameter.0": "pay_123", + "code.file.path": "/app/checkout.py", + "code.function.name": "charge", + "code.line.number": 42, + "customer_id": "cus_123", + "logger.name": "payments.checkout", + } + + +@pytest.mark.parametrize( + ("python_level", "braintrust_level"), + [ + (1, "trace"), + (logging.DEBUG, "debug"), + (logging.INFO, "info"), + (logging.WARNING, "warn"), + (logging.ERROR, "error"), + (logging.CRITICAL, "fatal"), + ], +) +def test_handler_maps_python_log_levels(with_memory_logger, python_level, braintrust_level): + handler = BraintrustLogHandler(init_test_logger(__name__)) + record = logging.LogRecord("app", python_level, __file__, 1, "message", (), None) + + handler.handle(record) + + [row] = with_memory_logger.pop() + assert row["context"]["otel"]["log"]["severity_text"] == braintrust_level.upper() + + +def test_handler_forwards_exception_info(with_memory_logger): + handler = BraintrustLogHandler(init_test_logger(__name__)) + + try: + raise ValueError("invalid payment") + except ValueError: + record = logging.LogRecord("payments", logging.ERROR, __file__, 1, "Charge failed", (), None) + record.exc_info = sys.exc_info() + + handler.handle(record) + + [row] = with_memory_logger.pop() + assert row["output"].startswith("Charge failed\nTraceback (most recent call last):") + assert row["output"].endswith("ValueError: invalid payment") + assert row["error"] == row["output"] + + +@pytest.mark.parametrize("logger_name", ["braintrust.logger", "urllib3.connectionpool"]) +def test_handler_ignores_internal_transport_loggers(with_memory_logger, logger_name): + handler = BraintrustLogHandler(init_test_logger(__name__)) + record = logging.LogRecord(logger_name, logging.ERROR, __file__, 1, "internal", (), None) + + handler.handle(record) + + assert with_memory_logger.pop() == [] diff --git a/py/src/braintrust/type_tests/test_public_exports.py b/py/src/braintrust/type_tests/test_public_exports.py index 928f67168..0702853f6 100644 --- a/py/src/braintrust/type_tests/test_public_exports.py +++ b/py/src/braintrust/type_tests/test_public_exports.py @@ -10,6 +10,7 @@ import braintrust import pytest from braintrust import ( + BraintrustLogHandler, auto_instrument, setup_ai_sdk, setup_pydantic_ai, @@ -21,6 +22,7 @@ _PUBLIC_SYMBOLS = [ + ("BraintrustLogHandler", BraintrustLogHandler), ("auto_instrument", auto_instrument), ("wrap_anthropic", wrap_anthropic), ("wrap_litellm", wrap_litellm), From f8a9011d0c867b2329b8e0295d72d1a14d2e4c57 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 17 Sep 2026 10:31:34 -0400 Subject: [PATCH 5/6] refine --- py/src/braintrust/logger.py | 39 ++++++++-------------------- py/src/braintrust/test_logger.py | 44 ++++++++++++-------------------- py/src/braintrust/test_logs.py | 11 +++----- 3 files changed, 31 insertions(+), 63 deletions(-) diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index 9a2255308..9aa5b1f98 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -132,14 +132,7 @@ DEFAULT_MAX_REQUEST_SIZE = 6 * 1024 * 1024 LogLevel = Literal["trace", "debug", "info", "warn", "error", "fatal"] -_OTEL_LOG_LEVELS: dict[LogLevel, int] = { - "trace": 1, - "debug": 5, - "info": 9, - "warn": 13, - "error": 17, - "fatal": 21, -} +_LOG_LEVELS: tuple[LogLevel, ...] = ("trace", "debug", "info", "warn", "error", "fatal") class _LogTemplateParameters(dict[str, object]): @@ -6048,12 +6041,11 @@ def _emit_log_record( metadata: dict[str, Any] | None, captured_at: float, ) -> str: - if level not in _OTEL_LOG_LEVELS: - valid_levels = ", ".join(_OTEL_LOG_LEVELS) + if level not in _LOG_LEVELS: + valid_levels = ", ".join(_LOG_LEVELS) raise ValueError(f"Invalid log level {level!r}. Expected one of: {valid_levels}") span_info = self.state.context_manager.get_current_span_info() - severity_number = _OTEL_LOG_LEVELS[level] span = self._start_span_impl( name="Log", type=SpanTypeAttribute.LOG, @@ -6063,20 +6055,9 @@ def _emit_log_record( root_span_id=span_info.trace_id if span_info else self._baseline_trace_id, lookup_span_parent=False, output=body, - error=(body if severity_number >= _OTEL_LOG_LEVELS["error"] and isinstance(body, str) else None), - metadata=metadata, + metadata={**(metadata or {}), "braintrust.log_level": level}, metrics={"end": captured_at}, created=datetime.datetime.fromtimestamp(captured_at, datetime.timezone.utc).isoformat(), - context={ - "otel": { - "signal": "logs", - "log": { - "time_unix_nano": str(round(captured_at * 1_000_000_000)), - "severity_number": severity_number, - "severity_text": level.upper(), - }, - } - }, ) if not self.async_flush: @@ -6085,27 +6066,27 @@ def _emit_log_record( return span.id def trace(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry TRACE severity.""" + """Capture a trace-level log.""" return self.emit_log(body=body, level="trace", metadata=metadata, **parameters) def debug(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry DEBUG severity.""" + """Capture a debug-level log.""" return self.emit_log(body=body, level="debug", metadata=metadata, **parameters) def info(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry INFO severity.""" + """Capture an info-level log.""" return self.emit_log(body=body, level="info", metadata=metadata, **parameters) def warn(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry WARN severity.""" + """Capture a warn-level log.""" return self.emit_log(body=body, level="warn", metadata=metadata, **parameters) def error(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry ERROR severity.""" + """Capture an error-level log.""" return self.emit_log(body=body, level="error", metadata=metadata, **parameters) def fatal(self, body: Any, metadata: dict[str, Any] | None = None, **parameters: object) -> str: - """Capture a log at OpenTelemetry FATAL severity.""" + """Capture a fatal-level log.""" return self.emit_log(body=body, level="fatal", metadata=metadata, **parameters) def log_feedback( diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index f679b3e71..62d55fb57 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -1384,19 +1384,14 @@ def test_logger_emit_log_without_active_span(with_memory_logger): assert first["root_span_id"] == second["root_span_id"] assert not first.get("span_parents") assert first["output"] == "Payment failed" - assert first["error"] == "Payment failed" - assert first["metadata"] == {"payment_id": "pay_123"} + assert "error" not in first + assert first["metadata"] == {"payment_id": "pay_123", "braintrust.log_level": "error"} assert first["span_attributes"]["name"] == "Log" assert first["span_attributes"]["type"] == "log" assert first["metrics"]["start"] == first["metrics"]["end"] - assert first["context"]["otel"]["signal"] == "logs" - assert first["context"]["otel"]["log"] == { - "time_unix_nano": str(round(first["metrics"]["start"] * 1_000_000_000)), - "severity_number": 17, - "severity_text": "ERROR", - } + assert "otel" not in first.get("context", {}) assert "error" not in second - assert second["context"]["otel"]["log"]["severity_number"] == 9 + assert second["metadata"] == {"braintrust.log_level": "info"} def test_logger_emit_log_enqueues_single_row(with_memory_logger): @@ -1434,28 +1429,22 @@ def test_logger_emit_log_uses_active_span(with_memory_logger): assert log_row["span_id"] == owner_row["span_id"] assert log_row["root_span_id"] == owner_row["root_span_id"] assert not log_row.get("span_parents") - assert log_row["context"]["otel"]["log"]["severity_number"] == 5 + assert log_row["metadata"] == {"attempt": 1, "braintrust.log_level": "debug"} + assert "otel" not in log_row.get("context", {}) -@pytest.mark.parametrize( - ("level", "severity_number"), - [("trace", 1), ("debug", 5), ("info", 9), ("warn", 13), ("error", 17), ("fatal", 21)], -) -def test_logger_emit_log_maps_otel_log_levels(with_memory_logger, level, severity_number): +@pytest.mark.parametrize("level", ["trace", "debug", "info", "warn", "error", "fatal"]) +def test_logger_emit_log_adds_log_level_metadata(with_memory_logger, level): test_logger = init_test_logger(__name__) test_logger.emit_log(body="message", level=level) [row] = with_memory_logger.pop() - assert row["context"]["otel"]["log"]["severity_number"] == severity_number - assert row["context"]["otel"]["log"]["severity_text"] == level.upper() + assert row["metadata"] == {"braintrust.log_level": level} -@pytest.mark.parametrize( - ("method_name", "severity_number"), - [("trace", 1), ("debug", 5), ("info", 9), ("warn", 13), ("error", 17), ("fatal", 21)], -) -def test_logger_log_level_helpers(with_memory_logger, method_name, severity_number): +@pytest.mark.parametrize("method_name", ["trace", "debug", "info", "warn", "error", "fatal"]) +def test_logger_log_level_helpers(with_memory_logger, method_name): test_logger = init_test_logger(__name__) log_id = getattr(test_logger, method_name)("message", metadata={"source": method_name}) @@ -1463,8 +1452,7 @@ def test_logger_log_level_helpers(with_memory_logger, method_name, severity_numb [row] = with_memory_logger.pop() assert row["id"] == log_id assert row["output"] == "message" - assert row["metadata"] == {"source": method_name} - assert row["context"]["otel"]["log"]["severity_number"] == severity_number + assert row["metadata"] == {"source": method_name, "braintrust.log_level": method_name} def test_logger_log_helpers_render_template_parameters(with_memory_logger): @@ -1485,17 +1473,18 @@ def test_logger_log_helpers_render_template_parameters(with_memory_logger): "braintrust.template.parameter.user_id": "user-123", "braintrust.template.parameter.amount": 12.5, "braintrust.template": "User {user_id} paid {amount:.2f} with {method}", + "braintrust.log_level": "info", } -def test_logger_error_uses_rendered_template_as_error(with_memory_logger): +def test_logger_error_renders_template_without_error_field(with_memory_logger): test_logger = init_test_logger(__name__) test_logger.error("Payment {payment_id} failed", payment_id="pay-123") [row] = with_memory_logger.pop() assert row["output"] == "Payment pay-123 failed" - assert row["error"] == "Payment pay-123 failed" + assert "error" not in row def test_logger_log_helpers_do_not_format_without_parameters(with_memory_logger): @@ -1505,7 +1494,7 @@ def test_logger_log_helpers_do_not_format_without_parameters(with_memory_logger) [row] = with_memory_logger.pop() assert row["output"] == '{"key": "{value}"}' - assert not row.get("metadata") + assert row["metadata"] == {"braintrust.log_level": "info"} def test_logger_log_template_parameters_are_safely_serialized(with_memory_logger): @@ -1516,6 +1505,7 @@ def test_logger_log_template_parameters_are_safely_serialized(with_memory_logger [row] = with_memory_logger.pop() assert row["output"] == "Request failed: bad request" assert row["metadata"]["braintrust.template.parameter.error"] == "bad request" + assert row["metadata"]["braintrust.log_level"] == "warn" def test_logger_emit_log_rejects_invalid_level(with_memory_logger): diff --git a/py/src/braintrust/test_logs.py b/py/src/braintrust/test_logs.py index 5254c4426..ebaa41459 100644 --- a/py/src/braintrust/test_logs.py +++ b/py/src/braintrust/test_logs.py @@ -28,12 +28,9 @@ def test_handler_forwards_log_record(with_memory_logger): assert row["output"] == "Payment pay_123 failed" assert row["created"] == "1970-01-01T00:20:34.500000+00:00" assert row["metrics"] == {"start": 1234.5, "end": 1234.5} - assert row["context"]["otel"]["log"] == { - "time_unix_nano": "1234500000000", - "severity_number": 13, - "severity_text": "WARN", - } + assert "otel" not in row.get("context", {}) assert row["metadata"] == { + "braintrust.log_level": "warn", "braintrust.template": "Payment %s failed", "braintrust.template.parameter.0": "pay_123", "code.file.path": "/app/checkout.py", @@ -62,7 +59,7 @@ def test_handler_maps_python_log_levels(with_memory_logger, python_level, braint handler.handle(record) [row] = with_memory_logger.pop() - assert row["context"]["otel"]["log"]["severity_text"] == braintrust_level.upper() + assert row["metadata"]["braintrust.log_level"] == braintrust_level def test_handler_forwards_exception_info(with_memory_logger): @@ -79,7 +76,7 @@ def test_handler_forwards_exception_info(with_memory_logger): [row] = with_memory_logger.pop() assert row["output"].startswith("Charge failed\nTraceback (most recent call last):") assert row["output"].endswith("ValueError: invalid payment") - assert row["error"] == row["output"] + assert "error" not in row @pytest.mark.parametrize("logger_name", ["braintrust.logger", "urllib3.connectionpool"]) From 3e9b5f1128549c3ba163ccb56863c2d07c125a80 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 17 Sep 2026 14:02:50 -0400 Subject: [PATCH 6/6] feat(logger): support Python 3.14 t-strings Render string.templatelib.Template bodies with their embedded values while retaining the reconstructed template and raw parameters in metadata. Load templatelib only on Python 3.14+ so the SDK remains compatible with Python 3.10 through 3.13. --- py/src/braintrust/logger.py | 61 ++++++++++++++++++++++++++++++-- py/src/braintrust/test_logger.py | 40 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index 9aa5b1f98..b5472de56 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -8,6 +8,7 @@ import dataclasses import datetime import hashlib +import importlib import inspect import io import json @@ -134,6 +135,8 @@ LogLevel = Literal["trace", "debug", "info", "warn", "error", "fatal"] _LOG_LEVELS: tuple[LogLevel, ...] = ("trace", "debug", "info", "warn", "error", "fatal") +_TEMPLATELIB = importlib.import_module("string.templatelib") if sys.version_info >= (3, 14) else None + class _LogTemplateParameters(dict[str, object]): """Preserve placeholders whose values were not provided.""" @@ -142,6 +145,46 @@ def __missing__(self, key: str) -> str: return "{" + key + "}" +def _is_t_string(value: Any) -> bool: + return _TEMPLATELIB is not None and isinstance(value, _TEMPLATELIB.Template) + + +def _render_t_string(template: Any) -> tuple[str, str, dict[str, object]]: + """Render a Python 3.14 t-string and retain its template structure.""" + assert _TEMPLATELIB is not None + + rendered_parts: list[str] = [] + template_parts: list[str] = [] + parameters: dict[str, object] = {} + + for index, (literal, interpolation) in enumerate(zip(template.strings, template.interpolations)): + rendered_parts.append(literal) + template_parts.append(literal.replace("{", "{{").replace("}", "}}")) + + placeholder = "{" + interpolation.expression + if interpolation.conversion is not None: + placeholder += "!" + interpolation.conversion + if interpolation.format_spec: + placeholder += ":" + interpolation.format_spec + placeholder += "}" + template_parts.append(placeholder) + + parameter_name = interpolation.expression.strip() or str(index) + parameters[parameter_name] = interpolation.value + try: + converted = _TEMPLATELIB.convert(interpolation.value, interpolation.conversion) + rendered_parts.append(format(converted, interpolation.format_spec)) + except Exception: + # Logging should not disrupt the application because an interpolation + # uses an unsupported conversion or format specifier. + rendered_parts.append(placeholder) + + final_literal = template.strings[-1] + rendered_parts.append(final_literal) + template_parts.append(final_literal.replace("{", "{{").replace("}", "}}")) + return "".join(rendered_parts), "".join(template_parts), parameters + + @dataclasses.dataclass class Logs3OverflowInputRow: object_ids: dict[str, Any] @@ -6001,9 +6044,12 @@ def emit_log( String bodies may contain ``str.format``-style placeholders. Keyword parameters are interpolated into the body and retained in metadata along with the original template. Missing parameters remain as placeholders. + On Python 3.14 and newer, ``string.templatelib.Template`` bodies are + rendered using their embedded interpolation values, which are also + retained in metadata. - :param body: The log body. May be any JSON-serializable value when no - template parameters are provided. + :param body: The log body. May be a Python 3.14+ t-string or any + JSON-serializable value when no template parameters are provided. :param level: The OpenTelemetry log severity: ``trace``, ``debug``, ``info``, ``warn``, ``error``, or ``fatal``. :param metadata: Optional JSON-serializable attributes for the log. @@ -6012,7 +6058,16 @@ def emit_log( """ rendered_body = body rendered_metadata = metadata - if parameters: + if _is_t_string(body): + if parameters: + raise TypeError("T-string bodies already contain their interpolation values") + rendered_body, template, t_string_parameters = _render_t_string(body) + rendered_metadata = dict(metadata) if metadata is not None else {} + rendered_metadata.update( + {f"braintrust.template.parameter.{key}": value for key, value in t_string_parameters.items()} + ) + rendered_metadata["braintrust.template"] = template + elif parameters: if not isinstance(body, str): raise TypeError("Log body must be a string when template parameters are provided") rendered_metadata = dict(metadata) if metadata is not None else {} diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index 62d55fb57..afa2721de 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -2,10 +2,12 @@ # pyright: reportPrivateUsage=false import asyncio import builtins +import importlib import inspect import json import logging import os +import sys import threading import time from collections.abc import AsyncGenerator @@ -1477,6 +1479,44 @@ def test_logger_log_helpers_render_template_parameters(with_memory_logger): } +@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+") +def test_logger_log_helpers_render_t_string(with_memory_logger): + templatelib = importlib.import_module("string.templatelib") + template = templatelib.Template( + "User ", + templatelib.Interpolation("user-123", "user_id"), + " paid ", + templatelib.Interpolation(12.5, "amount", "r", ">8"), + " with {card}", + ) + test_logger = init_test_logger(__name__) + + log_id = test_logger.info(template, metadata={"source": "checkout"}) + + [row] = with_memory_logger.pop() + assert row["id"] == log_id + assert row["output"] == "User user-123 paid 12.5 with {card}" + assert row["metadata"] == { + "source": "checkout", + "braintrust.template.parameter.user_id": "user-123", + "braintrust.template.parameter.amount": 12.5, + "braintrust.template": "User {user_id} paid {amount!r:>8} with {{card}}", + "braintrust.log_level": "info", + } + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+") +def test_logger_t_string_rejects_keyword_template_parameters(with_memory_logger): + templatelib = importlib.import_module("string.templatelib") + template = templatelib.Template("User ", templatelib.Interpolation("user-123", "user_id")) + test_logger = init_test_logger(__name__) + + with pytest.raises(TypeError, match="already contain their interpolation values"): + test_logger.info(template, user_id="other-user") + + assert with_memory_logger.pop() == [] + + def test_logger_error_renders_template_without_error_field(with_memory_logger): test_logger = init_test_logger(__name__)