Treat `logfire.tags` passed through logging and loguru as log tags

Merged
TH
12d ago
+110-0

Fixes #524

What

LogfireLoggingHandler (and therefore the loguru handler) now passes a logfire.tags value from the log record to Logfire.log(tags=...), so it is recorded the same way as logfire.info(..., _tags=[...]).

Why

As noted in #524, logger.info('Hello {name}', name='World', **{'logfire.tags': ['foo', 'bar']}) was expected to work, but the value was treated as an ordinary attribute: it was exported as the JSON string '["foo","bar"]', listed in logfire.json_schema, and overwritten by any tags on the handler's logfire_instance. The same happened with logger.bind(**{'logfire.tags': [...]}) and stdlib logging with extra={'logfire.tags': [...]}.

How

emit() pops logfire.tags from the filled attributes and passes it as tags=, so it is merged with the instance's tags and exported as a string array. A single string is treated as one tag, as the LogfireSpan.tags setter already does; other malformed values behave the same as they do for _tags.

Before / After

logfire.tags for the loguru call above:

  • Before: '["foo","bar"]', plus a logfire.tags entry in logfire.json_schema
  • After: ('foo', 'bar'), no schema entry

With logfire_instance=logfire.with_tags('instance'): before ('instance',), after ('instance', 'foo').

Tests: test_loguru_tags (kwargs and bind), test_loguru_single_string_tag, test_stdlib_logging_tags, test_stdlib_logging_single_string_tag and test_stdlib_logging_tags_merged_with_instance_tags; all fail on main.

The structlog integration calls Logfire.log directly, so it isn't covered here; happy to do that in a follow-up if you'd like.

This change was written with Claude Opus 5.5 (via Claude Code) under my direction, then checked with a separate AI review pass and the relevant tests, ruff and pyright run locally. I'm responsible for it and will follow up on review.