diff --git a/inc/agent-api.php b/inc/agent-api.php index 41f8392..bd5f898 100644 --- a/inc/agent-api.php +++ b/inc/agent-api.php @@ -28,6 +28,9 @@ class TI_Parrot_Agent_API { const RATE_WINDOW = 3600; + // more matching options than any real fleet of products would create + const MAX_CRASH_PRODUCTS = 20; + private $parrot; function __construct( $parrot ) { @@ -47,6 +50,7 @@ function register_routes() { '/products' => 'get_products_index', '/products/(?P[a-z0-9_-]+)' => 'get_product', '/logs' => 'get_logs', + '/crashes' => 'get_crashes', ); foreach ( $routes as $route => $callback ) { register_rest_route( @@ -109,6 +113,11 @@ function get_manifest( $request ) { 'route' => '/logs', 'plugins' => $this->parrot->get_registered_log_plugins(), ), + array( + 'slug' => 'crashes', + 'label' => __( 'Product crash reports', 'pirate-parrot' ), + 'route' => '/crashes', + ), ); foreach ( $this->get_providers() as $slug => $provider ) { $sections[] = array( @@ -274,6 +283,44 @@ function get_logs( $request ) { ); } + /** + * Crash summaries captured by the ThemeIsle SDK crash reporter, which + * stores per-product `{product_key}_crash_data` options (reports already + * redacted and size-capped client-side by the SDK). Reads the options + * directly — no SDK classes involved — so sites without the SDK, without + * crash-capable product versions, or without any recorded crash all get + * an empty product list, never an error. + */ + function get_crashes( $request ) { + global $wpdb; + + $suffix = '_crash_data'; + $products = array(); + $names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name LIMIT %d", + '%' . $wpdb->esc_like( $suffix ), + self::MAX_CRASH_PRODUCTS + ) + ); + + foreach ( (array) $names as $name ) { + $data = get_option( $name, array() ); + if ( ! is_array( $data ) ) { + continue; + } + // "product", not "product_key" — the redaction backstop blanks + // any key name matching /key/ + $products[] = array( + 'product' => substr( $name, 0, 0 - strlen( $suffix ) ), + 'reports' => isset( $data['reports'] ) && is_array( $data['reports'] ) ? array_values( $data['reports'] ) : array(), + 'meta' => isset( $data['meta'] ) && is_array( $data['meta'] ) ? $data['meta'] : array(), + ); + } + + return $this->respond( array( 'products' => $products ) ); + } + function respond( $data ) { $data = $this->redact( $data ); $encoded = wp_json_encode( $data ); diff --git a/tests/test-agent-api.php b/tests/test-agent-api.php index e9307c4..91abdc6 100644 --- a/tests/test-agent-api.php +++ b/tests/test-agent-api.php @@ -151,6 +151,61 @@ public function test_manifest_with_valid_token() { $slugs = wp_list_pluck( $data['sections'], 'slug' ); $this->assertContains( 'site', $slugs ); $this->assertContains( 'logs', $slugs ); + $this->assertContains( 'crashes', $slugs ); + } + + public function test_crashes_section_is_empty_without_crash_data() { + $response = $this->request( '/crashes', $this->agent_token ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( 'products' => array() ), $response->get_data() ); + } + + public function test_crashes_section_lists_sdk_crash_data_and_skips_malformed() { + update_option( + 'neve_crash_data', + array( + 'reports' => array( + 'abc123' => array( + 'fingerprint' => 'abc123', + 'event_type' => 'fatal_error', + 'message' => 'Call to undefined function neve_missing()', + 'file' => 'product:inc/broken.php', + 'line' => 42, + 'request_context' => 'frontend', + 'product_version' => '4.2.3', + 'count' => 7, + ), + ), + 'meta' => array( 'dropped' => 0 ), + ) + ); + // shape written by something else entirely — must be skipped, not fatal + update_option( 'rogue_crash_data', 'not-an-array' ); + // SDK option present but empty/partial — listed with normalized shape + update_option( 'hestia_crash_data', array( 'meta' => array() ) ); + + $response = $this->request( '/crashes', $this->agent_token ); + $this->assertSame( 200, $response->get_status() ); + + $products = $response->get_data()['products']; + $this->assertSame( array( 'hestia', 'neve' ), wp_list_pluck( $products, 'product' ) ); + + $neve = $products[1]; + $this->assertSame( 'fatal_error', $neve['reports'][0]['event_type'] ); + $this->assertSame( 42, $neve['reports'][0]['line'] ); + $this->assertSame( array( 'dropped' => 0 ), $neve['meta'] ); + $this->assertSame( array(), $products[0]['reports'] ); + } + + public function test_crashes_section_bounds_the_option_scan() { + for ( $i = 1; $i <= TI_Parrot_Agent_API::MAX_CRASH_PRODUCTS + 5; $i++ ) { + update_option( sprintf( 'product%02d_crash_data', $i ), array( 'reports' => array(), 'meta' => array() ) ); + } + + $products = $this->request( '/crashes', $this->agent_token )->get_data()['products']; + + $this->assertCount( TI_Parrot_Agent_API::MAX_CRASH_PRODUCTS, $products ); } public function test_x_parrot_token_header_fallback() {