Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions inc/agent-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -47,6 +50,7 @@ function register_routes() {
'/products' => 'get_products_index',
'/products/(?P<slug>[a-z0-9_-]+)' => 'get_product',
'/logs' => 'get_logs',
'/crashes' => 'get_crashes',
);
foreach ( $routes as $route => $callback ) {
register_rest_route(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 );
Expand Down
55 changes: 55 additions & 0 deletions tests/test-agent-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading