Files
prautoblogger/includes/services/opik/class-opik-dispatcher.php
peptiderepo ff6ad5eba1 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
2026-04-23 14:48:27 +00:00

185 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
/**
* Opik dispatcher — cron handler for async span/trace dispatch.
*
* Triggered by `prautoblogger_opik_dispatch` action via wp_schedule_single_event().
* Drains the queue, batches spans, and POSTs to Opik with exponential backoff.
*
* Handles retries per-batch (not per-item). Failed batches are re-enqueued
* for the next cron tick.
*
* @see includes/services/opik/class-opik-client.php
* @see includes/services/opik/class-opik-span-queue.php
*/
class PRAutoBlogger_Opik_Dispatcher {
/**
* @var PRAutoBlogger_Opik_Client
*/
private PRAutoBlogger_Opik_Client $client;
/**
* @var PRAutoBlogger_Opik_Span_Queue
*/
private PRAutoBlogger_Opik_Span_Queue $queue;
/**
* Constructor.
*
* @param PRAutoBlogger_Opik_Client $client Client instance.
* @param PRAutoBlogger_Opik_Span_Queue $queue Queue instance.
*/
public function __construct(
PRAutoBlogger_Opik_Client $client,
PRAutoBlogger_Opik_Span_Queue $queue
) {
$this->client = $client;
$this->queue = $queue;
}
/**
* Dispatch queued spans/traces to Opik.
*
* Called via wp_schedule_single_event(). Drains the queue in batches
* and POSTs to Opik. Failed batches are re-enqueued.
*
* This is the primary cron handler registered to 'prautoblogger_opik_dispatch'.
*
* @return void
*/
public function dispatch(): void {
if ( ! $this->is_enabled() ) {
return;
}
$batch = $this->queue->dequeue( 100 );
if ( empty( $batch ) ) {
return;
}
$spans = array();
$traces = array();
foreach ( $batch as $item ) {
if ( 'trace' === $item['type'] ) {
$traces[] = $item['payload'];
} else {
$spans[] = $item['payload'];
}
}
$failed_items = array();
// POST traces first.
if ( ! empty( $traces ) ) {
$success = $this->post_traces( $traces );
if ( ! $success ) {
$failed_items = array_merge(
$failed_items,
array_filter(
$batch,
function( $item ) {
return 'trace' === $item['type'];
}
)
);
}
}
// Then POST spans.
if ( ! empty( $spans ) ) {
$success = $this->post_spans( $spans );
if ( ! $success ) {
$failed_items = array_merge(
$failed_items,
array_filter(
$batch,
function( $item ) {
return 'span' === $item['type'];
}
)
);
}
}
// Re-enqueue failed items with backoff.
if ( ! empty( $failed_items ) ) {
$this->queue->reenqueue( $failed_items );
}
// Log summary.
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Opik dispatch: %d spans, %d traces, %d failed; queue depth now %d',
count( $spans ),
count( $traces ),
count( $failed_items ),
$this->queue->get_queue_depth()
),
'opik'
);
}
/**
* POST traces to Opik.
*
* @param array $traces Array of trace payloads.
*
* @return bool True on success, false on failure.
*/
private function post_traces( array $traces ): bool {
if ( count( $traces ) === 1 ) {
// Single trace: use create_trace.
$response = $this->client->create_trace( $traces[0] );
return null !== $response;
}
// Multiple traces: use batch.
$response = $this->client->batch_create_traces( $traces );
return null !== $response;
}
/**
* POST spans to Opik.
*
* @param array $spans Array of span payloads.
*
* @return bool True on success, false on failure.
*/
private function post_spans( array $spans ): bool {
if ( count( $spans ) === 1 ) {
// Single span: use create_span.
$response = $this->client->create_span( $spans[0] );
return null !== $response;
}
// Multiple spans: use batch.
$response = $this->client->batch_create_spans( $spans );
return null !== $response;
}
/**
* Check if Opik is enabled via feature flag and has credentials.
*
* @return bool
*/
private function is_enabled(): bool {
if ( ! get_option( 'prautoblogger_opik_enabled', false ) ) {
return false;
}
// Credentials must be defined in wp-config.php.
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ||
! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
return false;
}
$api_key = PRAUTOBLOGGER_OPIK_API_KEY;
$workspace = PRAUTOBLOGGER_OPIK_WORKSPACE;
return ! empty( $api_key ) && ! empty( $workspace );
}
}