Skip to content

Commit d5031fc

Browse files
committed
Logging
1 parent 39ee120 commit d5031fc

7 files changed

Lines changed: 189 additions & 3 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ stateDiagram-v2
4848
- **Symfony Event Dispatcher integration**: Dispatches php-circuit-breaker events through Symfony's event dispatcher
4949
when `symfony/event-dispatcher` is installed.
5050

51+
- **Debug logging**: Logs blocked requests and recorded outcomes to the `bizkit_circuit_breaker` logger channel when a
52+
`logger` service is available.
53+
5154
- **Console commands**: Optional commands are available when `symfony/console` is installed to inspect, force, and clear
5255
circuit breaker state.
5356

@@ -236,6 +239,21 @@ $response = $client->request('GET', 'https://example.com/api', [
236239
]);
237240
```
238241

242+
### Logging
243+
244+
When a `logger` service is available, decorated clients write debug messages to the `bizkit_circuit_breaker` logger
245+
channel. With MonologBundle, filter that channel like any other Symfony logger channel:
246+
247+
```yaml
248+
monolog:
249+
handlers:
250+
circuit_breaker:
251+
type: stream
252+
path: '%kernel.logs_dir%/circuit_breaker.log'
253+
level: debug
254+
channels: [ 'bizkit_circuit_breaker' ]
255+
```
256+
239257
### Console Commands
240258

241259
When `symfony/console` is installed, the bundle registers commands to inspect state, force a state, and clear overrides.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"php": ">=8.1",
3131
"gabrielanhaia/php-circuit-breaker": "^3.0",
3232
"psr/cache": "^3.0",
33+
"psr/log": "^3.0",
3334
"symfony/config": "^6.4 || ^7.4 || ^8.0",
3435
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
3536
"symfony/http-client": "^6.4 || ^7.4 || ^8.0",

src/BizkitCircuitBreakerBundle.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ final class BizkitCircuitBreakerBundle extends AbstractBundle
1515
{
1616
public function build(ContainerBuilder $container): void
1717
{
18-
$container->addCompilerPass(new CircuitBreakerHttpClientPass());
18+
// Needs to run before Monolog's LoggerChannelPass.
19+
$container->addCompilerPass(new CircuitBreakerHttpClientPass(), priority: 10);
1920
}
2021

2122
public function configure(DefinitionConfigurator $definition): void

src/DependencyInjection/Compiler/CircuitBreakerHttpClientPass.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ private static function decorateClient(ContainerBuilder $container, string $clie
150150
new Reference($failureCheckerId),
151151
$clientId,
152152
])
153+
->addMethodCall('setLogger', [new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)])
154+
->addTag('monolog.logger', ['channel' => 'bizkit_circuit_breaker'])
153155
->addTag('kernel.reset', ['method' => 'reset', 'on_invalid' => 'ignore']);
154156

155157
return $circuitBreakerId;

src/HttpClient/CircuitBreakerHttpClient.php

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Bizkit\CircuitBreakerBundle\FailureChecker\FailureCheckerInterface;
88
use GabrielAnhaia\PhpCircuitBreaker\CircuitBreaker;
9+
use Psr\Log\LoggerAwareInterface;
10+
use Psr\Log\LoggerAwareTrait;
911
use Symfony\Component\HttpClient\AsyncDecoratorTrait;
1012
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
1113
use Symfony\Component\HttpClient\MockHttpClient;
@@ -17,9 +19,10 @@
1719
use Symfony\Contracts\HttpClient\ResponseInterface;
1820
use Symfony\Contracts\Service\ResetInterface;
1921

20-
final class CircuitBreakerHttpClient implements HttpClientInterface, ResetInterface
22+
final class CircuitBreakerHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface
2123
{
2224
use AsyncDecoratorTrait;
25+
use LoggerAwareTrait;
2326

2427
public function __construct(
2528
HttpClientInterface $client,
@@ -61,6 +64,11 @@ public function request(string $method, string $url, array $options = []): Respo
6164
}
6265

6366
if (!$this->circuitBreaker->canPass($serviceName)) {
67+
$this->logger?->debug(
68+
'Circuit breaker blocked HTTP request.',
69+
self::createLogContext($method, $url, $serviceName),
70+
);
71+
6472
return self::createOpenCircuitResponse($method, $url, $options, $serviceName);
6573
}
6674

@@ -71,13 +79,21 @@ public function request(string $method, string $url, array $options = []): Respo
7179
$method,
7280
$url,
7381
$options,
74-
function (ChunkInterface $chunk, AsyncContext $context) use (&$recorded, $failureChecker, $serviceName): \Generator {
82+
function (ChunkInterface $chunk, AsyncContext $context) use (&$recorded, $failureChecker, $serviceName, $method, $url): \Generator {
7583
if (!$recorded) {
7684
if ($failureChecker($chunk, $context, $serviceName)) {
7785
$this->circuitBreaker->recordFailure($serviceName);
86+
$this->logger?->debug(
87+
'Circuit breaker recorded HTTP request failure.',
88+
self::createLogContext($method, $url, $serviceName, $chunk, $context),
89+
);
7890
$recorded = true;
7991
} elseif ($chunk->isLast()) {
8092
$this->circuitBreaker->recordSuccess($serviceName);
93+
$this->logger?->debug(
94+
'Circuit breaker recorded HTTP request success.',
95+
self::createLogContext($method, $url, $serviceName, $chunk, $context),
96+
);
8197
$recorded = true;
8298
}
8399
}
@@ -103,4 +119,34 @@ private static function createOpenCircuitResponse(string $method, string $url, a
103119

104120
return new AsyncResponse(new MockHttpClient($response), $method, $url, $options);
105121
}
122+
123+
/**
124+
* @return array<string, int|string>
125+
*/
126+
private static function createLogContext(
127+
string $method,
128+
string $url,
129+
string $serviceName,
130+
?ChunkInterface $chunk = null,
131+
?AsyncContext $context = null,
132+
): array {
133+
$logContext = [
134+
'service_name' => $serviceName,
135+
'method' => $method,
136+
'url' => $url,
137+
];
138+
139+
if (null !== $context) {
140+
$statusCode = $context->getStatusCode();
141+
if (0 !== $statusCode) {
142+
$logContext['status_code'] = $statusCode;
143+
}
144+
}
145+
146+
if (null !== $chunk && null !== $error = $chunk->getError()) {
147+
$logContext['error'] = $error;
148+
}
149+
150+
return $logContext;
151+
}
106152
}

tests/DependencyInjection/CircuitBreakerHttpClientPassTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ public function testDecoratesMainHttpClient(): void
6666
self::assertSame('bizkit_circuit_breaker.failure_checker.default', (string) $decorator->getArgument(2));
6767
self::assertSame('http_client', $decorator->getArgument(3));
6868
self::assertSame(['http_client', null, 30], $decorator->getDecoratedService());
69+
self::assertSame('setLogger', $decorator->getMethodCalls()[0][0]);
70+
self::assertSame('logger', (string) $loggerArg = $decorator->getMethodCalls()[0][1][0]);
71+
self::assertSame(ContainerInterface::IGNORE_ON_INVALID_REFERENCE, $loggerArg->getInvalidBehavior());
72+
self::assertSame([['channel' => 'bizkit_circuit_breaker']], $decorator->getTag('monolog.logger'));
6973

7074
self::assertCommandServiceLocatorContains($container, ['http_client']);
7175
}

tests/HttpClient/CircuitBreakerHttpClientTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use PHPUnit\Framework\Attributes\CoversClass;
1616
use PHPUnit\Framework\Attributes\TestWith;
1717
use PHPUnit\Framework\TestCase;
18+
use Psr\Log\AbstractLogger;
19+
use Psr\Log\LoggerInterface;
1820
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
1921
use Symfony\Component\HttpClient\Exception\ServerException;
2022
use Symfony\Component\HttpClient\Exception\TransportException;
@@ -265,6 +267,90 @@ public function testRecordsFailureOnlyOnceWhenServerErrorResponseAlsoFailsWhileS
265267
}
266268
}
267269

270+
public function testLogsRecordedSuccess(): void
271+
{
272+
$records = [];
273+
$storage = new InMemoryStorage();
274+
$circuitBreaker = new CircuitBreaker($storage, new CircuitBreakerConfig(failureThreshold: 1));
275+
$client = new CircuitBreakerHttpClient(
276+
new MockHttpClient(new MockResponse('ok', ['http_code' => 200])),
277+
$circuitBreaker,
278+
new DefaultFailureChecker(),
279+
'api',
280+
);
281+
$client->setLogger(self::createLogger($records));
282+
283+
self::assertSame('ok', $client->request('GET', 'https://example.com')->getContent(false));
284+
285+
self::assertSame([
286+
[
287+
'level' => 'debug',
288+
'message' => 'Circuit breaker recorded HTTP request success.',
289+
'context' => [
290+
'service_name' => 'api',
291+
'method' => 'GET',
292+
'url' => 'https://example.com',
293+
'status_code' => 200,
294+
],
295+
],
296+
], $records);
297+
}
298+
299+
public function testLogsRecordedFailure(): void
300+
{
301+
$records = [];
302+
$storage = new InMemoryStorage();
303+
$circuitBreaker = new CircuitBreaker($storage, new CircuitBreakerConfig(failureThreshold: 1));
304+
$client = new CircuitBreakerHttpClient(
305+
new MockHttpClient(new MockResponse('', ['http_code' => 500])),
306+
$circuitBreaker,
307+
new DefaultFailureChecker(),
308+
'api',
309+
);
310+
$client->setLogger(self::createLogger($records));
311+
312+
self::assertSame(500, $client->request('GET', 'https://example.com')->getStatusCode());
313+
314+
self::assertSame([
315+
[
316+
'level' => 'debug',
317+
'message' => 'Circuit breaker recorded HTTP request failure.',
318+
'context' => [
319+
'service_name' => 'api',
320+
'method' => 'GET',
321+
'url' => 'https://example.com',
322+
'status_code' => 500,
323+
],
324+
],
325+
], $records);
326+
}
327+
328+
public function testLogsBlockedRequestWhenCircuitIsOpen(): void
329+
{
330+
$records = [];
331+
$storage = new InMemoryStorage();
332+
$circuitBreaker = new CircuitBreaker($storage, new CircuitBreakerConfig(exceptionsEnabled: false));
333+
$circuitBreaker->forceState('api', CircuitState::OPEN);
334+
$innerClient = new MockHttpClient();
335+
336+
$client = new CircuitBreakerHttpClient($innerClient, $circuitBreaker, new DefaultFailureChecker(), 'api');
337+
$client->setLogger(self::createLogger($records));
338+
339+
self::assertSame(503, $client->request('GET', 'https://example.com')->getStatusCode());
340+
341+
self::assertSame([
342+
[
343+
'level' => 'debug',
344+
'message' => 'Circuit breaker blocked HTTP request.',
345+
'context' => [
346+
'service_name' => 'api',
347+
'method' => 'GET',
348+
'url' => 'https://example.com',
349+
],
350+
],
351+
], $records);
352+
}
353+
268354
/**
269355
* @param array<string, mixed> $options
270356
*/
@@ -382,4 +468,32 @@ public function testRejectsInvalidOverriddenServiceName(array $options): void
382468

383469
$client->request('GET', 'https://example.com', $options);
384470
}
471+
472+
/**
473+
* @param list<array{level: mixed, message: string, context: array<string, mixed>}> $records
474+
*/
475+
private static function createLogger(array &$records): LoggerInterface
476+
{
477+
return new class($records) extends AbstractLogger {
478+
/**
479+
* @param list<array{level: mixed, message: string, context: array<string, mixed>}> $records
480+
*/
481+
public function __construct(
482+
private array &$records,
483+
) {
484+
}
485+
486+
/**
487+
* @param array<string, mixed> $context
488+
*/
489+
public function log($level, \Stringable|string $message, array $context = []): void
490+
{
491+
$this->records[] = [
492+
'level' => $level,
493+
'message' => (string) $message,
494+
'context' => $context,
495+
];
496+
}
497+
};
498+
}
385499
}

0 commit comments

Comments
 (0)