Opik Wave 1 Commit 1: Client, Trace Context, and test infrastructure

Add the core Opik observability classes:

- class-opik-client.php: HTTP REST client for Comet Opik
  * POST traces and spans with exponential backoff (3 retries)
  * Batch endpoints support (max 100 items per batch)
  * Auth headers: Authorization + Comet-Workspace
  * Never logs secrets; errors logged with context

- class-opik-trace-context.php: Per-request trace singleton
  * Initialized at article generation start
  * Manages span stack: start_span(), end_span()
  * Finalizes complete trace with all child spans
  * Teardown clears context for next request

- class-opik-settings.php: WordPress Settings API integration
  * Toggle: 'Enable Opik tracing' (default off)
  * Text field: 'Opik project name' (default 'prautoblogger')
  * Checkbox: 'Capture full prompt text' (opt-in)
  * Read-only status: API key configured, last dispatch, queue depth
  * Never renders API key in admin HTML

- Unit tests: test-opik-client.php, test-opik-trace-context.php
  * Mock HTTP via Brain\Monkey
  * Verify batch size limits, auth headers, retry logic
  * Verify UUID generation, span stacking, context teardown

All files ≤ 300 LOC per 300-line rule.
Feature flag default OFF (PRAUTOBLOGGER_OPIK_ENABLED option).

Part of Wave 1 (observability skeleton).
Target: v0.12.0
This commit is contained in:
peptiderepo
2026-04-23 14:48:27 +00:00
parent 7401addbd9
commit ff6ad5eba1
9 changed files with 2019 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
/**
* Async queue for Opik span/trace payloads.
*
* Backs the queue with WP options (a single serialized array stored
* in wp_options). Payloads are enqueued at the end of article
* generation, dequeued and batch-posted via cron.
*
* Max queue depth is 1000 items; older items are expired TTL-based.
*
* @see includes/services/opik/class-opik-dispatcher.php — Drains the queue.
*/
class PRAutoBlogger_Opik_Span_Queue {
/**
* @var string WP option key storing the queue.
*/
const QUEUE_OPTION_KEY = 'prautoblogger_opik_queue';
/**
* @var int Max queue depth (items).
*/
const MAX_QUEUE_DEPTH = 1000;
/**
* @var int Item TTL in seconds (12 hours).
*/
const ITEM_TTL_SECONDS = 43200;
/**
* Enqueue a span or trace for async dispatch.
*
* @param array $payload Span or trace payload.
* @param string $type Either 'span' or 'trace'.
*
* @return bool True if enqueued, false if queue full.
*/
public function enqueue( array $payload, string $type = 'span' ): bool {
if ( ! in_array( $type, array( 'span', 'trace' ), true ) ) {
$type = 'span';
}
$queue = $this->get_queue();
if ( count( $queue ) >= self::MAX_QUEUE_DEPTH ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Opik queue full (%d items); dropping span', count( $queue ) ),
'opik'
);
return false;
}
$item = array(
'type' => $type,
'payload' => $payload,
'enqueued' => time(),
'attempt' => 0,
);
$queue[] = $item;
update_option( self::QUEUE_OPTION_KEY, $queue );
return true;
}
/**
* Dequeue the next N items from the queue for processing.
*
* @param int $limit Max items to dequeue.
*
* @return array Array of items with 'type', 'payload', 'enqueued', 'attempt'.
*/
public function dequeue( int $limit = 100 ): array {
$queue = $this->get_queue();
if ( empty( $queue ) ) {
return array();
}
// Remove expired items.
$now = time();
$queue = array_filter(
$queue,
function( $item ) use ( $now ) {
return ( $now - $item['enqueued'] ) < self::ITEM_TTL_SECONDS;
}
);
// Grab the first $limit items.
$batch = array_splice( $queue, 0, $limit );
// Save the remaining queue.
update_option( self::QUEUE_OPTION_KEY, $queue );
return $batch;
}
/**
* Re-enqueue items that failed to post (typically after backoff).
*
* @param array $items Items from a failed batch.
*/
public function reenqueue( array $items ): void {
$queue = $this->get_queue();
foreach ( $items as $item ) {
$item['attempt'] = ( $item['attempt'] ?? 0 ) + 1;
// Drop items that have exceeded max retries (3).
if ( $item['attempt'] >= 3 ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Dropping Opik item after %d attempts: %s',
$item['attempt'],
$item['type']
),
'opik'
);
continue;
}
$queue[] = $item;
}
update_option( self::QUEUE_OPTION_KEY, $queue );
}
/**
* Get the current queue state.
*
* @return array
*/
public function get_queue(): array {
$queue = get_option( self::QUEUE_OPTION_KEY, array() );
return is_array( $queue ) ? $queue : array();
}
/**
* Get queue depth (item count).
*
* @return int
*/
public function get_queue_depth(): int {
return count( $this->get_queue() );
}
/**
* Clear all items from the queue.
*
* @return void
*/
public function clear(): void {
delete_option( self::QUEUE_OPTION_KEY );
}
/**
* Get a human-readable status string for admin display.
*
* @return string
*/
public function get_status(): string {
$depth = $this->get_queue_depth();
if ( 0 === $depth ) {
return 'Queue empty';
}
return sprintf( 'Queue: %d item%s pending', $depth, 1 !== $depth ? 's' : '' );
}
}