PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Sync / Response_Telemetry.php

Response_Telemetry.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Sync/Response_Telemetry.php

291 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sync REST response telemetry.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WP_Error;
11 use WP_REST_Request;
12 use WP_REST_Response;
13
14 /**
15 * Adds cheap contextual telemetry to v2 sync responses.
16 *
17 * Change-response bodies stay deterministic for a given store state so their
18 * strong ETags remain honest validators under RFC 9110 section 8.8.1. Volatile
19 * timing and memory telemetry therefore lives in headers only.
20 */
21 final class Response_Telemetry {
22 /**
23 * Request start times keyed by object hash.
24 *
25 * @var array<string, float>
26 */
27 private static $started = array();
28
29 /**
30 * Register the request lifecycle hooks once.
31 */
32 public static function register_hooks(): void {
33 if ( false === has_filter( 'rest_pre_dispatch', array( self::class, 'start_request' ) ) ) {
34 add_filter( 'rest_pre_dispatch', array( self::class, 'start_request' ), 1, 3 );
35 add_filter( 'rest_pre_dispatch', array( self::class, 'decorate_precomputed_response' ), PHP_INT_MAX, 3 );
36 add_filter( 'rest_request_after_callbacks', array( self::class, 'decorate_callback_response' ), 10, 3 );
37 // Auth rejections (rest_authentication_errors) skip dispatch entirely —
38 // rest_post_dispatch is the one filter every served response passes.
39 add_filter( 'rest_post_dispatch', array( self::class, 'ensure_contextual_headers' ), PHP_INT_MAX, 3 );
40 }
41 }
42
43
44 /**
45 * Guarantee X-Server-Load on every served v2 sync response — including
46 * authentication rejections that never reached dispatch.
47 *
48 * @param mixed $response REST response.
49 * @param mixed $server REST server.
50 * @param WP_REST_Request $request REST request.
51 *
52 * @return mixed
53 */
54 public static function ensure_contextual_headers( $response, $server, WP_REST_Request $request ) {
55 if ( ! $response instanceof WP_REST_Response || ! self::is_sync_route( $request->get_route() ) ) {
56 return $response;
57 }
58 $headers = $response->get_headers();
59 if ( ! isset( $headers['X-Server-Load'] ) ) {
60 $response->header( 'X-Server-Load', (string) wp_json_encode( self::server_load() ) );
61 }
62
63 return $response;
64 }
65
66 /**
67 * Start timing before validation, permissions, and controller dispatch.
68 *
69 * @param mixed $result Precomputed response, normally null.
70 * @param mixed $server REST server.
71 * @param WP_REST_Request $request REST request.
72 *
73 * @return mixed
74 */
75 public static function start_request( $result, $server, WP_REST_Request $request ) {
76 if ( self::is_sync_route( $request->get_route() ) ) {
77 self::$started[ spl_object_hash( $request ) ] = microtime( true );
78 }
79
80 return $result;
81 }
82
83 /**
84 * Decorate responses returned early by another rest_pre_dispatch filter.
85 *
86 * @param mixed $result Precomputed response.
87 * @param mixed $server REST server.
88 * @param WP_REST_Request $request REST request.
89 *
90 * @return mixed
91 */
92 public static function decorate_precomputed_response( $result, $server, WP_REST_Request $request ) {
93 if ( empty( $result ) ) {
94 return $result;
95 }
96
97 return self::finish_request( $result, $request );
98 }
99
100 /**
101 * Decorate the response after validation, permissions, and the callback.
102 *
103 * @param mixed $response REST response.
104 * @param mixed $handler Matched REST handler.
105 * @param WP_REST_Request $request REST request.
106 *
107 * @return mixed
108 */
109 public static function decorate_callback_response( $response, $handler, WP_REST_Request $request ) {
110 return self::finish_request( $response, $request );
111 }
112
113 /**
114 * Normalize and decorate a response when timing was started for its request.
115 *
116 * @param mixed $response REST response.
117 * @param WP_REST_Request $request REST request.
118 *
119 * @return mixed
120 */
121 private static function finish_request( $response, WP_REST_Request $request ) {
122 $key = spl_object_hash( $request );
123 if ( ! isset( self::$started[ $key ] ) ) {
124 return $response;
125 }
126
127 $started = self::$started[ $key ];
128 unset( self::$started[ $key ] );
129
130 if ( $response instanceof WP_Error ) {
131 $response = rest_convert_error_to_response( $response );
132 } else {
133 $response = rest_ensure_response( $response );
134 }
135
136 return self::decorate( $response, $request, $started );
137 }
138
139 /**
140 * Attach server load to every sync response and timing to selected routes.
141 *
142 * @param WP_REST_Response $response REST response.
143 * @param WP_REST_Request $request Sync REST request.
144 * @param float $started Request start time.
145 */
146 private static function decorate( WP_REST_Response $response, WP_REST_Request $request, float $started ): WP_REST_Response {
147 $response->header( 'X-Server-Load', (string) wp_json_encode( self::server_load() ) );
148
149 // Error bodies (incl. the write contract's golden 4xx shapes) are never
150 // touched — contextual headers are the only telemetry they carry.
151 if ( 304 === $response->get_status() || $response->get_status() >= 400 ) {
152 return $response;
153 }
154
155 $route = untrailingslashit( $request->get_route() );
156 if ( self::is_changes_route( $route ) ) {
157 $duration = self::duration_ms( $started );
158 $response->header( 'X-WCPOS-Memory-Peak', (string) memory_get_peak_usage( true ) );
159 } elseif ( self::is_metrics_route( $route ) ) {
160 $duration = self::add_metrics_envelope( $response, $started );
161 } else {
162 return $response;
163 }
164
165 $response->header( 'Server-Timing', 'wcpos;dur=' . $duration );
166
167 return $response;
168 }
169
170 /**
171 * Add the top-level metrics object the client's pull protocol parses.
172 *
173 * @param WP_REST_Response $response REST response.
174 * @param float $started Request start time.
175 */
176 private static function add_metrics_envelope( WP_REST_Response $response, float $started ): float {
177 $metrics = self::metrics( $started );
178 $data = (array) $response->get_data();
179
180 $data['metrics'] = $metrics;
181 $response->set_data( $data );
182
183 return $metrics['duration_ms'];
184 }
185
186 /**
187 * Build the common metrics payload.
188 *
189 * @param float $started Request start time.
190 *
191 * @return array{duration_ms: float, memory_peak_bytes: int}
192 */
193 private static function metrics( float $started ): array {
194 return array(
195 'duration_ms' => self::duration_ms( $started ),
196 'memory_peak_bytes' => memory_get_peak_usage( true ),
197 );
198 }
199
200 /**
201 * Wall-clock request duration in milliseconds.
202 *
203 * @param float $started Request start time.
204 */
205 private static function duration_ms( float $started ): float {
206 return round( ( microtime( true ) - $started ) * 1000, 3 );
207 }
208
209 /**
210 * Whether the route receives the nested metrics envelope.
211 *
212 * @param string $route Request route.
213 */
214 private static function is_metrics_route( string $route ): bool {
215 $base = '/' . Api::ROUTE_NAMESPACE . '/';
216
217 // Only the pull carries a body metrics object. Push responses (success,
218 // delete and error) are golden-shaped write-contract surfaces and stay
219 // byte-identical — they get headers only.
220 $route = strtolower( $route );
221
222 return $base . 'orders/pull' === $route;
223 }
224
225 /**
226 * Whether the route belongs to the graduated changes surface.
227 *
228 * @param string $route Request route.
229 */
230 private static function is_changes_route( string $route ): bool {
231 $base = '/' . Api::ROUTE_NAMESPACE . '/';
232
233 return 0 === strpos( strtolower( $route ), $base . 'changes/' );
234 }
235
236 /**
237 * Whether a route belongs to the v2 sync surface.
238 *
239 * @param string $route Request route.
240 */
241 private static function is_sync_route( string $route ): bool {
242 // WordPress matches REST routes case-insensitively, so route detection must too.
243 $base = '/' . Api::ROUTE_NAMESPACE . '/';
244 $route = strtolower( untrailingslashit( $route ) );
245 if ( 0 !== strpos( $route, $base ) ) {
246 return false;
247 }
248
249 $routes = array(
250 $base . 'status',
251 $base . 'orders/pull',
252 $base . 'orders/index/backfill',
253 $base . 'changes/sequence-log',
254 $base . 'changes/revision-hash',
255 $base . 'changes/range-checksum',
256 $base . 'changes/config-fingerprint',
257 $base . 'changes/tick',
258 $base . 'digests',
259 $base . 'integrity/scan',
260 $base . 'integrity/rebuild',
261 $base . 'integrity/bucket',
262 $base . 'uuid/backfill',
263 $base . 'variations',
264 $base . 'resolve/barcode',
265 );
266
267 foreach ( Collections::with( 'proxy' ) as $collection ) {
268 $routes[] = $base . ltrim( $collection['proxy']['route'], '/' );
269 }
270
271 return in_array( $route, $routes, true )
272 || 0 === strpos( $route, $base . 'push/' );
273 }
274
275 /**
276 * Get the platform load average without invoking platform shell commands.
277 *
278 * @return array<int, float|int>
279 */
280 public static function server_load(): array {
281 if ( 0 !== stripos( PHP_OS, 'WIN' ) && function_exists( 'sys_getloadavg' ) ) {
282 $load = sys_getloadavg();
283 if ( is_array( $load ) && 3 === count( $load ) ) {
284 return array_values( $load );
285 }
286 }
287
288 return array( 0, 0, 0 );
289 }
290 }
291