Treat `logfire.tags` passed through logging and loguru as log tags
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 alogfire.tagsentry inlogfire.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.