This is an automated email from the ASF dual-hosted git repository.

bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 9f9752d47c proxy.process.http.000_responses metric (#12861)
9f9752d47c is described below

commit 9f9752d47c5c7443cf84b8419002e0dbf4ba36ab
Author: Brian Neradt <[email protected]>
AuthorDate: Thu Feb 19 12:31:26 2026 -0600

    proxy.process.http.000_responses metric (#12861)
    
    Add a new HTTP stat to track responses where no valid HTTP status code
    was sent to the client. This typically occurs when the client aborts
    the connection before a response is sent (ERR_CLIENT_ABORT).
---
 .../statistics/core/http-response-code.en.rst      |   7 ++
 include/proxy/http/HttpConfig.h                    |   1 +
 src/proxy/http/HttpConfig.cc                       |   1 +
 src/proxy/http/HttpTransact.cc                     |   4 +
 tests/gold_tests/statistics/abort_client.py        |  52 ++++++++++
 .../statistics/metric_response_000.test.py         | 107 +++++++++++++++++++++
 6 files changed, 172 insertions(+)

diff --git 
a/doc/admin-guide/monitoring/statistics/core/http-response-code.en.rst 
b/doc/admin-guide/monitoring/statistics/core/http-response-code.en.rst
index 56904f284c..f5425c1ee1 100644
--- a/doc/admin-guide/monitoring/statistics/core/http-response-code.en.rst
+++ b/doc/admin-guide/monitoring/statistics/core/http-response-code.en.rst
@@ -28,6 +28,13 @@ HTTP status code of the response. Please refer to the
 :ref:`appendix-http-status-codes` appendix for more details on what each status
 code means.
 
+.. ts:stat:: global proxy.process.http.000_responses integer
+   :type: counter
+
+   The number of HTTP transactions where no valid HTTP response status code was
+   sent to the client. This typically occurs when the client aborts the
+   connection before a response is sent (ERR_CLIENT_ABORT).
+
 .. ts:stat:: global proxy.process.http.100_responses integer
    :type: counter
 
diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h
index 4ac5e07183..4df4958b8a 100644
--- a/include/proxy/http/HttpConfig.h
+++ b/include/proxy/http/HttpConfig.h
@@ -208,6 +208,7 @@ struct HttpStatsBlock {
   Metrics::Counter::AtomicType *pushed_document_total_size;
   Metrics::Counter::AtomicType *pushed_response_header_total_size;
   Metrics::Counter::AtomicType *put_requests;
+  Metrics::Counter::AtomicType *response_status_000_count;
   Metrics::Counter::AtomicType *response_status_100_count;
   Metrics::Counter::AtomicType *response_status_101_count;
   Metrics::Counter::AtomicType *response_status_1xx_count;
diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc
index a34f77d21e..957cb0da7f 100644
--- a/src/proxy/http/HttpConfig.cc
+++ b/src/proxy/http/HttpConfig.cc
@@ -399,6 +399,7 @@ register_stat_callbacks()
   http_rsb.pushed_document_total_size        = 
Metrics::Counter::createPtr("proxy.process.http.pushed_document_total_size");
   http_rsb.pushed_response_header_total_size = 
Metrics::Counter::createPtr("proxy.process.http.pushed_response_header_total_size");
   http_rsb.put_requests                      = 
Metrics::Counter::createPtr("proxy.process.http.put_requests");
+  http_rsb.response_status_000_count         = 
Metrics::Counter::createPtr("proxy.process.http.000_responses");
   http_rsb.response_status_100_count         = 
Metrics::Counter::createPtr("proxy.process.http.100_responses");
   http_rsb.response_status_101_count         = 
Metrics::Counter::createPtr("proxy.process.http.101_responses");
   http_rsb.response_status_1xx_count         = 
Metrics::Counter::createPtr("proxy.process.http.1xx_responses");
diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc
index 08cccee0ef..5296532f27 100644
--- a/src/proxy/http/HttpTransact.cc
+++ b/src/proxy/http/HttpTransact.cc
@@ -8485,6 +8485,10 @@ HttpTransact::client_result_stat(State *s, ink_hrtime 
total_time, ink_hrtime req
   if (s->client_info.abort == ABORTED) {
     client_transaction_result = ClientTransactionResult_t::ERROR_ABORT;
   }
+  // Count 000 responses separately since they include aborts (the main source 
of 000).
+  if (static_cast<int>(client_response_status) == 0) {
+    Metrics::Counter::increment(http_rsb.response_status_000_count);
+  }
   // Count the status codes, assuming the client didn't abort (i.e. there is 
an m_http)
   if ((s->source != Source_t::NONE) && (s->client_info.abort == DIDNOT_ABORT)) 
{
     switch (static_cast<int>(client_response_status)) {
diff --git a/tests/gold_tests/statistics/abort_client.py 
b/tests/gold_tests/statistics/abort_client.py
new file mode 100644
index 0000000000..8fb322e659
--- /dev/null
+++ b/tests/gold_tests/statistics/abort_client.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""A client that sends an HTTP request and immediately aborts."""
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import argparse
+import socket
+import sys
+
+
+def main() -> int:
+    """Connect, send a partial request, and abort."""
+    parser = argparse.ArgumentParser(description='Send a partial request and 
abort.')
+    parser.add_argument('host', help='The host to connect to.')
+    parser.add_argument('port', type=int, help='The port to connect to.')
+    args = parser.parse_args()
+
+    # Connect to the server.
+    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    sock.connect((args.host, args.port))
+    print(f'Connected to {args.host}:{args.port}')
+
+    # Send ONLY partial request headers (no terminating \r\n\r\n).
+    # This means ATS will wait for more data and never construct a response.
+    partial_request = b"GET / HTTP/1.1\r\nHost: www.example.com\r\n"
+    sock.sendall(partial_request)
+    print('Sent partial request (missing final CRLF), aborting...')
+
+    # Immediately close the socket.
+    # This triggers an ERR_CLIENT_ABORT before any response is constructed.
+    sock.close()
+    print('Connection closed.')
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/tests/gold_tests/statistics/metric_response_000.test.py 
b/tests/gold_tests/statistics/metric_response_000.test.py
new file mode 100644
index 0000000000..30af20a185
--- /dev/null
+++ b/tests/gold_tests/statistics/metric_response_000.test.py
@@ -0,0 +1,107 @@
+"""Verify the proxy.process.http.000_responses stat is incremented for client 
aborts."""
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+import sys
+
+Test.Summary = __doc__
+
+
+class MetricResponse000Test:
+    """Verify that the 000_responses stat is incremented when a client 
aborts."""
+
+    _abort_client = 'abort_client.py'
+    _server_counter = 0
+    _ts_counter = 0
+
+    def __init__(self):
+        """Configure and run the test."""
+        self._configure_server()
+        self._configure_traffic_server()
+        self._configure_abort_client()
+        self._configure_successful_request()
+        self._verify_000_metric()
+
+    def _configure_server(self) -> None:
+        """Configure the origin server."""
+        self._server = 
Test.MakeOriginServer(f'server-{MetricResponse000Test._server_counter}')
+        MetricResponse000Test._server_counter += 1
+
+        request_header = {"headers": "GET / HTTP/1.1\r\nHost: 
www.example.com\r\n\r\n", "timestamp": "1469733493.993", "body": ""}
+        response_header = {
+            "headers": "HTTP/1.1 200 OK\r\nConnection: 
close\r\nContent-Length: 0\r\n\r\n",
+            "timestamp": "1469733493.993",
+            "body": ""
+        }
+        self._server.addResponse("sessionlog.json", request_header, 
response_header)
+
+    def _configure_traffic_server(self) -> None:
+        """Configure ATS."""
+        self._ts = 
Test.MakeATSProcess(f'ts-{MetricResponse000Test._ts_counter}', 
enable_cache=False)
+        MetricResponse000Test._ts_counter += 1
+
+        self._ts.Disk.remap_config.AddLine(f'map / 
http://127.0.0.1:{self._server.Variables.Port}/')
+        self._ts.Disk.records_config.update({
+            'proxy.config.diags.debug.enabled': 0,
+            'proxy.config.diags.debug.tags': 'http',
+        })
+
+    def _configure_abort_client(self) -> None:
+        """Configure a client to send a partial request and abort."""
+        tr = Test.AddTestRun('Trigger a client abort with partial request')
+
+        tr.Setup.CopyAs(os.path.join(Test.TestDirectory, self._abort_client), 
Test.RunDirectory)
+
+        p = tr.Processes.Default
+        p.Command = f'{sys.executable} {self._abort_client} 127.0.0.1 
{self._ts.Variables.port}'
+        p.ReturnCode = 0
+
+        self._ts.StartBefore(self._server)
+        p.StartBefore(self._ts)
+
+        tr.StillRunningAfter = self._ts
+        tr.StillRunningAfter = self._server
+
+    def _configure_successful_request(self) -> None:
+        """Send a successful request to verify it doesn't increment 000 
stat."""
+        tr = Test.AddTestRun('Send a successful request')
+        tr.Processes.Default.Command = f'curl -s -o /dev/null -w 
"%{{http_code}}" http://127.0.0.1:{self._ts.Variables.port}/'
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.All = Testers.ContainsExpression('200', 
'Expected 200 response')
+        tr.StillRunningAfter = self._ts
+        tr.StillRunningAfter = self._server
+
+    def _verify_000_metric(self) -> None:
+        """Verify the 000_responses stat is incremented."""
+        # Wait for stats to propagate.
+        tr = Test.AddTestRun('Wait for stats')
+        tr.Processes.Default.Command = 'sleep 2'
+        tr.Processes.Default.ReturnCode = 0
+        tr.StillRunningAfter = self._ts
+
+        # Verify the 000_responses stat is non-zero.
+        tr = Test.AddTestRun('Check 000_responses stat')
+        tr.Processes.Default.Command = 'traffic_ctl metric get 
proxy.process.http.000_responses'
+        tr.Processes.Default.Env = self._ts.Env
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.All = Testers.ContainsExpression(
+            'proxy.process.http.000_responses 1', 'The 000_responses stat 
should be 1')
+        tr.StillRunningAfter = self._ts
+
+
+MetricResponse000Test()

Reply via email to