@@ -136,6 +136,14 @@ type RequestContext struct {
136136 RequestDroppedReason errcommon.RequestDroppedReason
137137 modelServerStreaming bool
138138
139+ // responseProcessingDuration is the EPP cost of handling the response. For a
140+ // streamed response it is the sum of the per-chunk handler slices, since the
141+ // gaps between chunks are model server generation time. For a non-streaming
142+ // response it is the single interval from responseHeadersReceivedAt onward,
143+ // during which the response is entirely in EPP's hands.
144+ responseProcessingDuration time.Duration
145+ responseHeadersReceivedAt time.Time
146+
139147 Response * Response
140148
141149 reqHeaderResp * extProcPb.ProcessingResponse
@@ -265,6 +273,25 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
265273 },
266274 }
267275
276+ // Request-phase failures (parser resolution, body parsing, admission
277+ // rejection) leave the switch before the success path, so both call this.
278+ // Flow-control rejections carry the queue wait and are the slowest samples;
279+ // dropping them would bias the histogram low.
280+ recordRequestProcessing := sync .OnceFunc (func () {
281+ metrics .RecordRequestProcessingLatency (time .Since (reqCtx .RequestReceivedTimestamp ))
282+ })
283+
284+ // Record EPP response processing latency once when the stream ends. Using a
285+ // defer (rather than emitting on end-of-stream) ensures aborted streams
286+ // (client cancel or upstream error before EOS) are also recorded, so the
287+ // metric is not biased toward fully streamed responses. The guard skips
288+ // requests that never reached the response phase.
289+ defer func () {
290+ if reqCtx .responseProcessingDuration > 0 {
291+ metrics .RecordResponseProcessingLatency (reqCtx .responseProcessingDuration )
292+ }
293+ }()
294+
268295 buf := s .bufferPool .Get ().(* bytes.Buffer )
269296 buf .Reset ()
270297 defer func () {
@@ -461,10 +488,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
461488 if parseResult .SkipResponseProcessing {
462489 reqCtx .RequestState = RequestResponseProcessingSkipped
463490 }
491+
492+ recordRequestProcessing ()
464493 }
465494 case * extProcPb.ProcessingRequest_RequestTrailers :
466495 // This is currently unused.
467496 case * extProcPb.ProcessingRequest_ResponseHeaders :
497+ respHeadersReceivedAt := time .Now ()
468498 for _ , header := range v .ResponseHeaders .Headers .GetHeaders () {
469499 value := string (header .RawValue )
470500 loggerTrace .Info ("header" , "key" , header .Key , "value" , value )
@@ -478,12 +508,15 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
478508 reqCtx .RequestState = ResponseReceived
479509 reqCtx = s .HandleResponseHeaders (ctx , reqCtx , v )
480510 reqCtx .respHeaderResp = s .generateResponseHeaderResponse (reqCtx )
511+ reqCtx .responseHeadersReceivedAt = respHeadersReceivedAt
512+ reqCtx .responseProcessingDuration += time .Since (respHeadersReceivedAt )
481513
482514 case * extProcPb.ProcessingRequest_ResponseBody :
483515 endOfStream := v .ResponseBody .EndOfStream
484516 chunk := v .ResponseBody .Body
485517
486518 if reqCtx .modelServerStreaming {
519+ respBodyStart := time .Now ()
487520 if endOfStream {
488521 reqCtx .ResponseComplete = true
489522 reqCtx .ResponseCompleteTimestamp = time .Now ()
@@ -493,6 +526,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
493526 chunk = rewriteModelName (chunk , reqCtx .TargetModelName , reqCtx .IncomingModelName )
494527 // For streaming response, we send response chunk back to envoy every time we received it.
495528 reqCtx .respBodyResp = generateResponseBodyResponses (chunk , endOfStream , reqCtx .Response .DynamicMetadata )
529+ reqCtx .responseProcessingDuration += time .Since (respBodyStart )
496530 } else {
497531 respBody = append (respBody , chunk ... )
498532 if endOfStream {
@@ -513,6 +547,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer)
513547
514548 // Handle the err and fire an immediate response.
515549 if err != nil {
550+ recordRequestProcessing ()
516551 if logger .V (logutil .DEBUG ).Enabled () {
517552 logger .V (logutil .DEBUG ).Error (err , "Failed to process request" , "request" , req )
518553 } else {
@@ -553,6 +588,7 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
553588 return
554589 }
555590
591+ start := time .Now ()
556592 reqCtx .ResponseComplete = true
557593 reqCtx .ResponseCompleteTimestamp = time .Now ()
558594 reqCtx = s .HandleResponseBody (ctx , reqCtx , body , true )
@@ -562,6 +598,13 @@ func (s *StreamingServer) finishResponse(ctx context.Context, reqCtx *RequestCon
562598 // For non-streaming response, we send response back to envoy after receiving all the response body.
563599 reqCtx .respBodyResp = generateResponseBodyResponses (body , setEos , reqCtx .Response .DynamicMetadata )
564600 }
601+ if modelStreaming || reqCtx .responseHeadersReceivedAt .IsZero () {
602+ reqCtx .responseProcessingDuration += time .Since (start )
603+ } else {
604+ // Supersedes the header slice already accumulated: the interval since the
605+ // response headers arrived covers it and the body wait in between.
606+ reqCtx .responseProcessingDuration = time .Since (reqCtx .responseHeadersReceivedAt )
607+ }
565608}
566609
567610// rewriteModelName replaces occurrences of the target (internal) model name with the
0 commit comments