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:
peptiderepo
2026-04-21 00:59:54 +07:00
parent 24a025c173
commit a3ed663bae
3 changed files with 98 additions and 9 deletions

View File

@@ -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;
}

View File

@@ -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 [

View File

@@ -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.
*/