fix(image-pipeline): surface per-image failures in the event log
Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:
1. The batch provider tagged each failed key with ['error' => ...] but
the ERROR log line it emitted didn't identify which key failed, so
operators couldn't tell image_a from image_b in the event log when
one of two parallel requests died.
2. Image_Pipeline::process_image_a/_b folded the per-key error into
$result['errors'] and returned silently, with no WARNING row. Ops
only learned about the miss when the outer catch in the caller fired
— and on the 2026-04-20 incident that outer catch didn't fire, so
nothing was logged at all except an unrelated pricing warning.
Fix:
- class-open-router-image-batch.php: collect_result() takes the request
key as its first argument and every Logger::error message reads
'OpenRouter batch image gen <class> for key "<key>": <detail>'.
Matches the shape of OpenRouter_Image_Support::log_retry.
- class-image-pipeline.php: process_image_a and process_image_b now
emit a Logger::warning('image_pipeline') row whenever $image_data is
missing or carries an error key, in addition to pushing to
$result['errors']. The warning fires whether or not the outer catch
runs.
Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.
No behavior change beyond logging; cost impact zero.
Part of the v0.8.0 image bundle (issues from thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -165,7 +165,14 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_a'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$result['errors'][] = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,7 +203,14 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_b'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$result['errors'][] = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
$results = [];
|
||||
foreach ( $handles as $key => $entry ) {
|
||||
$results[ $key ] = $this->collect_result(
|
||||
(string) $key,
|
||||
$entry['handle'],
|
||||
$entry['model'],
|
||||
$entry['req'],
|
||||
@@ -169,21 +170,35 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
/**
|
||||
* Parse the result of one cURL handle into image data or error.
|
||||
*
|
||||
* All failure paths emit `Logger::error` at the point they fold the
|
||||
* request into an `['error' => ...]` array — tagged with the request
|
||||
* `$key` so the pipeline can attribute which of the parallel images
|
||||
* failed (image_a vs image_b) without cross-referencing timestamps.
|
||||
*
|
||||
* @param string $key Request key from the batch map (e.g. "image_a").
|
||||
* @param \CurlHandle $ch Individual cURL handle (completed).
|
||||
* @param string $model Resolved model id.
|
||||
* @param array $req Original request array.
|
||||
* @param float $start Microtime when this handle was created.
|
||||
*
|
||||
* @return array Image data array or `{error: string}`.
|
||||
*
|
||||
* Side effects: writes `Logger::error` on any failure path.
|
||||
*/
|
||||
private function collect_result( $ch, string $model, array $req, float $start ): array {
|
||||
private function collect_result( string $key, $ch, string $model, array $req, float $start ): array {
|
||||
$latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 );
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
$curl_errno = curl_errno( $ch );
|
||||
if ( 0 !== $curl_errno ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
$msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) );
|
||||
$detail = curl_error( $ch );
|
||||
$msg = sprintf(
|
||||
'OpenRouter batch image gen cURL error %d for key "%s": %s',
|
||||
$curl_errno,
|
||||
$key,
|
||||
$detail
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
@@ -194,7 +209,12 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
$raw = (string) curl_multi_getcontent( $ch );
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
$msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) );
|
||||
$msg = sprintf(
|
||||
'OpenRouter batch image gen HTTP %d for key "%s": %s',
|
||||
$http_code,
|
||||
$key,
|
||||
substr( $raw, 0, 300 )
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
@@ -202,11 +222,13 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
try {
|
||||
$image_bytes = $this->support->extract_image_bytes( $raw );
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Batch parse error: ' . $e->getMessage(),
|
||||
'openrouter-image-batch'
|
||||
$msg = sprintf(
|
||||
'OpenRouter batch image gen parse error for key "%s": %s',
|
||||
$key,
|
||||
$e->getMessage()
|
||||
);
|
||||
return [ 'error' => $e->getMessage() ];
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -211,6 +211,59 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a Logger::warning row is persisted when image_data carries an error.
|
||||
*
|
||||
* Covers Issue 2 from the image-mime-bug thread: previously the pipeline
|
||||
* pushed the error into $result['errors'] silently, with no WARNING-level
|
||||
* event_log row visible to ops. This regression-guards that a WARNING row
|
||||
* now fires whether or not the outer catch in the caller triggers.
|
||||
*/
|
||||
public function test_partial_batch_failure_emits_warning_log_row(): void {
|
||||
// Seed Logger's log_level so warnings actually get written.
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [
|
||||
'prautoblogger_image_enabled' => '1',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
|
||||
$mock_wpdb = $this->create_mock_wpdb();
|
||||
$captured_rows = [];
|
||||
$mock_wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $row ) use ( &$captured_rows ) {
|
||||
$captured_rows[] = $row;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image_batch' )->willReturn( [
|
||||
'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ],
|
||||
] );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
$pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
|
||||
|
||||
$warning_rows = array_filter(
|
||||
$captured_rows,
|
||||
static fn( $row ) => ( $row['level'] ?? '' ) === 'warning'
|
||||
&& ( $row['context'] ?? '' ) === 'image_pipeline'
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$warning_rows,
|
||||
'Expected at least one image_pipeline WARNING row when image_data has an error key.'
|
||||
);
|
||||
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test graceful handling when one image in the batch returns an error.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user