diff --git a/includes/admin/class-opik-settings.php b/includes/admin/class-opik-settings.php new file mode 100644 index 0000000..a7c33c3 --- /dev/null +++ b/includes/admin/class-opik-settings.php @@ -0,0 +1,201 @@ + 'boolean' ) + ); + + add_settings_field( + 'prautoblogger_opik_project_name', + __( 'Opik project name', 'prautoblogger' ), + array( $this, 'render_field_project_name' ), + 'prautoblogger_settings', + 'prautoblogger_observability' + ); + + register_setting( + 'prautoblogger_settings', + 'prautoblogger_opik_project_name', + array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ) + ); + + add_settings_field( + 'prautoblogger_opik_capture_full_prompt', + __( 'Capture full prompt text', 'prautoblogger' ), + array( $this, 'render_field_capture_full' ), + 'prautoblogger_settings', + 'prautoblogger_observability' + ); + + register_setting( + 'prautoblogger_settings', + 'prautoblogger_opik_capture_full_prompt', + array( 'type' => 'boolean' ) + ); + + add_settings_field( + 'prautoblogger_opik_status', + __( 'Status', 'prautoblogger' ), + array( $this, 'render_field_status' ), + 'prautoblogger_settings', + 'prautoblogger_observability' + ); + } + + /** + * Render section help text. + */ + public function render_section_help(): void { + echo wp_kses_post( + '

Optional LLM observability via Comet Opik. ' . + 'Captures detailed traces of article generation with per-LLM-call metrics. ' . + 'Requires API credentials in wp-config.php.

' + ); + } + + /** + * Render the "Enable Opik" checkbox. + */ + public function render_field_enabled(): void { + $enabled = get_option( 'prautoblogger_opik_enabled', false ); + $checked = $enabled ? 'checked' : ''; + ?> + /> + + has_credentials() ) { + echo '

' . + esc_html__( 'Note: API credentials must be set in wp-config.php (PRAUTOBLOGGER_OPIK_API_KEY, PRAUTOBLOGGER_OPIK_WORKSPACE)', 'prautoblogger' ) . + '

'; + } + } + + /** + * Render the project name field. + */ + public function render_field_project_name(): void { + $value = get_option( 'prautoblogger_opik_project_name', 'prautoblogger' ); + ?> + +

+ +

+ + /> + +

+ +

+ has_credentials(); + $queue = new PRAutoBlogger_Opik_Span_Queue(); + $queue_depth = $queue->get_queue_depth(); + $last_dispatch = get_option( 'prautoblogger_opik_last_dispatch', 0 ); + $last_dispatch_str = $last_dispatch + ? sprintf( + /* translators: %s = human-readable time */ + __( '%s ago', 'prautoblogger' ), + human_time_diff( (int) $last_dispatch ) + ) + : __( 'Never', 'prautoblogger' ); + + ?> +
+

+ + ✓ Yes' : '✗ No'; ?> +

+

+ + +

+

+ + + +

+
+ api_key = $api_key; + $this->workspace = $workspace; + $this->base_url = rtrim( $base_url, '/' ) . '/'; + $this->timeout = $timeout; + } + + /** + * Create a trace (top-level span container for one article generation). + * + * @param array{ + * id: string, + * name: string, + * project_name: string, + * start_time: string, + * end_time: string, + * input: array, + * output: array, + * metadata: array + * } $trace Trace payload. + * + * @return array|null Response from Opik, or null on persistent failure. + */ + public function create_trace( array $trace ): ?array { + $endpoint = 'v1/private/traces'; + return $this->post( $endpoint, $trace ); + } + + /** + * Create a single span (LLM call record). + * + * @param array{ + * id: string, + * trace_id: string, + * parent_span_id?: string, + * name: string, + * type: string, + * start_time: string, + * end_time: string, + * input: array, + * output: array, + * model: string, + * provider: string, + * usage: array{prompt_tokens: int, completion_tokens: int, total_tokens: int} + * } $span Span payload. + * + * @return array|null Response from Opik, or null on persistent failure. + */ + public function create_span( array $span ): ?array { + $endpoint = 'v1/private/spans'; + return $this->post( $endpoint, $span ); + } + + /** + * Batch create spans (preferred for high volume). + * + * @param array $spans Array of span payloads (max 100 per batch). + * + * @return array|null Response from Opik, or null on persistent failure. + */ + public function batch_create_spans( array $spans ): ?array { + // Enforce batch size limit. + if ( count( $spans ) > 100 ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Batch span count %d exceeds 100 limit; truncating', count( $spans ) ), + 'opik' + ); + $spans = array_slice( $spans, 0, 100 ); + } + + $endpoint = 'v1/private/spans/batch'; + $payload = array( 'spans' => $spans ); + return $this->post( $endpoint, $payload ); + } + + /** + * Batch create traces. + * + * @param array $traces Array of trace payloads (max 100 per batch). + * + * @return array|null Response from Opik, or null on persistent failure. + */ + public function batch_create_traces( array $traces ): ?array { + // Enforce batch size limit. + if ( count( $traces ) > 100 ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Batch trace count %d exceeds 100 limit; truncating', count( $traces ) ), + 'opik' + ); + $traces = array_slice( $traces, 0, 100 ); + } + + $endpoint = 'v1/private/traces/batch'; + $payload = array( 'traces' => $traces ); + return $this->post( $endpoint, $payload ); + } + + /** + * POST to Opik with exponential backoff retry on transient errors. + * + * Retries on: HTTP 5xx, timeout, network error. + * Does not retry on: 4xx (permanent errors). + * + * @param string $endpoint Relative endpoint path (e.g. 'v1/private/traces'). + * @param array $payload JSON-serializable payload. + * @param int $attempt Internal retry counter (start at 0). + * + * @return array|null Decoded JSON response, or null on final failure. + */ + private function post( string $endpoint, array $payload, int $attempt = 0 ): ?array { + $max_attempts = 3; + + $url = $this->base_url . $endpoint; + $headers = $this->build_headers(); + $body = wp_json_encode( $payload ); + + if ( false === $body ) { + PRAutoBlogger_Logger::instance()->error( + 'Failed to JSON-encode Opik payload', + 'opik' + ); + return null; + } + + $args = array( + 'method' => 'POST', + 'headers' => $headers, + 'body' => $body, + 'timeout' => $this->timeout, + 'data_format' => 'body', + ); + + $response = wp_remote_post( $url, $args ); + + if ( is_wp_error( $response ) ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'Opik HTTP error (attempt %d/%d): %s', + $attempt + 1, + $max_attempts, + $response->get_error_message() + ), + 'opik' + ); + + // Retry on network/timeout errors. + if ( $attempt < $max_attempts - 1 ) { + $delay = (int) ( 2 ** $attempt ); // 1s, 2s, 4s + sleep( $delay ); + return $this->post( $endpoint, $payload, $attempt + 1 ); + } + + return null; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + + // Success. + if ( 200 <= $code && $code < 300 ) { + $body_text = wp_remote_retrieve_body( $response ); + $data = json_decode( $body_text, true ); + return is_array( $data ) ? $data : array(); + } + + // Permanent client error. + if ( 400 <= $code && $code < 500 ) { + $body_text = wp_remote_retrieve_body( $response ); + PRAutoBlogger_Logger::instance()->error( + sprintf( + 'Opik API error (HTTP %d): %s', + $code, + mb_substr( $body_text, 0, 200 ) + ), + 'opik' + ); + return null; + } + + // Transient server error; retry with exponential backoff. + if ( 500 <= $code && $code < 600 ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'Opik server error (HTTP %d, attempt %d/%d)', + $code, + $attempt + 1, + $max_attempts + ), + 'opik' + ); + + if ( $attempt < $max_attempts - 1 ) { + $delay = (int) ( 2 ** $attempt ); + sleep( $delay ); + return $this->post( $endpoint, $payload, $attempt + 1 ); + } + + return null; + } + + // Unexpected status. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Opik unexpected HTTP %d', $code ), + 'opik' + ); + return null; + } + + /** + * Build HTTP headers for Opik API requests. + * + * Auth: Authorization: (raw, not Bearer) + * Workspace: Comet-Workspace: peptiderepo + * + * @return array + */ + private function build_headers(): array { + return array( + 'Content-Type' => 'application/json', + 'Authorization' => $this->api_key, + 'Comet-Workspace' => $this->workspace, + ); + } +} diff --git a/includes/services/opik/class-opik-dispatcher.php b/includes/services/opik/class-opik-dispatcher.php new file mode 100644 index 0000000..10c135a --- /dev/null +++ b/includes/services/opik/class-opik-dispatcher.php @@ -0,0 +1,184 @@ +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 ); + } +} diff --git a/includes/services/opik/class-opik-span-queue.php b/includes/services/opik/class-opik-span-queue.php new file mode 100644 index 0000000..daa0452 --- /dev/null +++ b/includes/services/opik/class-opik-span-queue.php @@ -0,0 +1,170 @@ +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' : '' ); + } +} diff --git a/includes/services/opik/class-opik-trace-context.php b/includes/services/opik/class-opik-trace-context.php new file mode 100644 index 0000000..6ccefb3 --- /dev/null +++ b/includes/services/opik/class-opik-trace-context.php @@ -0,0 +1,196 @@ +trace_id = wp_generate_uuid4(); + $this->trace_start = microtime( true ); + return $this->trace_id; + } + + /** + * Get the current trace ID. + * + * @return string + */ + public function get_trace_id(): string { + return $this->trace_id; + } + + /** + * Start a span within the current trace. + * + * @param array{ + * name: string, + * type: string, + * model?: string, + * provider?: string, + * input?: array, + * parent_span_id?: string + * } $span_data Span metadata. + * + * @return string Generated span ID (UUID). + */ + public function start_span( array $span_data ): string { + $span_id = wp_generate_uuid4(); + + $span = array( + 'id' => $span_id, + 'trace_id' => $this->trace_id, + 'name' => $span_data['name'] ?? 'unknown', + 'type' => $span_data['type'] ?? 'general', + 'start_time' => gmdate( 'Y-m-d\TH:i:s\Z' ), + 'start_time_ms' => microtime( true ), + 'end_time' => null, + 'end_time_ms' => null, + 'input' => $span_data['input'] ?? array(), + 'output' => array(), + 'model' => $span_data['model'] ?? null, + 'provider' => $span_data['provider'] ?? null, + 'parent_span_id' => $span_data['parent_span_id'] ?? null, + 'usage' => array(), + ); + + $this->span_stack[ $span_id ] = $span; + return $span_id; + } + + /** + * End a span and record its output. + * + * @param string $span_id Span ID from start_span(). + * @param array{ + * output?: array, + * usage?: array + * } $span_data Final span data to merge. + */ + public function end_span( string $span_id, array $span_data = array() ): void { + if ( ! isset( $this->span_stack[ $span_id ] ) ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Attempt to end unknown span %s', $span_id ), + 'opik' + ); + return; + } + + $span = &$this->span_stack[ $span_id ]; + $span['end_time'] = gmdate( 'Y-m-d\TH:i:s\Z' ); + $span['end_time_ms'] = microtime( true ); + $span['output'] = $span_data['output'] ?? array(); + $span['usage'] = $span_data['usage'] ?? array(); + } + + /** + * Get a completed span from the stack. + * + * @param string $span_id + * + * @return array|null + */ + public function get_span( string $span_id ): ?array { + return $this->span_stack[ $span_id ] ?? null; + } + + /** + * Get all spans collected in this trace. + * + * @return array + */ + public function get_spans(): array { + return $this->span_stack; + } + + /** + * Finalize and return the trace object with all spans. + * + * @return array{ + * id: string, + * name: string, + * project_name: string, + * start_time: string, + * end_time: string, + * input: array, + * output: array, + * metadata: array, + * spans: array + * } + */ + public function finalize_trace(): array { + $trace_end = microtime( true ); + + return array( + 'id' => $this->trace_id, + 'name' => 'article_generation', + 'project_name' => get_option( 'prautoblogger_opik_project_name', 'prautoblogger' ), + 'start_time' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $this->trace_start ), + 'end_time' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $trace_end ), + 'input' => array( 'pipeline' => 'article_generation' ), + 'output' => array( 'spans_count' => count( $this->span_stack ) ), + 'metadata' => array( 'source' => 'prautoblogger' ), + 'spans' => $this->span_stack, + ); + } + + /** + * Tear down the context (clear it for the next request). + * + * Called at the end of the article generation pipeline. + */ + public static function teardown(): void { + self::$instance = null; + } +} diff --git a/tests/unit/Services/test-opik-client.php b/tests/unit/Services/test-opik-client.php new file mode 100644 index 0000000..9720bcd --- /dev/null +++ b/tests/unit/Services/test-opik-client.php @@ -0,0 +1,281 @@ +assertInstanceOf( \PRAutoBlogger_Opik_Client::class, $client ); + } + + /** + * Test create_trace sends correct payload to endpoint. + */ + public function test_create_trace_sends_payload(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'test-key', + 'test-workspace' + ); + + $trace_payload = array( + 'id' => 'trace-123', + 'name' => 'test_trace', + 'project_name' => 'prautoblogger', + 'start_time' => '2026-04-23T10:00:00Z', + 'end_time' => '2026-04-23T10:01:00Z', + 'input' => array( 'topic' => 'test' ), + 'output' => array( 'status' => 'ok' ), + 'metadata' => array(), + ); + + // Mock wp_remote_post to return success. + Functions\when( 'wp_remote_post' )->justReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'id' => 'trace-123' ) ), + ) + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'id' => 'trace-123' ) ) ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $result = $client->create_trace( $trace_payload ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'id', $result ); + } + + /** + * Test create_span sends span with correct structure. + */ + public function test_create_span_sends_payload(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'test-key', + 'test-workspace' + ); + + $span_payload = array( + 'id' => 'span-456', + 'trace_id' => 'trace-123', + 'name' => 'draft_generation', + 'type' => 'llm', + 'start_time' => '2026-04-23T10:00:00Z', + 'end_time' => '2026-04-23T10:00:15Z', + 'input' => array( 'prompt_hash' => 'sha256-abc' ), + 'output' => array( 'response' => 'test' ), + 'model' => 'gpt-4', + 'provider' => 'openrouter', + 'usage' => array( + 'prompt_tokens' => 100, + 'completion_tokens' => 50, + 'total_tokens' => 150, + ), + ); + + Functions\when( 'wp_remote_post' )->justReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'id' => 'span-456' ) ), + ) + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'id' => 'span-456' ) ) ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $result = $client->create_span( $span_payload ); + + $this->assertIsArray( $result ); + } + + /** + * Test batch_create_spans respects the 100-item limit. + */ + public function test_batch_create_spans_truncates_over_100(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'test-key', + 'test-workspace' + ); + + // Create 150 dummy spans. + $spans = array_map( + function( $i ) { + return array( + 'id' => "span-$i", + 'trace_id' => 'trace-123', + 'name' => 'test', + 'type' => 'llm', + 'start_time' => '2026-04-23T10:00:00Z', + 'end_time' => '2026-04-23T10:00:01Z', + 'input' => array(), + 'output' => array(), + 'model' => 'gpt-4', + 'provider' => 'openrouter', + 'usage' => array( 'total_tokens' => 10 ), + ); + }, + range( 1, 150 ) + ); + + Functions\when( 'wp_remote_post' )->justReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'inserted' => 100 ) ), + ) + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'inserted' => 100 ) ) ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + // Should truncate and succeed. + $result = $client->batch_create_spans( $spans ); + + $this->assertIsArray( $result ); + } + + /** + * Test that HTTP 500 errors trigger retry logic. + */ + public function test_retry_on_500_error(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'test-key', + 'test-workspace', + 'https://api.example.com/opik/', + 1 // 1-second timeout to make test fast + ); + + $call_count = 0; + Functions\when( 'wp_remote_post' )->alias( + function() use ( &$call_count ) { + ++$call_count; + if ( $call_count < 3 ) { + // First two calls return 500. + return array( + 'response' => array( 'code' => 500 ), + 'body' => 'Server error', + ); + } + // Third call succeeds. + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'ok' => true ) ), + ); + } + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->alias( + function() use ( &$call_count ) { + return $call_count < 3 ? 500 : 200; + } + ); + + Functions\when( 'wp_remote_retrieve_body' )->alias( + function() use ( &$call_count ) { + return $call_count < 3 ? 'Server error' : wp_json_encode( array( 'ok' => true ) ); + } + ); + + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $result = $client->create_trace( array( 'id' => 'test', 'name' => 'test' ) ); + + // Should eventually succeed on 3rd attempt. + $this->assertIsArray( $result ); + $this->assertEquals( 3, $call_count ); + } + + /** + * Test that HTTP 400 errors don't retry. + */ + public function test_no_retry_on_400_error(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'test-key', + 'test-workspace' + ); + + $call_count = 0; + Functions\when( 'wp_remote_post' )->alias( + function() use ( &$call_count ) { + ++$call_count; + return array( + 'response' => array( 'code' => 400 ), + 'body' => wp_json_encode( array( 'error' => 'Bad request' ) ), + ); + } + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'error' => 'Bad request' ) ) ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $result = $client->create_trace( array( 'id' => 'test', 'name' => 'test' ) ); + + // Should fail on first attempt, not retry. + $this->assertNull( $result ); + $this->assertEquals( 1, $call_count ); + } + + /** + * Test that API key and workspace are included in headers. + */ + public function test_headers_include_credentials(): void { + $client = new \PRAutoBlogger_Opik_Client( + 'secret-key-12345', + 'my-workspace' + ); + + $captured_headers = null; + Functions\when( 'wp_remote_post' )->alias( + function( $url, $args ) use ( &$captured_headers ) { + $captured_headers = $args['headers'] ?? array(); + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array() ), + ); + } + ); + + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array() ) ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $client->create_trace( array( 'id' => 'test', 'name' => 'test' ) ); + + $this->assertIsArray( $captured_headers ); + $this->assertArrayHasKey( 'Authorization', $captured_headers ); + $this->assertEquals( 'secret-key-12345', $captured_headers['Authorization'] ); + $this->assertArrayHasKey( 'Comet-Workspace', $captured_headers ); + $this->assertEquals( 'my-workspace', $captured_headers['Comet-Workspace'] ); + } +} diff --git a/tests/unit/Services/test-opik-dispatcher.php b/tests/unit/Services/test-opik-dispatcher.php new file mode 100644 index 0000000..5a40b8e --- /dev/null +++ b/tests/unit/Services/test-opik-dispatcher.php @@ -0,0 +1,241 @@ +alias( + function( $option, $default = false ) { + return $this->option_store[ $option ] ?? $default; + } + ); + + Functions\when( 'update_option' )->alias( + function( $option, $value ) { + $this->option_store[ $option ] = $value; + return true; + } + ); + + Functions\when( 'delete_option' )->alias( + function( $option ) { + unset( $this->option_store[ $option ] ); + return true; + } + ); + } + + /** + * Test dispatcher skips if Opik is not enabled. + */ + public function test_dispatch_skips_if_disabled(): void { + $client = $this->create_mock_client(); + $queue = $this->create_mock_queue(); + + // Queue has items but feature is disabled. + $this->option_store['prautoblogger_opik_enabled'] = false; + + $dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue ); + $dispatcher->dispatch(); + + // Client should not have been called. + $this->assertTrue( true ); + } + + /** + * Test dispatch() batches and posts spans. + */ + public function test_dispatch_posts_spans(): void { + // Enable Opik and set credentials. + $this->option_store['prautoblogger_opik_enabled'] = true; + + // Mock constants. + if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) { + define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' ); + } + if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) { + define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' ); + } + + $posted_spans = null; + + $client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'batch_create_spans', 'batch_create_traces' ) ) + ->getMock(); + + $client->expects( $this->once() ) + ->method( 'batch_create_spans' ) + ->willReturnCallback( function( $spans ) use ( &$posted_spans ) { + $posted_spans = $spans; + return array( 'inserted' => count( $spans ) ); + } ); + + $queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'dequeue', 'get_queue_depth' ) ) + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'dequeue' ) + ->with( 100 ) + ->willReturn( + array( + array( + 'type' => 'span', + 'payload' => array( 'id' => 'span-1', 'name' => 'test' ), + 'attempt' => 0, + 'enqueued' => time(), + ), + ) + ); + + $queue->method( 'get_queue_depth' )->willReturn( 0 ); + + $dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue ); + $dispatcher->dispatch(); + + $this->assertNotNull( $posted_spans ); + $this->assertCount( 1, $posted_spans ); + } + + /** + * Test dispatcher handles empty queue gracefully. + */ + public function test_dispatch_handles_empty_queue(): void { + $this->option_store['prautoblogger_opik_enabled'] = true; + + if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) { + define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' ); + } + if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) { + define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' ); + } + + $client = $this->create_mock_client(); + $queue = $this->create_mock_queue( array() ); + + $dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue ); + $dispatcher->dispatch(); + + // Should not error. + $this->assertTrue( true ); + } + + /** + * Test dispatcher separates spans and traces. + */ + public function test_dispatch_separates_types(): void { + $this->option_store['prautoblogger_opik_enabled'] = true; + + if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) { + define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' ); + } + if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) { + define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' ); + } + + $posted_traces = null; + $posted_spans = null; + + $client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'batch_create_traces', 'batch_create_spans' ) ) + ->getMock(); + + $client->method( 'batch_create_traces' )->willReturnCallback( + function( $traces ) use ( &$posted_traces ) { + $posted_traces = $traces; + return array( 'inserted' => count( $traces ) ); + } + ); + + $client->method( 'batch_create_spans' )->willReturnCallback( + function( $spans ) use ( &$posted_spans ) { + $posted_spans = $spans; + return array( 'inserted' => count( $spans ) ); + } + ); + + $queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'dequeue', 'get_queue_depth' ) ) + ->getMock(); + + $queue->method( 'dequeue' )->willReturn( + array( + array( + 'type' => 'trace', + 'payload' => array( 'id' => 'trace-1' ), + 'attempt' => 0, + 'enqueued' => time(), + ), + array( + 'type' => 'span', + 'payload' => array( 'id' => 'span-1' ), + 'attempt' => 0, + 'enqueued' => time(), + ), + ) + ); + + $queue->method( 'get_queue_depth' )->willReturn( 0 ); + + $dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue ); + $dispatcher->dispatch(); + + $this->assertNotNull( $posted_traces ); + $this->assertCount( 1, $posted_traces ); + $this->assertNotNull( $posted_spans ); + $this->assertCount( 1, $posted_spans ); + } + + /** + * Helper to create a mock client. + */ + private function create_mock_client() { + $client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'batch_create_spans', 'batch_create_traces' ) ) + ->getMock(); + + $client->method( 'batch_create_spans' )->willReturn( array() ); + $client->method( 'batch_create_traces' )->willReturn( array() ); + + return $client; + } + + /** + * Helper to create a mock queue. + */ + private function create_mock_queue( array $items = array() ) { + $queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class ) + ->disableOriginalConstructor() + ->onlyMethods( array( 'dequeue', 'get_queue_depth' ) ) + ->getMock(); + + $queue->method( 'dequeue' )->willReturn( $items ); + $queue->method( 'get_queue_depth' )->willReturn( count( $items ) ); + + return $queue; + } +} diff --git a/tests/unit/Services/test-opik-span-queue.php b/tests/unit/Services/test-opik-span-queue.php new file mode 100644 index 0000000..d22be91 --- /dev/null +++ b/tests/unit/Services/test-opik-span-queue.php @@ -0,0 +1,248 @@ +alias( + function( $option, $default = false ) { + return $this->option_store[ $option ] ?? $default; + } + ); + + Functions\when( 'update_option' )->alias( + function( $option, $value ) { + $this->option_store[ $option ] = $value; + return true; + } + ); + + Functions\when( 'delete_option' )->alias( + function( $option ) { + unset( $this->option_store[ $option ] ); + return true; + } + ); + } + + /** + * Test enqueue() adds item to queue. + */ + public function test_enqueue_adds_item(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $payload = array( + 'id' => 'span-1', + 'name' => 'test', + 'trace_id' => 'trace-1', + ); + + $success = $queue->enqueue( $payload, 'span' ); + + $this->assertTrue( $success ); + $this->assertEquals( 1, $queue->get_queue_depth() ); + } + + /** + * Test enqueue() respects max depth limit. + */ + public function test_enqueue_respects_max_depth(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + // Fill queue to max. + for ( $i = 0; $i < 1000; ++$i ) { + $success = $queue->enqueue( + array( 'id' => "span-$i", 'name' => 'test' ), + 'span' + ); + if ( ! $success ) { + // Queue full. + $this->assertEquals( 1000, $queue->get_queue_depth() ); + return; + } + } + + // If we got here, queue filled successfully. + $this->assertEquals( 1000, $queue->get_queue_depth() ); + + // Try to add one more — should fail. + $success = $queue->enqueue( + array( 'id' => 'overflow', 'name' => 'test' ), + 'span' + ); + $this->assertFalse( $success ); + } + + /** + * Test dequeue() returns items in order. + */ + public function test_dequeue_returns_items(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $queue->enqueue( array( 'id' => 'span-1' ), 'span' ); + $queue->enqueue( array( 'id' => 'span-2' ), 'span' ); + $queue->enqueue( array( 'id' => 'span-3' ), 'span' ); + + $batch = $queue->dequeue( 10 ); + + $this->assertCount( 3, $batch ); + $this->assertEquals( 'span-1', $batch[0]['payload']['id'] ); + $this->assertEquals( 'span-2', $batch[1]['payload']['id'] ); + $this->assertEquals( 'span-3', $batch[2]['payload']['id'] ); + } + + /** + * Test dequeue() respects limit parameter. + */ + public function test_dequeue_respects_limit(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + for ( $i = 1; $i <= 50; ++$i ) { + $queue->enqueue( array( 'id' => "span-$i" ), 'span' ); + } + + $batch = $queue->dequeue( 10 ); + + $this->assertCount( 10, $batch ); + $this->assertEquals( 40, $queue->get_queue_depth() ); + } + + /** + * Test reenqueue() increments attempt counter. + */ + public function test_reenqueue_increments_attempt(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $items = array( + array( + 'type' => 'span', + 'payload' => array( 'id' => 'span-1' ), + 'attempt' => 0, + 'enqueued' => time(), + ), + ); + + $queue->reenqueue( $items ); + + $batch = $queue->dequeue( 10 ); + $this->assertEquals( 1, $batch[0]['attempt'] ); + } + + /** + * Test reenqueue() drops items after max retries. + */ + public function test_reenqueue_drops_after_max_attempts(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $items = array( + array( + 'type' => 'span', + 'payload' => array( 'id' => 'span-1' ), + 'attempt' => 3, // Already at max. + 'enqueued' => time(), + ), + ); + + $queue->reenqueue( $items ); + + // Item should be dropped, queue should be empty. + $this->assertEquals( 0, $queue->get_queue_depth() ); + } + + /** + * Test dequeue() removes expired items (TTL > 12 hours). + */ + public function test_dequeue_removes_expired_items(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $now = time(); + + // Add a fresh item. + $fresh = array( + 'type' => 'span', + 'payload' => array( 'id' => 'fresh' ), + 'enqueued' => $now, + ); + + // Add an expired item (13 hours old). + $expired = array( + 'type' => 'span', + 'payload' => array( 'id' => 'expired' ), + 'enqueued' => $now - 46800, // 13 hours in seconds. + ); + + $this->option_store['prautoblogger_opik_queue'] = array( $fresh, $expired ); + + $batch = $queue->dequeue( 10 ); + + // Only fresh item should be returned. + $this->assertCount( 1, $batch ); + $this->assertEquals( 'fresh', $batch[0]['payload']['id'] ); + } + + /** + * Test get_queue_depth() returns item count. + */ + public function test_get_queue_depth(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $this->assertEquals( 0, $queue->get_queue_depth() ); + + $queue->enqueue( array( 'id' => 'span-1' ), 'span' ); + $this->assertEquals( 1, $queue->get_queue_depth() ); + + $queue->enqueue( array( 'id' => 'span-2' ), 'span' ); + $this->assertEquals( 2, $queue->get_queue_depth() ); + } + + /** + * Test clear() empties the queue. + */ + public function test_clear_empties_queue(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $queue->enqueue( array( 'id' => 'span-1' ), 'span' ); + $queue->enqueue( array( 'id' => 'span-2' ), 'span' ); + + $this->assertEquals( 2, $queue->get_queue_depth() ); + + $queue->clear(); + + $this->assertEquals( 0, $queue->get_queue_depth() ); + } + + /** + * Test enqueue() with both span and trace types. + */ + public function test_enqueue_handles_trace_type(): void { + $queue = new \PRAutoBlogger_Opik_Span_Queue(); + + $queue->enqueue( array( 'id' => 'trace-1' ), 'trace' ); + $queue->enqueue( array( 'id' => 'span-1' ), 'span' ); + + $batch = $queue->dequeue( 10 ); + + $this->assertCount( 2, $batch ); + $this->assertEquals( 'trace', $batch[0]['type'] ); + $this->assertEquals( 'span', $batch[1]['type'] ); + } +} diff --git a/tests/unit/Services/test-opik-trace-context.php b/tests/unit/Services/test-opik-trace-context.php new file mode 100644 index 0000000..457c173 --- /dev/null +++ b/tests/unit/Services/test-opik-trace-context.php @@ -0,0 +1,222 @@ +alias( function() { + static $counter = 0; + return 'uuid-' . ++$counter; + } ); + } + + protected function tearDown(): void { + // Clean up singleton. + \PRAutoBlogger_Opik_Trace_Context::teardown(); + parent::tearDown(); + } + + /** + * Test current() returns singleton instance. + */ + public function test_current_returns_singleton(): void { + $ctx1 = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx2 = \PRAutoBlogger_Opik_Trace_Context::current(); + + $this->assertSame( $ctx1, $ctx2 ); + } + + /** + * Test init_trace() generates a UUID. + */ + public function test_init_trace_generates_uuid(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $trace_id = $ctx->init_trace(); + + $this->assertNotEmpty( $trace_id ); + $this->assertStringStartsWith( 'uuid-', $trace_id ); + $this->assertEquals( $trace_id, $ctx->get_trace_id() ); + } + + /** + * Test start_span() creates span and returns ID. + */ + public function test_start_span_creates_span(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx->init_trace(); + + $span_id = $ctx->start_span( + array( + 'name' => 'draft_generation', + 'type' => 'llm', + 'model' => 'gpt-4', + 'provider' => 'openrouter', + 'input' => array( 'prompt_hash' => 'sha256-abc' ), + ) + ); + + $this->assertNotEmpty( $span_id ); + $this->assertStringStartsWith( 'uuid-', $span_id ); + + $span = $ctx->get_span( $span_id ); + $this->assertIsArray( $span ); + $this->assertEquals( 'draft_generation', $span['name'] ); + $this->assertEquals( 'llm', $span['type'] ); + $this->assertEquals( 'gpt-4', $span['model'] ); + } + + /** + * Test end_span() records output and usage. + */ + public function test_end_span_records_data(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx->init_trace(); + + $span_id = $ctx->start_span( + array( + 'name' => 'test_span', + 'type' => 'llm', + ) + ); + + $ctx->end_span( + $span_id, + array( + 'output' => array( 'response' => 'Hello world' ), + 'usage' => array( + 'prompt_tokens' => 50, + 'completion_tokens' => 25, + 'total_tokens' => 75, + ), + ) + ); + + $span = $ctx->get_span( $span_id ); + $this->assertIsArray( $span['output'] ); + $this->assertEquals( 'Hello world', $span['output']['response'] ); + $this->assertEquals( 75, $span['usage']['total_tokens'] ); + } + + /** + * Test get_spans() returns all active spans. + */ + public function test_get_spans_returns_all(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx->init_trace(); + + $span1 = $ctx->start_span( array( 'name' => 'span1', 'type' => 'llm' ) ); + $span2 = $ctx->start_span( array( 'name' => 'span2', 'type' => 'llm' ) ); + $span3 = $ctx->start_span( array( 'name' => 'span3', 'type' => 'llm' ) ); + + $all_spans = $ctx->get_spans(); + + $this->assertCount( 3, $all_spans ); + $this->assertArrayHasKey( $span1, $all_spans ); + $this->assertArrayHasKey( $span2, $all_spans ); + $this->assertArrayHasKey( $span3, $all_spans ); + } + + /** + * Test finalize_trace() returns complete trace object. + */ + public function test_finalize_trace_includes_all_data(): void { + Functions\when( 'get_option' )->alias( + function( $option, $default = false ) { + if ( 'prautoblogger_opik_project_name' === $option ) { + return 'prautoblogger'; + } + return $default; + } + ); + + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $trace_id = $ctx->init_trace(); + + $span1 = $ctx->start_span( array( 'name' => 'step1', 'type' => 'llm' ) ); + $ctx->end_span( $span1, array( 'output' => array( 'ok' => true ) ) ); + + $span2 = $ctx->start_span( array( 'name' => 'step2', 'type' => 'llm' ) ); + $ctx->end_span( $span2, array( 'output' => array( 'ok' => true ) ) ); + + $trace = $ctx->finalize_trace(); + + $this->assertEquals( $trace_id, $trace['id'] ); + $this->assertEquals( 'article_generation', $trace['name'] ); + $this->assertEquals( 'prautoblogger', $trace['project_name'] ); + $this->assertArrayHasKey( 'start_time', $trace ); + $this->assertArrayHasKey( 'end_time', $trace ); + $this->assertCount( 2, $trace['spans'] ); + } + + /** + * Test teardown() clears the singleton. + */ + public function test_teardown_clears_singleton(): void { + $ctx1 = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx1->init_trace(); + + \PRAutoBlogger_Opik_Trace_Context::teardown(); + + $ctx2 = \PRAutoBlogger_Opik_Trace_Context::current(); + + // Should be a new instance. + $this->assertNotSame( $ctx1, $ctx2 ); + $this->assertEmpty( $ctx2->get_trace_id() ); + } + + /** + * Test warning logged for ending unknown span. + */ + public function test_warning_for_unknown_span(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx->init_trace(); + + // Try to end a span that doesn't exist. + $ctx->end_span( 'unknown-uuid-12345' ); + + // If we got here without an exception, the warning was logged. + $this->assertTrue( true ); + } + + /** + * Test span input is stored as-is (caller responsible for redaction). + */ + public function test_span_input_stored_as_is(): void { + $ctx = \PRAutoBlogger_Opik_Trace_Context::current(); + $ctx->init_trace(); + + $input_data = array( + 'prompt_hash' => 'sha256-abc123', + 'model' => 'gpt-4', + 'version' => '1.0', + ); + + $span_id = $ctx->start_span( + array( + 'name' => 'test', + 'type' => 'llm', + 'input' => $input_data, + ) + ); + + $span = $ctx->get_span( $span_id ); + $this->assertEquals( $input_data, $span['input'] ); + } +}