diff --git a/src/Fetch/Concerns/ManagesDebugAndProfiling.php b/src/Fetch/Concerns/ManagesDebugAndProfiling.php new file mode 100644 index 0000000..66154ba --- /dev/null +++ b/src/Fetch/Concerns/ManagesDebugAndProfiling.php @@ -0,0 +1,178 @@ + + */ + protected array $debugOptions = []; + + /** + * The profiler instance for performance tracking. + */ + protected ?FetchProfiler $profiler = null; + + /** + * The last debug info from the most recent request. + */ + protected ?DebugInfo $lastDebugInfo = null; + + /** + * Enable debug mode with specified options. + * + * @param array|bool $options Debug options or true to enable all + * @return $this + */ + public function withDebug(array|bool $options = true): static + { + $this->debugEnabled = $options !== false; + $this->debugOptions = array_merge(DebugInfo::getDefaultOptions(), is_array($options) ? $options : []); + + return $this; + } + + /** + * Set a profiler for performance tracking. + * + * @param FetchProfiler $profiler The profiler instance + * @return $this + */ + public function withProfiler(FetchProfiler $profiler): static + { + $this->profiler = $profiler; + + return $this; + } + + /** + * Get the profiler instance if set. + */ + public function getProfiler(): ?FetchProfiler + { + return $this->profiler; + } + + /** + * Check if debug mode is enabled. + */ + public function isDebugEnabled(): bool + { + return $this->debugEnabled; + } + + /** + * Get the debug options. + * + * @return array + */ + public function getDebugOptions(): array + { + return $this->debugOptions; + } + + /** + * Get the last debug info from the most recent request. + */ + public function getLastDebugInfo(): ?DebugInfo + { + return $this->lastDebugInfo; + } + + /** + * Create debug info for the current request. + * + * @param string $method HTTP method + * @param string $uri Request URI + * @param array $options Request options + * @param \Psr\Http\Message\ResponseInterface|null $response The response + * @param array $timings Timing information + * @param int $memoryUsage Memory usage in bytes + */ + protected function createDebugInfo( + string $method, + string $uri, + array $options, + ?\Psr\Http\Message\ResponseInterface $response = null, + array $timings = [], + int $memoryUsage = 0 + ): DebugInfo { + $this->lastDebugInfo = DebugInfo::create( + $method, + $uri, + $options, + $response, + $timings, + $memoryUsage + ); + + return $this->lastDebugInfo; + } + + /** + * Start profiling for a request. + * + * @param string $method HTTP method + * @param string $uri Request URI + * @return string|null The request ID if profiling is enabled + */ + protected function startProfiling(string $method, string $uri): ?string + { + if ($this->profiler === null || ! $this->profiler->isEnabled()) { + return null; + } + + $requestId = FetchProfiler::generateRequestId($method, $uri); + $this->profiler->startProfile($requestId); + + return $requestId; + } + + /** + * Record a profiling event. + * + * @param string|null $requestId The request ID + * @param string $event The event name + */ + protected function recordProfilingEvent(?string $requestId, string $event): void + { + if ($requestId === null || $this->profiler === null) { + return; + } + + $this->profiler->recordEvent($requestId, $event); + } + + /** + * End profiling for a request. + * + * @param string|null $requestId The request ID + * @param int|null $statusCode HTTP status code + */ + protected function endProfiling(?string $requestId, ?int $statusCode = null): void + { + if ($requestId === null || $this->profiler === null) { + return; + } + + $this->profiler->endProfile($requestId, $statusCode); + } +} diff --git a/src/Fetch/Concerns/PerformsHttpRequests.php b/src/Fetch/Concerns/PerformsHttpRequests.php index 4f277f7..aac842a 100644 --- a/src/Fetch/Concerns/PerformsHttpRequests.php +++ b/src/Fetch/Concerns/PerformsHttpRequests.php @@ -344,17 +344,55 @@ protected function executeSyncRequest( array $options, float $startTime, ): ResponseInterface { - return $this->retryRequest(function () use ($method, $uri, $options, $startTime): ResponseInterface { + // Start profiling if profiler is available + $requestId = null; + if (method_exists($this, 'startProfiling')) { + $requestId = $this->startProfiling($method, $uri); + } + + // Track memory for debugging + $startMemory = memory_get_usage(true); + + return $this->retryRequest(function () use ($method, $uri, $options, $startTime, $requestId, $startMemory): ResponseInterface { try { + // Record request sent event for profiling + if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { + $this->recordProfilingEvent($requestId, 'request_sent'); + } + // Send the request to Guzzle $psrResponse = $this->getHttpClient()->request($method, $uri, $options); + // Record response received event for profiling + if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { + $this->recordProfilingEvent($requestId, 'response_start'); + } + // Calculate duration $duration = microtime(true) - $startTime; // Create our response object $response = Response::createFromBase($psrResponse); + // End profiling + if ($requestId !== null && method_exists($this, 'endProfiling')) { + $this->endProfiling($requestId, $response->getStatusCode()); + } + + // Create debug info if debug mode is enabled + if (method_exists($this, 'isDebugEnabled') && $this->isDebugEnabled()) { + $memoryUsage = memory_get_usage(true) - $startMemory; + $timings = [ + 'total_time' => round($duration * 1000, 3), + 'start_time' => $startTime, + 'end_time' => microtime(true), + ]; + + if (method_exists($this, 'createDebugInfo')) { + $this->createDebugInfo($method, $uri, $options, $response, $timings, $memoryUsage); + } + } + // Trigger retry on configured retryable status codes if (in_array($response->getStatusCode(), $this->getRetryableStatusCodes(), true)) { $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); @@ -369,6 +407,11 @@ protected function executeSyncRequest( return $response; } catch (GuzzleException $e) { + // End profiling with error + if ($requestId !== null && method_exists($this, 'endProfiling')) { + $this->endProfiling($requestId, null); + } + // Normalize to Fetch RequestException to participate in retry logic if ($e instanceof GuzzleRequestException) { $req = $e->getRequest(); diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php index a2a75cb..d6dbc82 100644 --- a/src/Fetch/Http/ClientHandler.php +++ b/src/Fetch/Http/ClientHandler.php @@ -7,6 +7,7 @@ use Fetch\Concerns\ConfiguresRequests; use Fetch\Concerns\HandlesMocking; use Fetch\Concerns\HandlesUris; +use Fetch\Concerns\ManagesDebugAndProfiling; use Fetch\Concerns\ManagesPromises; use Fetch\Concerns\ManagesRetries; use Fetch\Concerns\PerformsHttpRequests; @@ -28,6 +29,7 @@ class ClientHandler implements ClientHandlerInterface use ConfiguresRequests, HandlesMocking, HandlesUris, + ManagesDebugAndProfiling, ManagesPromises, ManagesRetries, PerformsHttpRequests; diff --git a/src/Fetch/Interfaces/ClientHandler.php b/src/Fetch/Interfaces/ClientHandler.php index 7a6c5b4..142a3d6 100644 --- a/src/Fetch/Interfaces/ClientHandler.php +++ b/src/Fetch/Interfaces/ClientHandler.php @@ -535,4 +535,42 @@ public function getRetryableExceptions(): array; * @return static New client handler instance */ public function withClonedOptions(array $options): self; + + /** + * Enable debug mode with specified options. + * + * @param array|bool $options Debug options or true to enable all + * @return $this + */ + public function withDebug(array|bool $options = true): self; + + /** + * Set a profiler for performance tracking. + * + * @param \Fetch\Support\FetchProfiler $profiler The profiler instance + * @return $this + */ + public function withProfiler(\Fetch\Support\FetchProfiler $profiler): self; + + /** + * Get the profiler instance if set. + */ + public function getProfiler(): ?\Fetch\Support\FetchProfiler; + + /** + * Check if debug mode is enabled. + */ + public function isDebugEnabled(): bool; + + /** + * Get the debug options. + * + * @return array + */ + public function getDebugOptions(): array; + + /** + * Get the last debug info from the most recent request. + */ + public function getLastDebugInfo(): ?\Fetch\Support\DebugInfo; } diff --git a/src/Fetch/Support/DebugInfo.php b/src/Fetch/Support/DebugInfo.php new file mode 100644 index 0000000..cddc509 --- /dev/null +++ b/src/Fetch/Support/DebugInfo.php @@ -0,0 +1,299 @@ + + */ + protected static array $defaultOptions = [ + 'request_headers' => true, + 'request_body' => true, + 'response_headers' => true, + 'response_body' => 1024, // First 1KB by default, false to disable, true for all + 'timing' => true, + 'memory' => true, + 'dns_resolution' => false, + ]; + + /** + * Create a new DebugInfo instance. + * + * @param array $requestData Request data (method, uri, headers, body) + * @param ResponseInterface|null $response The HTTP response + * @param array $timings Timing information for the request + * @param array $connectionStats Connection statistics + * @param int $memoryUsage Memory usage in bytes + */ + public function __construct( + protected array $requestData, + protected ?ResponseInterface $response, + protected array $timings = [], + protected array $connectionStats = [], + protected int $memoryUsage = 0 + ) {} + + /** + * Create a DebugInfo instance from a request and response. + * + * @param string $method HTTP method + * @param string $uri Request URI + * @param array $options Request options including headers and body + * @param ResponseInterface|null $response The HTTP response + * @param array $timings Timing information + * @param int $memoryUsage Memory usage in bytes + */ + public static function create( + string $method, + string $uri, + array $options, + ?ResponseInterface $response = null, + array $timings = [], + int $memoryUsage = 0 + ): static { + $requestData = [ + 'method' => strtoupper($method), + 'uri' => $uri, + 'headers' => $options['headers'] ?? [], + 'body' => $options['body'] ?? ($options['json'] ?? null), + ]; + + return new static($requestData, $response, $timings, [], $memoryUsage); + } + + /** + * Get the default debug options. + * + * @return array + */ + public static function getDefaultOptions(): array + { + return self::$defaultOptions; + } + + /** + * Set default debug options. + * + * @param array $options + */ + public static function setDefaultOptions(array $options): void + { + self::$defaultOptions = array_merge(self::$defaultOptions, $options); + } + + /** + * Get the request data. + * + * @return array + */ + public function getRequestData(): array + { + return $this->requestData; + } + + /** + * Get the response. + */ + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + /** + * Get the timing information. + * + * @return array + */ + public function getTimings(): array + { + return $this->timings; + } + + /** + * Get the connection statistics. + * + * @return array + */ + public function getConnectionStats(): array + { + return $this->connectionStats; + } + + /** + * Get the memory usage. + */ + public function getMemoryUsage(): int + { + return $this->memoryUsage; + } + + /** + * Format the request information for output. + * + * @param array $options Output options + * @return array + */ + public function formatRequest(array $options = []): array + { + $options = array_merge(self::$defaultOptions, $options); + $formatted = [ + 'method' => $this->requestData['method'] ?? 'GET', + 'uri' => $this->requestData['uri'] ?? '', + ]; + + if ($options['request_headers']) { + $formatted['headers'] = $this->requestData['headers'] ?? []; + } + + if ($options['request_body']) { + $body = $this->requestData['body'] ?? null; + if ($body !== null) { + $formatted['body'] = $this->formatBody($body, $options['request_body']); + } + } + + return $formatted; + } + + /** + * Format the response information for output. + * + * @param array $options Output options + * @return array|null + */ + public function formatResponse(array $options = []): ?array + { + if ($this->response === null) { + return null; + } + + $options = array_merge(self::$defaultOptions, $options); + $formatted = [ + 'status_code' => $this->response->getStatusCode(), + 'reason_phrase' => $this->response->getReasonPhrase(), + ]; + + if ($options['response_headers']) { + $formatted['headers'] = $this->response->getHeaders(); + } + + if ($options['response_body'] !== false) { + $body = (string) $this->response->getBody(); + $this->response->getBody()->rewind(); + $formatted['body'] = $this->formatBody($body, $options['response_body']); + } + + return $formatted; + } + + /** + * Get the debug information as an array. + * + * @param array $options Output options + * @return array + */ + public function toArray(array $options = []): array + { + $options = array_merge(self::$defaultOptions, $options); + $result = [ + 'request' => $this->formatRequest($options), + ]; + + if ($this->response !== null) { + $result['response'] = $this->formatResponse($options); + } + + if ($options['timing'] && ! empty($this->timings)) { + $result['performance'] = $this->timings; + } + + if ($options['memory'] && $this->memoryUsage > 0) { + $result['memory'] = [ + 'bytes' => $this->memoryUsage, + 'formatted' => $this->formatBytes($this->memoryUsage), + ]; + } + + if (! empty($this->connectionStats)) { + $result['connection'] = $this->connectionStats; + } + + return $result; + } + + /** + * Get the debug information as a JSON string. + * + * @param array $options Output options + */ + public function dump(array $options = []): string + { + return json_encode($this->toArray($options), JSON_PRETTY_PRINT) ?: '{}'; + } + + /** + * Format a body for output, optionally truncating. + * + * @param mixed $body The body content + * @param bool|int $option True for full body, int for max bytes, false to disable + */ + protected function formatBody(mixed $body, bool|int $option): mixed + { + if ($option === false) { + return null; + } + + // If it's an array, encode it as JSON for readability + if (is_array($body)) { + $body = json_encode($body, JSON_PRETTY_PRINT); + } + + if (! is_string($body)) { + return $body; + } + + // Truncate if a byte limit is specified + if (is_int($option) && strlen($body) > $option) { + return substr($body, 0, $option).'... (truncated)'; + } + + return $body; + } + + /** + * Format bytes into human-readable string. + */ + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $unitIndex = 0; + + while ($bytes >= 1024 && $unitIndex < count($units) - 1) { + $bytes /= 1024; + $unitIndex++; + } + + return round($bytes, 2).' '.$units[$unitIndex]; + } + + /** + * Get debug info as string representation. + */ + public function __toString(): string + { + return $this->dump(); + } +} diff --git a/src/Fetch/Support/FetchProfiler.php b/src/Fetch/Support/FetchProfiler.php new file mode 100644 index 0000000..30c31f7 --- /dev/null +++ b/src/Fetch/Support/FetchProfiler.php @@ -0,0 +1,277 @@ +> + */ + protected array $profiles = []; + + /** + * Whether the profiler is enabled. + */ + protected bool $enabled = true; + + /** + * Generate a unique request ID. + * + * @param string $method HTTP method + * @param string $uri Request URI + */ + public static function generateRequestId(string $method, string $uri): string + { + return sprintf('%s_%s_%s', strtoupper($method), md5($uri), uniqid()); + } + + /** + * Start a new profile for a request. + * + * @param string $requestId Unique identifier for the request + */ + public function startProfile(string $requestId): void + { + if (! $this->enabled) { + return; + } + + $this->profiles[$requestId] = [ + 'start_time' => microtime(true), + 'start_memory' => memory_get_usage(true), + 'events' => [], + 'completed' => false, + ]; + } + + /** + * Record a timing event for a request. + * + * @param string $requestId The request identifier + * @param string $event The event name (e.g., 'dns_start', 'connect_start', 'ssl_start', 'transfer_start') + * @param float|null $timestamp The timestamp (defaults to current time) + */ + public function recordEvent(string $requestId, string $event, ?float $timestamp = null): void + { + if (! $this->enabled || ! isset($this->profiles[$requestId])) { + return; + } + + $this->profiles[$requestId]['events'][$event] = $timestamp ?? microtime(true); + } + + /** + * End a profile for a request. + * + * @param string $requestId The request identifier + * @param int|null $statusCode HTTP status code of the response + */ + public function endProfile(string $requestId, ?int $statusCode = null): void + { + if (! $this->enabled || ! isset($this->profiles[$requestId])) { + return; + } + + $this->profiles[$requestId]['end_time'] = microtime(true); + $this->profiles[$requestId]['end_memory'] = memory_get_usage(true); + $this->profiles[$requestId]['status_code'] = $statusCode; + $this->profiles[$requestId]['completed'] = true; + } + + /** + * Get the profile for a specific request. + * + * @param string $requestId The request identifier + * @return array|null The profile data or null if not found + */ + public function getProfile(string $requestId): ?array + { + if (! isset($this->profiles[$requestId])) { + return null; + } + + return $this->calculateMetrics($requestId); + } + + /** + * Get all profiles. + * + * @return array> + */ + public function getAllProfiles(): array + { + $calculated = []; + foreach (array_keys($this->profiles) as $requestId) { + $calculated[$requestId] = $this->calculateMetrics($requestId); + } + + return $calculated; + } + + /** + * Clear a specific profile. + * + * @param string $requestId The request identifier + */ + public function clearProfile(string $requestId): void + { + unset($this->profiles[$requestId]); + } + + /** + * Clear all profiles. + */ + public function clearAll(): void + { + $this->profiles = []; + } + + /** + * Enable the profiler. + * + * @return $this + */ + public function enable(): static + { + $this->enabled = true; + + return $this; + } + + /** + * Disable the profiler. + * + * @return $this + */ + public function disable(): static + { + $this->enabled = false; + + return $this; + } + + /** + * Check if the profiler is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get summary statistics across all profiles. + * + * @return array + */ + public function getSummary(): array + { + $allProfiles = $this->getAllProfiles(); + + if (empty($allProfiles)) { + return [ + 'total_requests' => 0, + 'completed_requests' => 0, + 'failed_requests' => 0, + 'total_time' => 0, + 'average_time' => 0, + 'min_time' => 0, + 'max_time' => 0, + 'total_memory' => 0, + ]; + } + + $times = []; + $completed = 0; + $failed = 0; + $totalMemory = 0; + + foreach ($allProfiles as $profile) { + if ($profile['completed']) { + $times[] = $profile['total_time']; + $completed++; + + if (isset($profile['status_code']) && $profile['status_code'] >= 400) { + $failed++; + } + } + + $totalMemory += abs($profile['memory_delta']); + } + + $totalTime = array_sum($times); + + return [ + 'total_requests' => count($allProfiles), + 'completed_requests' => $completed, + 'failed_requests' => $failed, + 'total_time' => round($totalTime, 3), + 'average_time' => $completed > 0 ? round($totalTime / $completed, 3) : 0, + 'min_time' => ! empty($times) ? round(min($times), 3) : 0, + 'max_time' => ! empty($times) ? round(max($times), 3) : 0, + 'total_memory' => $totalMemory, + ]; + } + + /** + * Calculate metrics for a profile. + * + * @param string $requestId The request identifier + * @return array + */ + protected function calculateMetrics(string $requestId): array + { + $profile = $this->profiles[$requestId]; + $startTime = $profile['start_time']; + $endTime = $profile['end_time'] ?? microtime(true); + $events = $profile['events']; + + $metrics = [ + 'request_id' => $requestId, + 'total_time' => round(($endTime - $startTime) * 1000, 3), // in ms + 'memory_start' => $profile['start_memory'], + 'memory_end' => $profile['end_memory'] ?? memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'status_code' => $profile['status_code'] ?? null, + 'completed' => $profile['completed'], + ]; + + // Calculate memory delta + $metrics['memory_delta'] = $metrics['memory_end'] - $metrics['memory_start']; + + // Calculate timing phases if events are recorded + if (isset($events['dns_start'], $events['dns_end'])) { + $metrics['dns_time'] = round(($events['dns_end'] - $events['dns_start']) * 1000, 3); + } + + if (isset($events['connect_start'], $events['connect_end'])) { + $metrics['connect_time'] = round(($events['connect_end'] - $events['connect_start']) * 1000, 3); + } + + if (isset($events['ssl_start'], $events['ssl_end'])) { + $metrics['ssl_time'] = round(($events['ssl_end'] - $events['ssl_start']) * 1000, 3); + } + + if (isset($events['transfer_start'], $events['transfer_end'])) { + $metrics['transfer_time'] = round(($events['transfer_end'] - $events['transfer_start']) * 1000, 3); + } + + if (isset($events['request_sent'])) { + $metrics['time_to_first_byte'] = round((($events['response_start'] ?? $endTime) - $events['request_sent']) * 1000, 3); + } + + // Include raw events for detailed analysis + $metrics['events'] = $events; + + return $metrics; + } +} diff --git a/tests/Unit/DebugInfoTest.php b/tests/Unit/DebugInfoTest.php new file mode 100644 index 0000000..6799a41 --- /dev/null +++ b/tests/Unit/DebugInfoTest.php @@ -0,0 +1,228 @@ + ['Authorization' => 'Bearer token']], + null, + ['total_time' => 100.5], + 1024 + ); + + $this->assertInstanceOf(DebugInfo::class, $debugInfo); + $this->assertEquals(['total_time' => 100.5], $debugInfo->getTimings()); + $this->assertEquals(1024, $debugInfo->getMemoryUsage()); + } + + public function test_create_with_response(): void + { + $response = new Psr7Response(200, ['Content-Type' => 'application/json'], '{"data":"test"}'); + + $debugInfo = DebugInfo::create( + 'POST', + 'https://example.com/api', + ['headers' => ['Content-Type' => 'application/json'], 'body' => ['test' => 'data']], + $response + ); + + $this->assertSame($response, $debugInfo->getResponse()); + $requestData = $debugInfo->getRequestData(); + $this->assertEquals('POST', $requestData['method']); + $this->assertEquals('https://example.com/api', $requestData['uri']); + } + + public function test_format_request(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + ['headers' => ['Accept' => 'application/json']], + ); + + $formatted = $debugInfo->formatRequest(); + + $this->assertEquals('GET', $formatted['method']); + $this->assertEquals('https://example.com/api', $formatted['uri']); + $this->assertArrayHasKey('headers', $formatted); + } + + public function test_format_request_with_disabled_headers(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + ['headers' => ['Accept' => 'application/json']], + ); + + $formatted = $debugInfo->formatRequest(['request_headers' => false]); + + $this->assertArrayNotHasKey('headers', $formatted); + } + + public function test_format_response(): void + { + $response = new Psr7Response(200, ['Content-Type' => 'application/json'], '{"status":"ok"}'); + + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [], + $response + ); + + $formatted = $debugInfo->formatResponse(); + + $this->assertEquals(200, $formatted['status_code']); + $this->assertEquals('OK', $formatted['reason_phrase']); + $this->assertArrayHasKey('headers', $formatted); + $this->assertArrayHasKey('body', $formatted); + } + + public function test_format_response_returns_null_when_no_response(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [] + ); + + $this->assertNull($debugInfo->formatResponse()); + } + + public function test_format_response_body_truncation(): void + { + $longBody = str_repeat('x', 2000); + $response = new Psr7Response(200, [], $longBody); + + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [], + $response + ); + + // Default truncation is 1024 bytes + $formatted = $debugInfo->formatResponse(['response_body' => 100]); + + $this->assertStringContainsString('... (truncated)', $formatted['body']); + $this->assertLessThan(200, strlen($formatted['body'])); + } + + public function test_to_array(): void + { + $response = new Psr7Response(200, [], '{"data":"test"}'); + + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [], + $response, + ['total_time' => 50.0], + 2048 + ); + + $array = $debugInfo->toArray(); + + $this->assertArrayHasKey('request', $array); + $this->assertArrayHasKey('response', $array); + $this->assertArrayHasKey('performance', $array); + $this->assertArrayHasKey('memory', $array); + $this->assertEquals(2048, $array['memory']['bytes']); + } + + public function test_dump_returns_json_string(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [] + ); + + $json = $debugInfo->dump(); + + $this->assertIsString($json); + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('request', $decoded); + } + + public function test_to_string_returns_json(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [] + ); + + $string = (string) $debugInfo; + + $this->assertJson($string); + } + + public function test_get_and_set_default_options(): void + { + $originalOptions = DebugInfo::getDefaultOptions(); + + DebugInfo::setDefaultOptions(['response_body' => false]); + $newOptions = DebugInfo::getDefaultOptions(); + + $this->assertFalse($newOptions['response_body']); + + // Restore original options + DebugInfo::setDefaultOptions($originalOptions); + } + + public function test_format_body_with_array(): void + { + $debugInfo = DebugInfo::create( + 'POST', + 'https://example.com/api', + ['body' => ['key' => 'value']], + ); + + $formatted = $debugInfo->formatRequest(); + + // Array bodies are JSON-encoded for readability + $this->assertArrayHasKey('body', $formatted); + } + + public function test_memory_formatting(): void + { + $debugInfo = DebugInfo::create( + 'GET', + 'https://example.com/api', + [], + null, + [], + 1024 * 1024 // 1MB + ); + + $array = $debugInfo->toArray(); + + $this->assertArrayHasKey('memory', $array); + $this->assertEquals('1 MB', $array['memory']['formatted']); + } + + public function test_json_body_option_is_captured(): void + { + $debugInfo = DebugInfo::create( + 'POST', + 'https://example.com/api', + ['json' => ['name' => 'test', 'value' => 123]], + ); + + $requestData = $debugInfo->getRequestData(); + + $this->assertEquals(['name' => 'test', 'value' => 123], $requestData['body']); + } +} diff --git a/tests/Unit/FetchProfilerTest.php b/tests/Unit/FetchProfilerTest.php new file mode 100644 index 0000000..eea9c77 --- /dev/null +++ b/tests/Unit/FetchProfilerTest.php @@ -0,0 +1,231 @@ +profiler = new FetchProfiler; + } + + public function test_profiler_is_enabled_by_default(): void + { + $this->assertTrue($this->profiler->isEnabled()); + } + + public function test_can_enable_and_disable_profiler(): void + { + $this->profiler->disable(); + $this->assertFalse($this->profiler->isEnabled()); + + $this->profiler->enable(); + $this->assertTrue($this->profiler->isEnabled()); + } + + public function test_start_profile_creates_entry(): void + { + $this->profiler->startProfile('test-request'); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertNotNull($profile); + $this->assertEquals('test-request', $profile['request_id']); + $this->assertFalse($profile['completed']); + } + + public function test_start_profile_does_nothing_when_disabled(): void + { + $this->profiler->disable(); + $this->profiler->startProfile('test-request'); + + $this->assertNull($this->profiler->getProfile('test-request')); + } + + public function test_record_event(): void + { + $this->profiler->startProfile('test-request'); + $this->profiler->recordEvent('test-request', 'dns_start'); + $this->profiler->recordEvent('test-request', 'dns_end'); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertArrayHasKey('dns_start', $profile['events']); + $this->assertArrayHasKey('dns_end', $profile['events']); + } + + public function test_record_event_with_custom_timestamp(): void + { + $timestamp = 1234567890.123; + + $this->profiler->startProfile('test-request'); + $this->profiler->recordEvent('test-request', 'custom_event', $timestamp); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertEquals($timestamp, $profile['events']['custom_event']); + } + + public function test_record_event_does_nothing_for_nonexistent_request(): void + { + $this->profiler->recordEvent('nonexistent', 'event'); + + $this->assertNull($this->profiler->getProfile('nonexistent')); + } + + public function test_end_profile(): void + { + $this->profiler->startProfile('test-request'); + $this->profiler->endProfile('test-request', 200); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertTrue($profile['completed']); + $this->assertEquals(200, $profile['status_code']); + } + + public function test_get_profile_returns_null_for_nonexistent(): void + { + $this->assertNull($this->profiler->getProfile('nonexistent')); + } + + public function test_get_profile_calculates_metrics(): void + { + $this->profiler->startProfile('test-request'); + usleep(10000); // 10ms + $this->profiler->endProfile('test-request', 200); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertArrayHasKey('total_time', $profile); + $this->assertArrayHasKey('memory_start', $profile); + $this->assertArrayHasKey('memory_end', $profile); + $this->assertArrayHasKey('memory_delta', $profile); + $this->assertArrayHasKey('memory_peak', $profile); + $this->assertGreaterThan(0, $profile['total_time']); + } + + public function test_get_profile_calculates_timing_phases(): void + { + $this->profiler->startProfile('test-request'); + + // Simulate DNS resolution + $dnsStart = microtime(true); + $this->profiler->recordEvent('test-request', 'dns_start', $dnsStart); + usleep(5000); + $this->profiler->recordEvent('test-request', 'dns_end', $dnsStart + 0.005); + + // Simulate connection + $connectStart = microtime(true); + $this->profiler->recordEvent('test-request', 'connect_start', $connectStart); + usleep(5000); + $this->profiler->recordEvent('test-request', 'connect_end', $connectStart + 0.005); + + $this->profiler->endProfile('test-request', 200); + + $profile = $this->profiler->getProfile('test-request'); + + $this->assertArrayHasKey('dns_time', $profile); + $this->assertArrayHasKey('connect_time', $profile); + $this->assertGreaterThan(0, $profile['dns_time']); + $this->assertGreaterThan(0, $profile['connect_time']); + } + + public function test_get_all_profiles(): void + { + $this->profiler->startProfile('request-1'); + $this->profiler->endProfile('request-1', 200); + + $this->profiler->startProfile('request-2'); + $this->profiler->endProfile('request-2', 201); + + $profiles = $this->profiler->getAllProfiles(); + + $this->assertCount(2, $profiles); + $this->assertArrayHasKey('request-1', $profiles); + $this->assertArrayHasKey('request-2', $profiles); + } + + public function test_clear_profile(): void + { + $this->profiler->startProfile('test-request'); + $this->profiler->clearProfile('test-request'); + + $this->assertNull($this->profiler->getProfile('test-request')); + } + + public function test_clear_all(): void + { + $this->profiler->startProfile('request-1'); + $this->profiler->startProfile('request-2'); + $this->profiler->clearAll(); + + $this->assertEmpty($this->profiler->getAllProfiles()); + } + + public function test_get_summary_with_no_profiles(): void + { + $summary = $this->profiler->getSummary(); + + $this->assertEquals(0, $summary['total_requests']); + $this->assertEquals(0, $summary['completed_requests']); + $this->assertEquals(0, $summary['total_time']); + } + + public function test_get_summary_with_profiles(): void + { + // First request + $this->profiler->startProfile('request-1'); + usleep(5000); + $this->profiler->endProfile('request-1', 200); + + // Second request + $this->profiler->startProfile('request-2'); + usleep(10000); + $this->profiler->endProfile('request-2', 500); + + // Third request (incomplete) + $this->profiler->startProfile('request-3'); + + $summary = $this->profiler->getSummary(); + + $this->assertEquals(3, $summary['total_requests']); + $this->assertEquals(2, $summary['completed_requests']); + $this->assertEquals(1, $summary['failed_requests']); // 500 status + $this->assertGreaterThan(0, $summary['total_time']); + $this->assertGreaterThan(0, $summary['average_time']); + $this->assertGreaterThan(0, $summary['min_time']); + $this->assertGreaterThan(0, $summary['max_time']); + } + + public function test_generate_request_id(): void + { + $requestId1 = FetchProfiler::generateRequestId('GET', 'https://example.com/api'); + $requestId2 = FetchProfiler::generateRequestId('GET', 'https://example.com/api'); + + // IDs should be unique even for the same method/uri + $this->assertNotEquals($requestId1, $requestId2); + + // ID should start with the method + $this->assertStringStartsWith('GET_', $requestId1); + } + + public function test_enable_returns_fluent_interface(): void + { + $result = $this->profiler->disable()->enable(); + + $this->assertSame($this->profiler, $result); + } + + public function test_disable_returns_fluent_interface(): void + { + $result = $this->profiler->enable()->disable(); + + $this->assertSame($this->profiler, $result); + } +} diff --git a/tests/Unit/ManagesDebugAndProfilingTest.php b/tests/Unit/ManagesDebugAndProfilingTest.php new file mode 100644 index 0000000..22452a2 --- /dev/null +++ b/tests/Unit/ManagesDebugAndProfilingTest.php @@ -0,0 +1,111 @@ +handler = new ClientHandler; + } + + public function test_with_debug_enables_debug_mode(): void + { + $result = $this->handler->withDebug(); + + $this->assertSame($this->handler, $result); + $this->assertTrue($this->handler->isDebugEnabled()); + } + + public function test_with_debug_false_disables_debug_mode(): void + { + $this->handler->withDebug(true); + $this->assertTrue($this->handler->isDebugEnabled()); + + $this->handler->withDebug(false); + $this->assertFalse($this->handler->isDebugEnabled()); + } + + public function test_with_debug_accepts_options_array(): void + { + $options = [ + 'request_headers' => true, + 'request_body' => false, + 'response_body' => 512, + ]; + + $this->handler->withDebug($options); + + $this->assertTrue($this->handler->isDebugEnabled()); + $debugOptions = $this->handler->getDebugOptions(); + + $this->assertFalse($debugOptions['request_body']); + $this->assertEquals(512, $debugOptions['response_body']); + } + + public function test_debug_mode_disabled_by_default(): void + { + $this->assertFalse($this->handler->isDebugEnabled()); + } + + public function test_get_debug_options_returns_defaults_when_enabled(): void + { + $this->handler->withDebug(true); + + $options = $this->handler->getDebugOptions(); + + $this->assertIsArray($options); + $this->assertArrayHasKey('request_headers', $options); + $this->assertArrayHasKey('response_headers', $options); + $this->assertArrayHasKey('timing', $options); + $this->assertArrayHasKey('memory', $options); + } + + public function test_with_profiler_sets_profiler(): void + { + $profiler = new FetchProfiler; + + $result = $this->handler->withProfiler($profiler); + + $this->assertSame($this->handler, $result); + $this->assertSame($profiler, $this->handler->getProfiler()); + } + + public function test_get_profiler_returns_null_when_not_set(): void + { + $this->assertNull($this->handler->getProfiler()); + } + + public function test_get_last_debug_info_returns_null_initially(): void + { + $this->assertNull($this->handler->getLastDebugInfo()); + } + + public function test_with_debug_is_fluent(): void + { + $result = $this->handler + ->withDebug(['request_headers' => true]) + ->withProfiler(new FetchProfiler); + + $this->assertInstanceOf(ClientHandler::class, $result); + } + + public function test_debug_options_merge_with_defaults(): void + { + $this->handler->withDebug(['custom_option' => 'value']); + + $options = $this->handler->getDebugOptions(); + + // Default options should still be present + $this->assertArrayHasKey('request_headers', $options); + $this->assertArrayHasKey('custom_option', $options); + $this->assertEquals('value', $options['custom_option']); + } +}