Skip to content

feat(logger): add new logging apis - #759

Open
Abhijeet Prasad (AbhiPrasad) wants to merge 13 commits into
mainfrom
abhi-logs
Open

Abhijeet Prasad (AbhiPrasad) wants to merge 13 commits into
mainfrom
abhi-logs

Conversation

@AbhiPrasad

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) commented Sep 10, 2026

Copy link
Copy Markdown
Member

resolves https://linear.app/braintrustdata/issue/SDK-341/add-logging-api-to-python-sdk
ref https://app.notion.com/p/braintrustdata/Braintrust-Logs-Schema-and-SDK-API-3def7858028980e29a83cddb3f81203b
schema https://github.com/braintrustdata/braintrust/pull/20692

AI Summary

Add first-class log emission to the project logger so applications can create independent type="log" rows without constructing spans manually.

  • Add Logger.emit_log(body, level, metadata).
  • Add trace(), debug(), info(), warn(), error(), and fatal() convenience methods.
  • Store the canonical severity in span_attributes["log_level"].
  • Add SpanTypeAttribute.LOG.
  • Support named Python format parameters while retaining the original template and parameter values in metadata.
  • On Python 3.14+, support t-string objects with embedded interpolation values, conversions, and format specifications.

Usage

Emit directly or use a severity helper:

logger.error(
    "Payment failed",
    metadata={"payment_id": "pay_123"},
)

logger.emit_log(
    body="Retrying payment",
    level="info",
    metadata={"attempt": 2},
)

Log methods also accept named str.format parameters:

logger.info(
    "User {user_id} paid {amount:.2f}",
    user_id="user_123",
    amount=12.5,
    metadata={"source": "checkout"},
)

On Python 3.14+, log methods also accept t-strings. Their interpolation values are already embedded, so no separate keyword parameters are needed:

user_id = "user_123"
amount = 12.5

logger.info(
    t"User {user_id} paid {amount:.2f}",
    metadata={"source": "checkout"},
)

Both forms store the rendered message in output:

User user_123 paid 12.50

They also retain the stable template and its raw parameters for querying and grouping. Canonical severity follows the updated logs schema and lives in span_attributes, separate from user metadata:

{
    "output": "User user_123 paid 12.50",
    "span_attributes": {
        "type": "log",
        "log_level": "info",
    },
    "metadata": {
        "source": "checkout",
        "braintrust.template": "User {user_id} paid {amount:.2f}",
        "braintrust.template.parameter.user_id": "user_123",
        "braintrust.template.parameter.amount": 12.5,
    },
}

Missing str.format parameters remain as placeholders, and malformed templates fall back to the original body so formatting errors do not disrupt application code. T-string conversions and format specifications follow f-string rendering semantics; unsupported formatting leaves the affected placeholder intact. Bodies may remain non-string JSON values when no template parameters are supplied.

T-string support is loaded only on Python 3.14+, preserving SDK compatibility with Python 3.10 through 3.13.

Trace correlation

Each log has a unique row ID and span ID unless it is correlated with an active span. A logger seeds one baseline trace ID when it is created, so consecutive unscoped logs from that logger remain grouped without grouping logs from separate logger instances.

helper -> emit_log -> type="log" row
                     |-- active span
                     |     trace_id = active trace_id
                     |     span_id  = active span_id
                     |
                     `-- no active span
                           trace_id = logger baseline trace_id
                           span_id  = newly generated span_id

This works with both native Braintrust spans and active OpenTelemetry spans through the existing context manager abstraction.

Python logging handler

The opt-in BraintrustLogHandler forwards standard-library logging records without enabling automatic instrumentation:

import logging

from braintrust import BraintrustLogHandler, init_logger

handler = BraintrustLogHandler(init_logger(project="my-project"))
logging.getLogger().addHandler(handler)

The handler preserves formatted messages, original timestamps, exceptions, template parameters, extra fields, logger/source metadata, active span correlation, and normalized severity in span_attributes.log_level. Logs from Braintrust and its urllib3 transport are excluded to prevent recursive forwarding.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-18T18:35:43.880347Z 6841bdf New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd6b7345e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

)
rendered_metadata["braintrust.template"] = body
try:
rendered_body = body.format_map(_LogTemplateParameters(parameters))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve formatting for missing fields with format specs

When an omitted placeholder has a conversion or format specifier, such as logger.info("{user} owes {amount:.2f}", user="alice"), __missing__ supplies the string "{amount}", formatting that string as a float raises, and this broad fallback restores the entire original template. Consequently even supplied parameters are left uninterpolated, contrary to the documented behavior that only missing parameters remain as placeholders. Preserve the missing field's conversion/specifier instead of abandoning all rendering.

Useful? React with 👍 / 👎.

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 {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize supported metadata before adding template attributes

When a caller combines template parameters with Pydantic-style metadata accepted by the rest of the logger API, this direct conversion can raise TypeError: an object implementing the supported model_dump() or dict() protocol is not necessarily iterable. The same metadata works when no template parameters are supplied because the normal event sanitizer handles those protocols, so logger.info("User {id}", metadata=model, id=...) unexpectedly emits no log. Retain the Metadata input contract and normalize it before merging the template attributes.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 657bcd32df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".


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, **parameters)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve level as a template parameter in helpers

When a severity helper is given a template parameter named level, such as logger.info("Connected at {level}", level="database"), the helper collects it in parameters and then passes it alongside the fixed level="info" argument, causing Python to raise TypeError: got multiple values for keyword argument 'level' before any log is emitted. Since named placeholders are otherwise advertised without restrictions, pass template parameters through a non-colliding container or render them before forwarding.

Useful? React with 👍 / 👎.

Comment thread py/src/braintrust/logs.py Outdated
Comment on lines +43 to +44
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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mapping keys in logging template metadata

When standard logging interpolation uses a non-dict mapping such as collections.UserDict (logger.info("%(user)s", UserDict(user="alice"))), LogRecord stores that mapping directly in record.args and formats the message successfully. This branch instead treats it as positional arguments and enumerates its keys, recording braintrust.template.parameter.0 = "user" rather than braintrust.template.parameter.user = "alice", so the emitted template attributes are silently incorrect; recognize general Mapping instances here.

Useful? React with 👍 / 👎.

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) changed the title feat(logger): add OpenTelemetry-compatible log emission feat(logger): add new logging apis Sep 17, 2026
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
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.
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.
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.
Comment thread py/src/braintrust/logs.py
@realark

Copy link
Copy Markdown
Contributor

🚀

Align emitted rows with the updated logs schema by moving canonical severity from
metadata to span_attributes.log_level. This keeps user and template metadata free
of schema-owned fields.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75a1b2aecb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread py/src/braintrust/logger.py Outdated
Comment on lines +172 to +173
parameter_name = interpolation.expression.strip() or str(index)
parameters[parameter_name] = interpolation.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve repeated t-string interpolation values

When a t-string repeats an expression that produces different values, such as it = iter([1, 2]); logger.info(t"{next(it)} {next(it)}"), both interpolations use the same parameter_name, so the second assignment silently overwrites the first. The rendered body remains "1 2", but the retained metadata contains only braintrust.template.parameter.next(it) = 2, losing a raw parameter the API promises to retain; use occurrence-specific keys when an expression is repeated.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6e6c8b8. Repeated expressions now receive occurrence-specific keys in evaluation order (for example, next(it).0 and next(it).1), so every raw value is retained. Unique expressions keep their existing unsuffixed keys. Added a regression test for the 1 2 case.

logging.shutdown holds a handler lock while flush waits for HTTP workers. Filter
Braintrust and urllib3 records through Handler.filter so those workers never try
to acquire the same lock.
Suffix repeated interpolation expressions by occurrence so each evaluated value is
preserved in log metadata. Unique expressions keep their existing parameter keys.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e6c8b84c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

name="Log",
type=SpanTypeAttribute.LOG,
span_attributes={"log_level": level},
start_time=captured_at,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve zero-valued record timestamps

When the handler forwards a replayed or synthetic LogRecord whose created value is exactly 0, this passes 0 into SpanImpl, where start_time or time.time() treats it as absent. The resulting row retains created and metrics.end at the Unix epoch but records metrics.start as the current time, corrupting the promised original timestamp and producing an invalid duration; handle zero explicitly rather than relying on the span constructor's falsy fallback.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1289051. SpanImpl now treats only None as an absent start time, so a valid 0 timestamp is preserved. Added a regression test that sends a LogRecord with created = 0 through BraintrustLogHandler and verifies created, metrics.start, and metrics.end all remain at the Unix epoch.

Treat only None as an absent start time so replayed epoch log records keep created, start, and end timestamps aligned.
Keep the internal log span name while excluding it from serialized log rows so the schema contains only log-specific span attributes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73bf23d97d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread py/src/braintrust/logs.py Outdated
Comment on lines +48 to +49
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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve positional mappings as a single template argument

When standard logging receives a mapping as its sole positional value, such as logger.info("payload=%s", {"id": 1}), LogRecord unwraps the one-element tuple and stores the dictionary directly in record.args, even though %s still formats the whole dictionary as argument 0. This branch instead interprets every dictionary as named interpolation and records braintrust.template.parameter.id = 1, losing the actual raw parameter braintrust.template.parameter.0 = {"id": 1}. Distinguish named %(key)s interpolation from a mapping supplied to a positional placeholder before constructing the metadata.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6841bdf. Mapping-shaped LogRecord.args are now expanded by key only when the template contains an active named %(key) placeholder. A mapping used with a positional %s is retained as braintrust.template.parameter.0, and escaped %%(key)s is handled as positional. Added regression coverage for all three cases.

Comment thread py/src/braintrust/logs.py Outdated
"asctime",
"message",
}
_IGNORED_LOGGER_PREFIXES = ("braintrust", "urllib3")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not suppress unrelated urllib3 application logs

When this handler is attached to a root or application logger, the prefix filter drops every urllib3 record before emission, including requests made by the application to its own services rather than by Braintrust. Applications commonly rely on urllib3.connectionpool logs for HTTP diagnostics, so enabling the Braintrust handler silently excludes that entire class of user logs. Scope recursion prevention to Braintrust transport activity rather than filtering the global urllib3 namespace.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d1073b. We no longer suppress the global urllib3 namespace. Braintrust HTTPConnection and Transport request scopes now set a ContextVar guard, and BraintrustLogHandler filters records only while that SDK transport guard is active (plus the existing braintrust logger namespace). Added regressions showing application urllib3 records are forwarded and SDK transport records are rejected before the handler lock, preserving the shutdown deadlock fix.

Mark Braintrust HTTP request scopes explicitly so the log handler suppresses only SDK transport activity instead of the entire urllib3 namespace.
Inspect percent-style placeholders before expanding mapping arguments so dictionaries passed to positional placeholders remain a single raw parameter.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6841bdfe31

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread py/src/braintrust/logs.py
Comment on lines +62 to +66
if isinstance(record.args, Mapping) and _uses_named_percent_parameters(record.msg):
parameters = record.args.items()
else:
positional_args = (record.args,) if isinstance(record.args, Mapping) else record.args
parameters = enumerate(positional_args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain mappings used by mixed percent placeholders

The new named-placeholder discriminator still loses positional parameter metadata for valid mixed templates. For example, logger.info("%s %(id)s", {"id": 1}) is rendered successfully by standard logging because the mapping is consumed both as the %s value and by %(id)s, but this branch records only braintrust.template.parameter.id and omits braintrust.template.parameter.0 for the positional placeholder. Handle mixed positional/named templates by retaining the mapping in both applicable forms.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants