Description
What I'm seeing
logfire.instrument_anthropic() aborts an in-progress streaming response with:
File ".../logfire/_internal/integrations/llm_providers/anthropic.py", line 288, in get_response_data
return {'combined_chunk_content': ''.join(texts), 'chunk_count': self._chunk_count}
TypeError: sequence item 0: expected str instance, NoneType found
Root cause
AnthropicMessageStreamState.get_response_data joins the text of every text block in the accumulated message:
texts = [block.text for block in self._message.content if isinstance(block, (TextBlock, BetaTextBlock))]
return {'combined_chunk_content': ''.join(texts), ...}
TextBlock.text / BetaTextBlock.text are typed str, but the Anthropic SDK accumulates the streaming snapshot via construct_type(...) (anthropic/lib/streaming/_beta_messages.py), which bypasses validation. A text block that is opened (content_block_start) and only ever receives a citations_delta — no text_delta — keeps text=None. This happens routinely with the web_search / web_fetch builtin tools, whose responses carry citation-bearing text blocks. The result is ''.join([None, ...]) → TypeError.
Why this is more than a dropped span attribute
The join runs in record_streaming's __exit__, i.e. inside the instrumented stream iterator (llm_providers/llm_provider.py, LogfireInstrumentedAsyncStream.__stream__). So the exception propagates out of the async generator into application code and kills a response the model already produced successfully — it is not contained to telemetry. We saw 50+ aborted end-user chat responses in production from this (model claude-opus-4-6, all using web_search).
Expected
Instrumentation must never raise into the stream. Skipping None text (as the OpenAI path already does in OpenaiCompletionStreamState.record_chunk, which only appends truthy content) resolves it:
texts = [
block.text
for block in self._message.content
if isinstance(block, (TextBlock, BetaTextBlock)) and block.text is not None
]
Still reproduces on main (4.35.0). I'd be happy to open a PR with a fix + test if that's helpful.
Note: convert_response_to_semconv (the 'latest' semconv path) also reads block.text and would store content=None for such a block — doesn't crash, but may warrant the same coalescing.
Python, Logfire & OS Versions, related packages
logfire="4.34.0" # also reproduces on main (4.35.0)
anthropic="0.96.0"
platform="Linux (prod); also repro'd on macOS-26.0-arm64"
python="3.11"
[related_packages]
pydantic="2.13.4"
opentelemetry-api="1.39.1"
opentelemetry-sdk="1.39.1"
opentelemetry-semantic-conventions="0.60b1"
Description
What I'm seeing
logfire.instrument_anthropic()aborts an in-progress streaming response with:Root cause
AnthropicMessageStreamState.get_response_datajoins the text of every text block in the accumulated message:TextBlock.text/BetaTextBlock.textare typedstr, but the Anthropic SDK accumulates the streaming snapshot viaconstruct_type(...)(anthropic/lib/streaming/_beta_messages.py), which bypasses validation. A text block that is opened (content_block_start) and only ever receives acitations_delta— notext_delta— keepstext=None. This happens routinely with theweb_search/web_fetchbuiltin tools, whose responses carry citation-bearing text blocks. The result is''.join([None, ...])→TypeError.Why this is more than a dropped span attribute
The join runs in
record_streaming's__exit__, i.e. inside the instrumented stream iterator (llm_providers/llm_provider.py,LogfireInstrumentedAsyncStream.__stream__). So the exception propagates out of the async generator into application code and kills a response the model already produced successfully — it is not contained to telemetry. We saw 50+ aborted end-user chat responses in production from this (modelclaude-opus-4-6, all usingweb_search).Expected
Instrumentation must never raise into the stream. Skipping
Nonetext (as the OpenAI path already does inOpenaiCompletionStreamState.record_chunk, which only appends truthy content) resolves it:Still reproduces on
main(4.35.0). I'd be happy to open a PR with a fix + test if that's helpful.Note:
convert_response_to_semconv(the'latest'semconv path) also readsblock.textand would storecontent=Nonefor such a block — doesn't crash, but may warrant the same coalescing.Python, Logfire & OS Versions, related packages