fix: support nested schemas and handle client disconnects in responses telemetry#2532
Conversation
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, this looks directionally right and fixes the important telemetry schema mismatch for Responses API usage (input_tokens / output_tokens) and nested response.usage.
One thing I’d like to tighten before merging: in Router::responses_stream, we now append any top-level string delta to the accumulated telemetry output. For the normal Responses streaming text event this is correct (type == "response.output_text.delta"), but the Responses stream can contain multiple event types. If a backend emits non-text delta events later, such as tool/function/reasoning-related deltas, those could accidentally be recorded as model output in telemetry.
Could we gate this on the event type, e.g. only append top-level string delta when type == "response.output_text.delta"? The delta.text object fallback can stay if it is needed for a Lemonade-specific backend format, but it should ideally be similarly constrained or documented.
I’d also like to see a small test for the output accumulator path:
- response.output_text.delta contributes to accumulated_text.
- A non-text delta event does not contribute to accumulated_text.
Optional but useful: add coverage for the CURLE_WRITE_ERROR path so we know client disconnects end the telemetry span as an error without triggering backend reset behavior.
74c6d1b to
c3a25a0
Compare
|
Hey @fl0rianr. Thanks for the feedback! I've addressed your recommendations and pushed the updates to the branch:
Let me know if something else needs fixing. |
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for the updates. The Responses delta accumulator looks good now.
I still think the streaming error handling needs one more fix before merge.
The new broad try/catch around HttpClient::post_stream(` in StreamingProxy::forward_sse_stream() changes the ownership of backend connection failures. HttpClient::post_stream() still throws for real cURL errors except CURLE_PARTIAL_FILE and CURLE_RECV_ERROR. With the new catch, connection failures such as “failed to connect” are converted into a telemetry error and are not rethrown to WrappedServer::forward_streaming_request().
That bypasses the existing retry/watchdog path in WrappedServer, which is specifically responsible for detecting backend connection failures before any stream bytes were delivered and replaying/reloading when appropriate. In the current shape, a backend connection failure can end up as an empty/done stream with only the telemetry span marked as error, rather than a proper retry/reset or client-visible error.
There is also a related issue with CURLE_WRITE_ERROR: forward_sse_stream() now has a branch for result.curl_code == CURLE_WRITE_ERROR, but HttpClient::post_stream() currently throws for that code before returning the HttpResponse, because only CURLE_PARTIAL_FILE and CURLE_RECV_ERROR are exempted. So the intended client-disconnect branch is not reliably reached.
I’d suggest this structure instead:
-
Let
HttpClient::post_stream()return CURLE_WRITE_ERROR to the streaming layer, like it already does for CURLE_PARTIAL_FILE / CURLE_RECV_ERROR. -
In
StreamingProxy, classify only CURLE_WRITE_ERROR as a client disconnect and end telemetry with a client-disconnect error. -
For CURLE_PARTIAL_FILE / CURLE_RECV_ERROR, keep the existing protocol-aware behavior:
- before [DONE]: throw backend stream failure;
- after [DONE]: treat as clean completion.
-
Do not broadly swallow other
post_stream()exceptions inStreamingProxy; let them propagate toWrappedServerso backend retry/watchdog behavior remains intact.
The new closed-port test currently exercises backend connection failure, not client disconnect. I would remove or replace it with a test where a stream starts successfully and DataSink.write returns false, so the test specifically validates the CURLE_WRITE_ERROR client-disconnect path without changing backend-failure semantics.
65617ad to
dc54cc5
Compare
|
Thanks for the precise feedback! Here is what was changed to address your notes:
|
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, this latest revision addresses my previous blocking concerns.
Great, right before approving.
Only small nit: in the new local httplib::Server test, I would wait until the server is ready after starting listen_after_bind() before issuing the client request, if svr.wait_until_ready() is available. That would avoid a possible startup race/flaky test on slower CI machines. Since I guess we all hate flaky CI it might be good if you have a look here again.
Code-wise, this looks good to me now.
dc54cc5 to
412794d
Compare
|
Done. Let me know if anything pops up. |
fl0rianr
left a comment
There was a problem hiding this comment.
Good, thanks, after a careful re-check I found a change that might influence something:
The StreamingProxy path looks correct now, but HttpClient::post_stream() is also used directly by CloudServer::forward_streaming_request(). Since this PR changes post_stream() to return CURLE_WRITE_ERROR instead of throwing it, CloudServer now also needs to inspect result.curl_code.
Currently CloudServer only checks result.status_code after post_stream(). For a client disconnect after a 200 response has started, CURLE_WRITE_ERROR can now come back in the returned HttpResponse, while status_code remains 200. That means CloudServer could treat a client-disconnected stream as successful and call the telemetry callback with an empty error.
Could we add explicit result.curl_code handling in CloudServer::forward_streaming_request() as well? At minimum, CURLE_WRITE_ERROR should be classified as "Client disconnected during stream" and should not be reported as a successful completion. Ideally this should mirror the StreamingProxy semantics: CURLE_WRITE_ERROR = client disconnect, CURLE_PARTIAL_FILE / CURLE_RECV_ERROR before [DONE] = backend stream failure, after [DONE] = clean completion.
…nnects in telemetry - Extract text token increments when streamed under top-level delta fields and gate them on type == "response.output_text.delta". - Parse usage metrics and timings when nested inside a response object, and support input_tokens/output_tokens key name fallbacks. - Unify streaming usage/timing extraction and delta accumulation in StreamingProxy static helpers. - Allow HttpClient::post_stream to return CURLE_WRITE_ERROR to the streaming layer instead of throwing. - Classify CURLE_WRITE_ERROR as a client disconnect, recording it as an error span in telemetry without throwing C++ exceptions. - Add comprehensive C++ unit test coverage for parsed metrics, delta accumulation gating, and client disconnects.
412794d to
87b4ecb
Compare
|
Fair call. Applied the changes. |
03f8c4c
Problem
/v1/responsesendpoint showed empty generation outputs and0token usage statistics on Arize Phoenix. This occurred because:deltaobject/string instead of achoicesarray.responseobject instead of being placed at the JSON root."input_tokens"and"output_tokens"instead of standard OpenAI"prompt_tokens"and"completion_tokens".CURLE_WRITE_ERROR). The proxy code didn't flag this curl error as a stream failure, resulting in the server synthesizing[DONE]and completing the span with anOKstatus despite the output being truncated.Solution
Router::responses_streamto parse top-leveldeltastrings and objects, extracting text changes correctly.StreamingProxy::forward_sse_streamandStreamingProxy::parse_telemetryto look forusageandtimingsboth at the root of the chunk and nested inside theresponseobject."prompt_tokens"/"completion_tokens"(OpenAI) and"input_tokens"/"output_tokens"(Responses API) key schemas in both streaming (StreamingProxy) and non-streaming (Router::chat_completion,Router::completion,Router::embeddings,Router::reranking) handlers.result.curl_code != CURLE_OK.CURLE_WRITE_ERROR), the telemetry span is ended with anERRORstatus indicating a client disconnect, preventing cut-off spans from appearing as successfulOKruns.CURLE_WRITE_ERRORso we don't trigger false-positive model resets or evictions on the server when a client simply closes their socket.Testing
test_telemetry_helpers.cppvalidatingStreamingProxy::parse_telemetryagainst root-level and nested telemetry schemas with standard and fallback key names.