feat: Opik LLM observability tracing skeleton (Wave 1) — v0.12.0
Wires PRAutoblogger into Comet Opik cloud for per-call tracing. - Thin PHP REST client (class-opik-client.php, 276 LOC) - Async fire-and-forget dispatch via wp_schedule_single_event - Feature flag PRAUTOBLOGGER_OPIK_ENABLED defaults OFF - One trace per article, 5 spans across content-generator + chief-editor - Exponential backoff 2s/4s/8s, max 3 retries, drops on failure - Admin settings page (api-key configured indicator only, never rendered) - ARCHITECTURE.md + CONVENTIONS.md updated PR #113 — QA gate: PASS (qa-reviews/prautoblogger/2026-04-23-0d8dc67.md)
This commit is contained in:
281
tests/unit/Services/test-opik-client.php
Normal file
281
tests/unit/Services/test-opik-client.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Opik_Client.
|
||||
*
|
||||
* @see includes/services/opik/class-opik-client.php
|
||||
*/
|
||||
namespace PRAutoBlogger\Tests\Services;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* @group opik
|
||||
* @group services
|
||||
*/
|
||||
class TestOpikClient extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Test constructor initializes properties correctly.
|
||||
*/
|
||||
public function test_constructor_sets_properties(): void {
|
||||
$client = new \PRAutoBlogger_Opik_Client(
|
||||
'test-api-key',
|
||||
'test-workspace',
|
||||
'https://api.example.com/opik/',
|
||||
45
|
||||
);
|
||||
|
||||
$this->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'] );
|
||||
}
|
||||
}
|
||||
241
tests/unit/Services/test-opik-dispatcher.php
Normal file
241
tests/unit/Services/test-opik-dispatcher.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Opik_Dispatcher.
|
||||
*
|
||||
* @see includes/services/opik/class-opik-dispatcher.php
|
||||
*/
|
||||
namespace PRAutoBlogger\Tests\Services;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* @group opik
|
||||
* @group services
|
||||
*/
|
||||
class TestOpikDispatcher extends BaseTestCase {
|
||||
|
||||
private array $option_store = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Mock WP options.
|
||||
Functions\when( 'get_option' )->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;
|
||||
}
|
||||
}
|
||||
248
tests/unit/Services/test-opik-span-queue.php
Normal file
248
tests/unit/Services/test-opik-span-queue.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Opik_Span_Queue.
|
||||
*
|
||||
* @see includes/services/opik/class-opik-span-queue.php
|
||||
*/
|
||||
namespace PRAutoBlogger\Tests\Services;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* @group opik
|
||||
* @group services
|
||||
*/
|
||||
class TestOpikSpanQueue extends BaseTestCase {
|
||||
|
||||
private array $option_store = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Mock WP options functions.
|
||||
Functions\when( 'get_option' )->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'] );
|
||||
}
|
||||
}
|
||||
222
tests/unit/Services/test-opik-trace-context.php
Normal file
222
tests/unit/Services/test-opik-trace-context.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Opik_Trace_Context.
|
||||
*
|
||||
* @see includes/services/opik/class-opik-trace-context.php
|
||||
*/
|
||||
namespace PRAutoBlogger\Tests\Services;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
/**
|
||||
* @group opik
|
||||
* @group services
|
||||
*/
|
||||
class TestOpikTraceContext extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Stub UUID generation.
|
||||
Functions\when( 'wp_generate_uuid4' )->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'] );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user