From cf5a7a1b0115c0d2acb77289dfb25816c76a3e03 Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Tue, 18 Jan 2022 15:54:10 +0300 Subject: [PATCH 1/9] feat(server): log unsafe errors --- src/Entity/Server.php | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index b5ca18c06..13342c2b8 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -7,9 +7,11 @@ use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\DependencyInjection\DependencySerializationTrait; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Utility\Error as ErrorUtil; use Drupal\graphql\GraphQL\Execution\ExecutionResult as CacheableExecutionResult; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\PersistedQueryPluginInterface; +use GraphQL\Error\ClientAware; use GraphQL\Error\DebugFlag; use GraphQL\Server\OperationParams; use Drupal\graphql\GraphQL\Execution\ResolveContext; @@ -210,6 +212,8 @@ public function executeOperation(OperationParams $operation) { Executor::setImplementationFactory($previous); } + $this->logErrors($operation, $result); + return $result; } @@ -397,6 +401,50 @@ protected function getErrorHandler() { }; } + /** + * Logs result errors if any. + * + * @param \GraphQL\Server\OperationParams $operation + * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result + * + * @return void + */ + protected function logErrors(OperationParams $operation, CacheableExecutionResult $result) { + if (empty($result->errors)) { + return; + } + + $unsafeErrors = array_filter($result->errors, function ($e) { + return !($e instanceof ClientAware) || ! $e->isClientSafe(); + }); + $isPreviousErrorLogged = FALSE; + foreach ($unsafeErrors as $error) { + if ($error->getPrevious() instanceof \Throwable) { + _drupal_log_error(ErrorUtil::decodeException($error->getPrevious())); + $isPreviousErrorLogged = TRUE; + } + } + + if ($unsafeErrors) { + \Drupal::logger('graphql')->error( + "There were errors during a GraphQL execution.\n{see_previous}\nDebug:\n
\n{debug}\n
", + [ + 'see_previous' => $isPreviousErrorLogged + ? 'See the previous log messages for the error details.' + : '', + 'debug' => json_encode([ + '$operation' => $operation, + // Do not pass $result to json_encode because it implements + // JsonSerializable and strips some data out during the serialization. + '$result->data' => $result->data, + '$result->errors' => $result->errors, + '$result->extensions' => $result->extensions, + ], JSON_PRETTY_PRINT), + ] + ); + } + } + /** * {@inheritDoc} */ From 0aed48bfc3498d255f99f34e3d86f3ea66aab5ae Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Tue, 18 Jan 2022 16:22:02 +0300 Subject: [PATCH 2/9] chore: fix phpstan/phpcs errors --- src/Entity/Server.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index 13342c2b8..483e907cf 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -406,16 +406,14 @@ protected function getErrorHandler() { * * @param \GraphQL\Server\OperationParams $operation * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result - * - * @return void */ - protected function logErrors(OperationParams $operation, CacheableExecutionResult $result) { + protected function logErrors(OperationParams $operation, CacheableExecutionResult $result): void { if (empty($result->errors)) { return; } $unsafeErrors = array_filter($result->errors, function ($e) { - return !($e instanceof ClientAware) || ! $e->isClientSafe(); + return !($e instanceof ClientAware) || !$e->isClientSafe(); }); $isPreviousErrorLogged = FALSE; foreach ($unsafeErrors as $error) { @@ -429,13 +427,12 @@ protected function logErrors(OperationParams $operation, CacheableExecutionResul \Drupal::logger('graphql')->error( "There were errors during a GraphQL execution.\n{see_previous}\nDebug:\n
\n{debug}\n
", [ - 'see_previous' => $isPreviousErrorLogged - ? 'See the previous log messages for the error details.' - : '', + 'see_previous' => $isPreviousErrorLogged ? 'See the previous log messages for the error details.' : '', 'debug' => json_encode([ '$operation' => $operation, // Do not pass $result to json_encode because it implements - // JsonSerializable and strips some data out during the serialization. + // JsonSerializable and strips some data out during the + // serialization. '$result->data' => $result->data, '$result->errors' => $result->errors, '$result->extensions' => $result->extensions, From 51399628af01b7581241eaf0faacffe6e328a85b Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Wed, 9 Feb 2022 09:14:10 +0300 Subject: [PATCH 3/9] chore: redo the logic as suggested by @Kingdutch --- src/Entity/Server.php | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index 483e907cf..324664dd6 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -408,26 +408,31 @@ protected function getErrorHandler() { * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result */ protected function logErrors(OperationParams $operation, CacheableExecutionResult $result): void { - if (empty($result->errors)) { - return; - } + $hasServerErrors = FALSE; + $hasLoggedPrevious = FALSE; + + foreach ($result->errors as $error) { + // Don't log errors intended for clients, only log those that + // a client would not be able to solve, they'd require work from + // a server developer. + if ($error instanceof ClientAware && $error->isClientSafe()) { + continue; + } - $unsafeErrors = array_filter($result->errors, function ($e) { - return !($e instanceof ClientAware) || !$e->isClientSafe(); - }); - $isPreviousErrorLogged = FALSE; - foreach ($unsafeErrors as $error) { + $hasServerErrors = TRUE; + // Log the error that cause the error we caught. This makes the error + // logs more useful because GraphQL usually wraps the original error. if ($error->getPrevious() instanceof \Throwable) { _drupal_log_error(ErrorUtil::decodeException($error->getPrevious())); - $isPreviousErrorLogged = TRUE; + $hasLoggedPrevious = TRUE; } } - if ($unsafeErrors) { + if ($hasServerErrors) { \Drupal::logger('graphql')->error( "There were errors during a GraphQL execution.\n{see_previous}\nDebug:\n
\n{debug}\n
", [ - 'see_previous' => $isPreviousErrorLogged ? 'See the previous log messages for the error details.' : '', + 'see_previous' => $hasLoggedPrevious ? 'See the previous log messages for the error details.' : '', 'debug' => json_encode([ '$operation' => $operation, // Do not pass $result to json_encode because it implements From 9ee8313522916f7b4a0f9703128df2ecd34c824c Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Wed, 9 Feb 2022 09:22:43 +0300 Subject: [PATCH 4/9] chore: rename the method --- src/Entity/Server.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index 324664dd6..c20766403 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -212,7 +212,7 @@ public function executeOperation(OperationParams $operation) { Executor::setImplementationFactory($previous); } - $this->logErrors($operation, $result); + $this->logUnsafeErrors($operation, $result); return $result; } @@ -402,12 +402,12 @@ protected function getErrorHandler() { } /** - * Logs result errors if any. + * Logs unsafe errors if any. * * @param \GraphQL\Server\OperationParams $operation * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result */ - protected function logErrors(OperationParams $operation, CacheableExecutionResult $result): void { + protected function logUnsafeErrors(OperationParams $operation, CacheableExecutionResult $result): void { $hasServerErrors = FALSE; $hasLoggedPrevious = FALSE; From 5508a41fdde7553060c684f1a847fb7bda9c1666 Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Wed, 9 Feb 2022 09:45:14 +0300 Subject: [PATCH 5/9] chore: log everything in one message --- src/Entity/Server.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index c20766403..b1c31197b 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -409,9 +409,9 @@ protected function getErrorHandler() { */ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutionResult $result): void { $hasServerErrors = FALSE; - $hasLoggedPrevious = FALSE; + $previousExceptions = []; - foreach ($result->errors as $error) { + foreach ($result->errors as $index => $error) { // Don't log errors intended for clients, only log those that // a client would not be able to solve, they'd require work from // a server developer. @@ -423,16 +423,17 @@ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutio // Log the error that cause the error we caught. This makes the error // logs more useful because GraphQL usually wraps the original error. if ($error->getPrevious() instanceof \Throwable) { - _drupal_log_error(ErrorUtil::decodeException($error->getPrevious())); - $hasLoggedPrevious = TRUE; + $previousExceptions[] = strtr( + "For error #@index: %type: @message in %function (line %line of %file)\n@backtrace_string.", + ErrorUtil::decodeException($error->getPrevious()) + ['@index' => $index] + ); } } if ($hasServerErrors) { \Drupal::logger('graphql')->error( - "There were errors during a GraphQL execution.\n{see_previous}\nDebug:\n
\n{debug}\n
", + "There were errors during a GraphQL execution.\nOperation details:\n
\n{debug}\n
\nPrevious exceptions:\n
\n{previous}\n
", [ - 'see_previous' => $hasLoggedPrevious ? 'See the previous log messages for the error details.' : '', 'debug' => json_encode([ '$operation' => $operation, // Do not pass $result to json_encode because it implements @@ -442,6 +443,7 @@ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutio '$result->errors' => $result->errors, '$result->extensions' => $result->extensions, ], JSON_PRETTY_PRINT), + 'previous' => implode('\n\n', $previousExceptions), ] ); } From 063ba6a5440a7466c1a5701d114cb2f75d0ffcac Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Wed, 9 Feb 2022 09:45:48 +0300 Subject: [PATCH 6/9] chore: better variable name --- src/Entity/Server.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index b1c31197b..efebe3097 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -408,7 +408,7 @@ protected function getErrorHandler() { * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result */ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutionResult $result): void { - $hasServerErrors = FALSE; + $hasUnsafeErrors = FALSE; $previousExceptions = []; foreach ($result->errors as $index => $error) { @@ -419,7 +419,7 @@ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutio continue; } - $hasServerErrors = TRUE; + $hasUnsafeErrors = TRUE; // Log the error that cause the error we caught. This makes the error // logs more useful because GraphQL usually wraps the original error. if ($error->getPrevious() instanceof \Throwable) { @@ -430,7 +430,7 @@ protected function logUnsafeErrors(OperationParams $operation, CacheableExecutio } } - if ($hasServerErrors) { + if ($hasUnsafeErrors) { \Drupal::logger('graphql')->error( "There were errors during a GraphQL execution.\nOperation details:\n
\n{debug}\n
\nPrevious exceptions:\n
\n{previous}\n
", [ From bc583de0ba8cae99223505d34519825421ca4d46 Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Thu, 10 Feb 2022 10:04:45 +0300 Subject: [PATCH 7/9] chore: move logging to Executor --- src/Entity/Server.php | 52 ----------------------------- src/GraphQL/Execution/Executor.php | 53 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/src/Entity/Server.php b/src/Entity/Server.php index efebe3097..b5ca18c06 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -7,11 +7,9 @@ use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\DependencyInjection\DependencySerializationTrait; use Drupal\Core\Entity\EntityStorageInterface; -use Drupal\Core\Utility\Error as ErrorUtil; use Drupal\graphql\GraphQL\Execution\ExecutionResult as CacheableExecutionResult; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\PersistedQueryPluginInterface; -use GraphQL\Error\ClientAware; use GraphQL\Error\DebugFlag; use GraphQL\Server\OperationParams; use Drupal\graphql\GraphQL\Execution\ResolveContext; @@ -212,8 +210,6 @@ public function executeOperation(OperationParams $operation) { Executor::setImplementationFactory($previous); } - $this->logUnsafeErrors($operation, $result); - return $result; } @@ -401,54 +397,6 @@ protected function getErrorHandler() { }; } - /** - * Logs unsafe errors if any. - * - * @param \GraphQL\Server\OperationParams $operation - * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result - */ - protected function logUnsafeErrors(OperationParams $operation, CacheableExecutionResult $result): void { - $hasUnsafeErrors = FALSE; - $previousExceptions = []; - - foreach ($result->errors as $index => $error) { - // Don't log errors intended for clients, only log those that - // a client would not be able to solve, they'd require work from - // a server developer. - if ($error instanceof ClientAware && $error->isClientSafe()) { - continue; - } - - $hasUnsafeErrors = TRUE; - // Log the error that cause the error we caught. This makes the error - // logs more useful because GraphQL usually wraps the original error. - if ($error->getPrevious() instanceof \Throwable) { - $previousExceptions[] = strtr( - "For error #@index: %type: @message in %function (line %line of %file)\n@backtrace_string.", - ErrorUtil::decodeException($error->getPrevious()) + ['@index' => $index] - ); - } - } - - if ($hasUnsafeErrors) { - \Drupal::logger('graphql')->error( - "There were errors during a GraphQL execution.\nOperation details:\n
\n{debug}\n
\nPrevious exceptions:\n
\n{previous}\n
", - [ - 'debug' => json_encode([ - '$operation' => $operation, - // Do not pass $result to json_encode because it implements - // JsonSerializable and strips some data out during the - // serialization. - '$result->data' => $result->data, - '$result->errors' => $result->errors, - '$result->extensions' => $result->extensions, - ], JSON_PRETTY_PRINT), - 'previous' => implode('\n\n', $previousExceptions), - ] - ); - } - } - /** * {@inheritDoc} */ diff --git a/src/GraphQL/Execution/Executor.php b/src/GraphQL/Execution/Executor.php index c16ae5451..82d14c081 100644 --- a/src/GraphQL/Execution/Executor.php +++ b/src/GraphQL/Execution/Executor.php @@ -6,15 +6,18 @@ use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Utility\Error as ErrorUtil; use Drupal\graphql\Event\OperationEvent; use Drupal\graphql\GraphQL\Execution\ExecutionResult as CacheableExecutionResult; use Drupal\graphql\GraphQL\Utility\DocumentSerializer; +use GraphQL\Error\ClientAware; use GraphQL\Executor\ExecutionResult; use GraphQL\Executor\ExecutorImplementation; use GraphQL\Executor\Promise\Promise; use GraphQL\Executor\Promise\PromiseAdapter; use GraphQL\Executor\ReferenceExecutor; use GraphQL\Language\AST\DocumentNode; +use GraphQL\Server\OperationParams; use GraphQL\Type\Schema; use GraphQL\Utils\AST; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -280,10 +283,60 @@ protected function doExecuteUncached() { $event = new OperationEvent($this->context, $result); $this->dispatcher->dispatch(OperationEvent::GRAPHQL_OPERATION_AFTER, $event); + $this->logUnsafeErrors($this->context->getOperation(), $result); + return $result; }); } + /** + * Logs unsafe errors if any. + * + * @param \GraphQL\Server\OperationParams $operation + * @param \Drupal\graphql\GraphQL\Execution\ExecutionResult $result + */ + protected function logUnsafeErrors(OperationParams $operation, ExecutionResult $result): void { + $hasUnsafeErrors = FALSE; + $previousErrors = []; + + foreach ($result->errors as $index => $error) { + // Don't log errors intended for clients, only log those that + // a client would not be able to solve, they'd require work from + // a server developer. + if ($error instanceof ClientAware && $error->isClientSafe()) { + continue; + } + + $hasUnsafeErrors = TRUE; + // Log the error that cause the error we caught. This makes the error + // logs more useful because GraphQL usually wraps the original error. + if ($error->getPrevious() instanceof \Throwable) { + $previousErrors[] = strtr( + "For error #@index: %type: @message in %function (line %line of %file)\n@backtrace_string.", + ErrorUtil::decodeException($error->getPrevious()) + ['@index' => $index] + ); + } + } + + if ($hasUnsafeErrors) { + \Drupal::logger('graphql')->error( + "There were errors during a GraphQL execution.\nOperation details:\n
\n{details}\n
\nPrevious errors:\n
\n{previous}\n
", + [ + 'details' => json_encode([ + '$operation' => $operation, + // Do not pass $result to json_encode because it implements + // JsonSerializable and strips some data out during the + // serialization. + '$result->data' => $result->data, + '$result->errors' => $result->errors, + '$result->extensions' => $result->extensions, + ], JSON_PRETTY_PRINT), + 'previous' => implode('\n\n', $previousErrors), + ] + ); + } + } + /** * Calculates the cache prefix from context for the current query. * From 7f830b1a6a07206d3b2d34836e917536d6698b44 Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Thu, 10 Feb 2022 13:18:54 +0300 Subject: [PATCH 8/9] test: add logger tests --- tests/src/Kernel/Framework/LoggerTest.php | 171 ++++++++++++++++++++++ tests/src/Kernel/GraphQLTestBase.php | 1 + 2 files changed, 172 insertions(+) create mode 100644 tests/src/Kernel/Framework/LoggerTest.php diff --git a/tests/src/Kernel/Framework/LoggerTest.php b/tests/src/Kernel/Framework/LoggerTest.php new file mode 100644 index 000000000..5d91365a6 --- /dev/null +++ b/tests/src/Kernel/Framework/LoggerTest.php @@ -0,0 +1,171 @@ +setUpSchema($schema); + + $this->mockResolver('Query', 'resolvesToNull', NULL); + $this->mockResolver('Query', 'throwsException', function () { + throw new \Exception('BOOM!'); + }); + $this->mockResolver('Query', 'takesIntArgument'); + + $this->container->get('logger.factory')->addLogger($this); + } + + public function log($level, $message, array $context = []) { + $this->loggerCalls[] = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + /** + * Test if invariant violation errors are logged. + */ + public function testInvariantViolationError(): void { + $result = $this->query('query { resolvesToNull }'); + $this->assertSame(200, $result->getStatusCode()); + // Client should not see the actual error. + $this->assertSame([ + 'errors' => [ + [ + 'message' => 'Internal server error', + 'extensions' => [ + 'category' => 'internal', + ], + 'locations' => [ + [ + 'line' => 1, + 'column' => 9, + ], + ], + 'path' => [ + 'resolvesToNull', + ], + ], + ], + ], json_decode($result->getContent(), TRUE)); + // The error should be logged. + $this->assertCount(1, $this->loggerCalls); + $loggerCall = reset($this->loggerCalls); + $details = json_decode($loggerCall['context']['details'], TRUE); + $this->assertSame($details['$operation']['query'], 'query { resolvesToNull }'); + $this->assertSame($details['$operation']['variables'], []); + $this->assertCount(1, $details['$result->errors']); + $this->assertSame( + $details['$result->errors'][0]['message'], + 'Cannot return null for non-nullable field "Query.resolvesToNull".' + ); + $this->assertStringContainsString( + 'For error #0: GraphQL\Error\InvariantViolation: Cannot return null for non-nullable field "Query.resolvesToNull".', + $loggerCall['context']['previous'] + ); + } + + /** + * Test if exceptions thrown from resolvers are logged. + */ + public function testException(): void { + $result = $this->query('query { throwsException }'); + $this->assertSame(200, $result->getStatusCode()); + // Client should not see the actual error. + $this->assertSame([ + 'errors' => [ + [ + 'message' => 'Internal server error', + 'extensions' => [ + 'category' => 'internal', + ], + 'locations' => [ + [ + 'line' => 1, + 'column' => 9, + ], + ], + 'path' => [ + 'throwsException', + ], + ], + ], + ], json_decode($result->getContent(), TRUE)); + // The error should be logged. + $this->assertCount(1, $this->loggerCalls); + $loggerCall = reset($this->loggerCalls); + $details = json_decode($loggerCall['context']['details'], TRUE); + $this->assertSame($details['$operation']['query'], 'query { throwsException }'); + $this->assertSame($details['$operation']['variables'], []); + $this->assertCount(1, $details['$result->errors']); + $this->assertSame($details['$result->errors'][0]['message'], 'BOOM!'); + $this->assertStringContainsString( + 'For error #0: Exception: BOOM!', + $loggerCall['context']['previous'] + ); + } + + /** + * Test if client error are not logged. + */ + public function testClientError(): void { + $result = $this->query('query { takesIntArgument(id: "boom") }'); + $this->assertSame(200, $result->getStatusCode()); + // The error should be reported back to client. + $this->assertSame([ + 'errors' => [ + 0 => [ + 'message' => 'Field "takesIntArgument" argument "id" requires type Int!, found "boom".', + 'extensions' => [ + 'category' => 'graphql', + ], + 'locations' => [ + 0 => [ + 'line' => 1, + 'column' => 30, + ], + ], + ], + ], + ], json_decode($result->getContent(), TRUE)); + // The error should not be logged. + $this->assertCount(0, $this->loggerCalls); + } + +} diff --git a/tests/src/Kernel/GraphQLTestBase.php b/tests/src/Kernel/GraphQLTestBase.php index c1fbf3533..0022ab2f1 100644 --- a/tests/src/Kernel/GraphQLTestBase.php +++ b/tests/src/Kernel/GraphQLTestBase.php @@ -70,6 +70,7 @@ protected function setUp(): void { $this->installEntitySchema('graphql_server'); $this->installEntitySchema('configurable_language'); $this->installConfig(['language']); + $this->installEntitySchema('menu_link_content'); $this->setUpCurrentUser([], $this->userPermissions()); From 93b7ec8fa52506cac364eb133e1e1485a6310182 Mon Sep 17 00:00:00 2001 From: Alex Tkachev Date: Thu, 10 Feb 2022 13:25:49 +0300 Subject: [PATCH 9/9] fix: fix phpcs error --- tests/src/Kernel/Framework/LoggerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/src/Kernel/Framework/LoggerTest.php b/tests/src/Kernel/Framework/LoggerTest.php index 5d91365a6..24ef71397 100644 --- a/tests/src/Kernel/Framework/LoggerTest.php +++ b/tests/src/Kernel/Framework/LoggerTest.php @@ -50,6 +50,9 @@ protected function setUp(): void { $this->container->get('logger.factory')->addLogger($this); } + /** + * {@inheritdoc} + */ public function log($level, $message, array $context = []) { $this->loggerCalls[] = [ 'level' => $level,