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'] );
+ }
+}