From 031aeeb4b34f7149b5de3f52318cbc0a3c97bafe Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Mon, 30 Sep 2024 20:56:51 -0400 Subject: [PATCH] back to working --- .gitignore | 4 + .gitlab-ci.yml | 24 ++ README.md | 142 ++++++++ composer.json | 28 ++ phpunit.xml.dist | 23 ++ src/Handler/CloudWatch.php | 411 +++++++++++++++++++++++ tests/Handler/CloudWatchTest.php | 539 +++++++++++++++++++++++++++++++ 7 files changed, 1171 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100755 src/Handler/CloudWatch.php create mode 100644 tests/Handler/CloudWatchTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ed9c8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/vendor/ +/.phpunit.result.cache +/composer.lock diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..b792ed4 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,24 @@ +stages: + - test + - deploy + +unit-test: + stage: test + image: siteworxpro/composer + only: + - branches + script: + - composer install + - composer run unit + +deploy: + stage: deploy + image: alpine:latest + only: + - tags + tags: + - build + script: + - apk add curl + - 'curl -iL --insecure --header "Job-Token: $CI_JOB_TOKEN" --data tag=${CI_COMMIT_TAG} "${CI_API_V4_URL}/projects/$CI_PROJECT_ID/packages/composer"' + environment: production diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5dbe48 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# AWS CloudWatch Logs Handler for Monolog + +Handler for PHP logging library [Monolog](https://github.com/Seldaek/monolog) for sending log entries to +[AWS CloudWatch Logs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html) service. + +Before using this library, it's recommended to get acquainted with the [pricing](https://aws.amazon.com/en/cloudwatch/pricing/) for AWS CloudWatch services. + +Please press **★ Star** button if you find this library useful. + +## Disclaimer +This library uses AWS API through AWS PHP SDK, which has limits on concurrent requests. It means that on high concurrent or high load applications it may not work on it's best way. Please consider using another solution such as logging to the stdout and redirecting logs with fluentd. + +## Requirements +* PHP ^8.0 +* AWS account with proper permissions (see list of permissions below) + +## Features +* Up to 10000 batch logs sending in order to avoid _Rate exceeded_ errors +* Log Groups creating with tags +* AWS CloudWatch Logs staff lazy loading +* Suitable for web applications and for long-living CLI daemons and workers + +## Installation +Install the latest version with [Composer](https://getcomposer.org/) by running + +```bash +$ composer require maxbanton/cwh:^2.0 +``` + +## Basic Usage +```php + 'eu-west-1', + 'version' => 'latest', + 'credentials' => [ + 'key' => 'your AWS key', + 'secret' => 'your AWS secret', + 'token' => 'your AWS session token', // token is optional + ] +]; + +// Instantiate AWS SDK CloudWatch Logs Client +$client = new CloudWatchLogsClient($sdkParams); + +// Log group name, will be created if none +$groupName = 'php-logtest'; + +// Log stream name, will be created if none +$streamName = 'ec2-instance-1'; + +// Days to keep logs, 14 by default. Set to `null` to allow indefinite retention. +$retentionDays = 30; + +// Instantiate handler (tags are optional) +$handler = new CloudWatch($client, $groupName, $streamName, $retentionDays, 10000, ['my-awesome-tag' => 'tag-value']); + +// Optionally set the JsonFormatter to be able to access your log messages in a structured way +$handler->setFormatter(new JsonFormatter()); + +// Create a log channel +$log = new Logger('name'); + +// Set handler +$log->pushHandler($handler); + +// Add records to the log +$log->debug('Foo'); +$log->warning('Bar'); +$log->error('Baz'); +``` + +## Frameworks integration + - [Silex](http://silex.sensiolabs.org/doc/master/providers/monolog.html#customization) + - [Symfony](http://symfony.com/doc/current/logging.html) ([Example](https://github.com/maxbanton/cwh/issues/10#issuecomment-296173601)) + - [Lumen](https://lumen.laravel.com/docs/5.2/errors) + - [Laravel](https://laravel.com/docs/5.4/errors) ([Example](https://stackoverflow.com/a/51790656/1856778)) + + [And many others](https://github.com/Seldaek/monolog#framework-integrations) + +# AWS IAM needed permissions +if you prefer to use a separate programmatic IAM user (recommended) or want to define a policy, make sure following permissions are included: +1. `CreateLogGroup` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogGroup.html) +1. `CreateLogStream` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogStream.html) +1. `PutLogEvents` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html) +1. `PutRetentionPolicy` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutRetentionPolicy.html) +1. `DescribeLogStreams` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogStreams.html) +1. `DescribeLogGroups` [aws docs](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogGroups.html) + +When setting the `$createGroup` argument to `false`, permissions `DescribeLogGroups` and `CreateLogGroup` can be omitted + +## AWS IAM Policy full json example +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:DescribeLogGroups" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutRetentionPolicy" + ], + "Resource": "{LOG_GROUP_ARN}" + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + "{LOG_STREAM_1_ARN}", + "{LOG_STREAM_2_ARN}" + ] + } + ] +} +``` + +## Issues +Feel free to [report any issues](https://github.com/maxbanton/cwh/issues/new) + +## Contributing +Please check [this document](https://github.com/maxbanton/cwh/blob/master/CONTRIBUTING.md) + +___ + +Made in Ukraine πŸ‡ΊπŸ‡¦ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..14b6770 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "siteworxpro/monolog-handlers", + "homepage": "https://github.com/maxbanton/cwh", + "type": "library", + "description": "AWS CloudWatch Handler for Monolog library", + "license": "MIT", + "authors": [], + "version": "1.0.0", + "require": { + "php": "^8", + "monolog/monolog": "^3.7.0", + "aws/aws-sdk-php": "^3.322.8" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.4", + "squizlabs/php_codesniffer": "^3.10.3" + }, + "scripts": { + "unit": "phpunit" + }, + "autoload": { + "psr-4": { + "Siteworx\\MonologHandlers\\": "src", + "Siteworx\\MonologHandlers\\Test\\": "tests" + } + }, + "minimum-stability": "stable" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..48b930d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + src + + + + + ./tests/ + + + diff --git a/src/Handler/CloudWatch.php b/src/Handler/CloudWatch.php new file mode 100755 index 0000000..858daf3 --- /dev/null +++ b/src/Handler/CloudWatch.php @@ -0,0 +1,411 @@ + 10000) { + throw new \InvalidArgumentException('Batch size can not be greater than 10000'); + } + + $this->client = $client; + $this->group = $group; + $this->stream = $stream; + $this->retention = $retention; + $this->batchSize = $batchSize; + $this->tags = $tags; + $this->createGroup = $createGroup; + + parent::__construct($level, $bubble); + + $this->savedTime = new \DateTime; + } + + /** + * {@inheritdoc} + * @throws \DateMalformedStringException + */ + protected function write(LogRecord $record): void + { + + $records = $this->formatRecords($record); + + foreach ($records as $record) { + if ($this->willMessageSizeExceedLimit($record) || $this->willMessageTimestampExceedLimit($record)) { + $this->flushBuffer(); + } + + $this->addToBuffer($record); + + if (count($this->buffer) >= $this->batchSize) { + $this->flushBuffer(); + } + } + } + + /** + * @param LogRecord $record + */ + private function addToBuffer(LogRecord $record): void + { + $this->currentDataAmount += $this->getMessageSize($record); + + $timestamp = $record->datetime->getTimestamp(); + + if (!$this->earliestTimestamp || $timestamp < $this->earliestTimestamp) { + $this->earliestTimestamp = $timestamp; + } + + $this->buffer[] = $record; + } + + private function flushBuffer(): void + { + if (!empty($this->buffer)) { + if (false === $this->initialized) { + $this->initialize(); + } + + // send items, retry once with a fresh sequence token + try { + $this->send($this->buffer); + } catch (CloudWatchLogsException $e) { + $this->refreshSequenceToken(); + $this->send($this->buffer); + } + + // clear buffer + $this->buffer = []; + + // clear the earliest timestamp + $this->earliestTimestamp = null; + + // clear data amount + $this->currentDataAmount = 0; + } + } + + private function checkThrottle(): void + { + $current = new \DateTime(); + $diff = $current->diff($this->savedTime)->s; + $sameSecond = $diff === 0; + + if ($sameSecond && $this->remainingRequests > 0) { + $this->remainingRequests--; + } elseif ($sameSecond && $this->remainingRequests === 0) { + sleep(1); + $this->remainingRequests = self::RPS_LIMIT; + } elseif (!$sameSecond) { + $this->remainingRequests = self::RPS_LIMIT; + } + + $this->savedTime = new \DateTime(); + } + + /** + * http://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html + * + * @param LogRecord $record + * @return int + */ + private function getMessageSize(LogRecord $record): int + { + return strlen($record->message) + 26; + } + + /** + * Determine whether the specified record's message size in addition to the + * size of the current queued messages will exceed AWS CloudWatch's limit. + * + * @param LogRecord $record + * @return bool + */ + protected function willMessageSizeExceedLimit(LogRecord $record): bool + { + return $this->currentDataAmount + $this->getMessageSize($record) >= $this->dataAmountLimit; + } + + /** + * Determine whether the specified record's timestamp exceeds the 24 hour timespan limit + * for all batched messages written in a single call to PutLogEvents. + * + * @param LogRecord $record + * @return bool + */ + protected function willMessageTimestampExceedLimit(LogRecord $record): bool + { + return $this->earliestTimestamp && $record->datetime->getTimestamp() - $this->earliestTimestamp > self::TIMESPAN_LIMIT; + } + + /** + * Event size in the batch can not be bigger than 256 KB + * https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html + * + * @param LogRecord $entry + * @return LogRecord[] + */ + private function formatRecords(LogRecord $entry): array + { + $entries = str_split($entry->formatted, self::EVENT_SIZE_LIMIT); + $timestamp = $entry->datetime->format('U.u') * 1000; + $format = str_contains($timestamp, '.') ? 'U.u' : 'U'; + + $dateTimeImmutable = \DateTimeImmutable::createFromFormat($format, $timestamp); + $records = []; + + foreach ($entries as $e) { + $records[] = new LogRecord($dateTimeImmutable, $entry->channel, $entry->level, $e, $entry->context); + } + + return $records; + } + + /** + * The batch of events must satisfy the following constraints: + * - The maximum batch size is 1,048,576 bytes, and this size is calculated as the sum of all event messages in + * UTF-8, plus 26 bytes for each log event. + * - None of the log events in the batch can be more than 2 hours in the future. + * - None of the log events in the batch can be older than 14 days or the retention period of the log group. + * - The log events in the batch must be in chronological ordered by their timestamp (the time the event occurred, + * expressed as the number of milliseconds since Jan 1, 1970 00:00:00 UTC). + * - The maximum number of log events in a batch is 10,000. + * - A batch of log events in a single request cannot span more than 24 hours. Otherwise, the operation fails. + * + * @param array $entries + * + * @throws \Aws\CloudWatchLogs\Exception\CloudWatchLogsException Thrown by putLogEvents for example in case of an + * invalid sequence token + */ + private function send(array $entries): void + { + // AWS expects to receive entries in chronological order... + usort($entries, static function (LogRecord $a, LogRecord $b) { + if ($a->datetime->getTimestamp() < $b->datetime->getTimestamp()) { + return -1; + } elseif ($a->datetime->getTimestamp() > $b->datetime->getTimestamp()) { + return 1; + } + + return 0; + }); + + $data = [ + 'logGroupName' => $this->group, + 'logStreamName' => $this->stream, + 'logEvents' => $entries + ]; + + if (!empty($this->sequenceToken)) { + $data['sequenceToken'] = $this->sequenceToken; + } + + $this->checkThrottle(); + + $response = $this->client->putLogEvents($data); + + $this->sequenceToken = $response->get('nextSequenceToken'); + } + + private function initializeGroup(): void + { + // fetch existing groups + $existingGroups = + $this + ->client + ->describeLogGroups(['logGroupNamePrefix' => $this->group]) + ->get('logGroups'); + + // extract existing groups names + $existingGroupsNames = array_map( + function ($group) { + return $group['logGroupName']; + }, + $existingGroups + ); + + // create group and set retention policy if not created yet + if (!in_array($this->group, $existingGroupsNames, true)) { + $createLogGroupArguments = ['logGroupName' => $this->group]; + + if (!empty($this->tags)) { + $createLogGroupArguments['tags'] = $this->tags; + } + + $this + ->client + ->createLogGroup($createLogGroupArguments); + + if ($this->retention !== null) { + $this + ->client + ->putRetentionPolicy( + [ + 'logGroupName' => $this->group, + 'retentionInDays' => $this->retention, + ] + ); + } + } + } + + private function initialize(): void + { + if ($this->createGroup) { + $this->initializeGroup(); + } + + $this->refreshSequenceToken(); + } + + private function refreshSequenceToken(): void + { + // fetch existing streams + $existingStreams = + $this + ->client + ->describeLogStreams( + [ + 'logGroupName' => $this->group, + 'logStreamNamePrefix' => $this->stream, + ] + )->get('logStreams'); + + // extract existing streams names + $existingStreamsNames = array_map( + function ($stream) { + + // set sequence token + if ($stream['logStreamName'] === $this->stream && isset($stream['uploadSequenceToken'])) { + $this->sequenceToken = $stream['uploadSequenceToken']; + } + + return $stream['logStreamName']; + }, + $existingStreams + ); + + // create stream if not created + if (!in_array($this->stream, $existingStreamsNames, true)) { + $this + ->client + ->createLogStream( + [ + 'logGroupName' => $this->group, + 'logStreamName' => $this->stream + ] + ); + } + + $this->initialized = true; + } + + /** + * {@inheritdoc} + */ + protected function getDefaultFormatter(): FormatterInterface + { + return new LineFormatter("%channel%: %level_name%: %message% %context% %extra%", null, false, true); + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + $this->flushBuffer(); + } +} diff --git a/tests/Handler/CloudWatchTest.php b/tests/Handler/CloudWatchTest.php new file mode 100644 index 0000000..1ca98f1 --- /dev/null +++ b/tests/Handler/CloudWatchTest.php @@ -0,0 +1,539 @@ +clientMock = + $this + ->getMockBuilder(CloudWatchLogsClient::class) + ->addMethods( + [ + 'describeLogGroups', + 'CreateLogGroup', + 'PutRetentionPolicy', + 'DescribeLogStreams', + 'CreateLogStream', + 'PutLogEvents' + ] + ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @throws \ReflectionException + */ + public function testInitializeWithCreateGroupDisabled() + { + $this + ->clientMock + ->expects($this->never()) + ->method('describeLogGroups'); + + $this + ->clientMock + ->expects($this->never()) + ->method('createLogGroup'); + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName, + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198322' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $handler = new CloudWatch($this->clientMock, $this->groupName, $this->streamName, 14, 10000, [], Level::Debug, true, false); + + $reflection = new \ReflectionClass($handler); + $reflectionMethod = $reflection->getMethod('initialize'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invoke($handler); + } + + public function testInitializeWithExistingLogGroup() + { + $logGroupsResult = new Result(['logGroups' => [['logGroupName' => $this->groupName]]]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogGroups') + ->with(['logGroupNamePrefix' => $this->groupName]) + ->willReturn($logGroupsResult); + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName, + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198322' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $handler = $this->getCUT(); + + $reflection = new \ReflectionClass($handler); + $reflectionMethod = $reflection->getMethod('initialize'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invoke($handler); + } + + public function testInitializeWithTags() + { + $tags = [ + 'applicationName' => 'dummyApplicationName', + 'applicationEnvironment' => 'dummyApplicationEnvironment' + ]; + + $logGroupsResult = new Result(['logGroups' => [['logGroupName' => $this->groupName . 'foo']]]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogGroups') + ->with(['logGroupNamePrefix' => $this->groupName]) + ->willReturn($logGroupsResult); + + $this + ->clientMock + ->expects($this->once()) + ->method('createLogGroup') + ->with([ + 'logGroupName' => $this->groupName, + 'tags' => $tags + ]); + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName, + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198322' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $handler = new CloudWatch($this->clientMock, $this->groupName, $this->streamName, 14, 10000, $tags); + + $reflection = new \ReflectionClass($handler); + $reflectionMethod = $reflection->getMethod('initialize'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invoke($handler); + } + + public function testInitializeWithEmptyTags() + { + $logGroupsResult = new Result(['logGroups' => [['logGroupName' => $this->groupName . 'foo']]]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogGroups') + ->with(['logGroupNamePrefix' => $this->groupName]) + ->willReturn($logGroupsResult); + + $this + ->clientMock + ->expects($this->once()) + ->method('createLogGroup') + ->with(['logGroupName' => $this->groupName]); //The empty array of tags is not handed over + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName, + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198322' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $handler = new CloudWatch($this->clientMock, $this->groupName, $this->streamName); + + $reflection = new \ReflectionClass($handler); + $reflectionMethod = $reflection->getMethod('initialize'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invoke($handler); + } + + public function testInitializeWithMissingGroupAndStream() + { + $logGroupsResult = new Result(['logGroups' => [['logGroupName' => $this->groupName . 'foo']]]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogGroups') + ->with(['logGroupNamePrefix' => $this->groupName]) + ->willReturn($logGroupsResult); + + $this + ->clientMock + ->expects($this->once()) + ->method('createLogGroup') + ->with(['logGroupName' => $this->groupName]); + + $this + ->clientMock + ->expects($this->once()) + ->method('putRetentionPolicy') + ->with([ + 'logGroupName' => $this->groupName, + 'retentionInDays' => 14, + ]); + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName . 'bar', + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198324' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $this + ->clientMock + ->expects($this->once()) + ->method('createLogStream') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamName' => $this->streamName + ]); + + $handler = $this->getCUT(); + + $reflection = new \ReflectionClass($handler); + $reflectionMethod = $reflection->getMethod('initialize'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invoke($handler); + } + + public function testLimitExceeded() + { + $this->expectException(\InvalidArgumentException::class); + (new CloudWatch($this->clientMock, 'a', 'b', 14, 10001)); + } + + public function testSendsOnClose() + { + $this->prepareMocks(); + + $this + ->clientMock + ->expects($this->once()) + ->method('PutLogEvents') + ->willReturn($this->awsResultMock); + + $handler = $this->getCUT(1); + + $handler->handle($this->getRecord(Level::Debug)); + + $handler->close(); + } + + public function testSendsBatches() + { + $this->prepareMocks(); + + $this + ->clientMock + ->expects($this->exactly(2)) + ->method('PutLogEvents') + ->willReturn($this->awsResultMock); + + $handler = $this->getCUT(3); + + foreach ($this->getMultipleRecords() as $record) { + $handler->handle($record); + } + + $handler->close(); + } + + public function testFormatter() + { + $handler = $this->getCUT(); + + $formatter = $handler->getFormatter(); + + $expected = new LineFormatter("%channel%: %level_name%: %message% %context% %extra%", null, false, true); + + $this->assertEquals($expected, $formatter); + } + + public function testExceptionFromDescribeLogGroups() + { + // e.g. 'User is not authorized to perform logs:DescribeLogGroups' + $awsException = $this->getMockBuilder(CloudWatchLogsException::class) + ->disableOriginalConstructor() + ->getMock(); + + // if this fails ... + $this + ->clientMock + ->expects($this->atLeastOnce()) + ->method('describeLogGroups') + ->will($this->throwException($awsException)); + + // ... this should not be called: + $this + ->clientMock + ->expects($this->never()) + ->method('describeLogStreams'); + + $this->expectException(CloudWatchLogsException::class); + + $handler = $this->getCUT(0); + $handler->handle($this->getRecord(Level::Info)); + } + + private function prepareMocks(): void + { + $logGroupsResult = new Result(['logGroups' => [['logGroupName' => $this->groupName]]]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogGroups') + ->with(['logGroupNamePrefix' => $this->groupName]) + ->willReturn($logGroupsResult); + + $logStreamResult = new Result([ + 'logStreams' => [ + [ + 'logStreamName' => $this->streamName, + 'uploadSequenceToken' => '49559307804604887372466686181995921714853186581450198322' + ] + ] + ]); + + $this + ->clientMock + ->expects($this->once()) + ->method('describeLogStreams') + ->with([ + 'logGroupName' => $this->groupName, + 'logStreamNamePrefix' => $this->streamName, + ]) + ->willReturn($logStreamResult); + + $this->awsResultMock = + $this + ->getMockBuilder(Result::class) + ->onlyMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + } + + public function testSortsEntriesChronologically() + { + $this->prepareMocks(); + + $this + ->clientMock + ->expects($this->once()) + ->method('PutLogEvents') + ->willReturnCallback(function (array $data) { + $this->assertStringContainsString('record1', $data['logEvents'][0]['message']); + $this->assertStringContainsString('record2', $data['logEvents'][1]['message']); + $this->assertStringContainsString('record3', $data['logEvents'][2]['message']); + $this->assertStringContainsString('record4', $data['logEvents'][3]['message']); + + return $this->awsResultMock; + }); + + $handler = $this->getCUT(4); + + // created with chronological timestamps: + $records = []; + + for ($i = 1; $i <= 4; ++$i) { + $dateTime = \DateTimeImmutable::createFromFormat('U', time() + $i); + if (!$dateTime) { + $dateTime = new \DateTimeImmutable(); + } + $record = $this->getRecord(Level::Info, 'record' . $i, $dateTime); + $records[] = $record; + } + + // but submitted in a different order: + $handler->handle($records[2]); + $handler->handle($records[0]); + $handler->handle($records[3]); + $handler->handle($records[1]); + + $handler->close(); + } + + public function testSendsBatchesSpanning24HoursOrLess() + { + $this->prepareMocks(); + + $this + ->clientMock + ->expects($this->exactly(3)) + ->method('PutLogEvents') + ->willReturnCallback(function (array $data) { + /** @var int|null */ + $earliestTime = null; + + /** @var int|null */ + $latestTime = null; + + /** @var LogRecord $logEvent */ + foreach ($data['logEvents'] as $logEvent) { + $logTimestamp = $logEvent->datetime->getTimestamp(); + + if (!$earliestTime || $logTimestamp < $earliestTime) { + $earliestTime = $logTimestamp; + } + + if (!$latestTime || $logTimestamp > $latestTime) { + $latestTime = $logTimestamp; + } + } + + $this->assertNotNull($earliestTime); + $this->assertNotNull($latestTime); + $this->assertGreaterThanOrEqual($earliestTime, $latestTime); + $this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $latestTime - $earliestTime); + + return $this->awsResultMock; + }); + + $handler = $this->getCUT(); + + // write 15 log entries spanning 3 days + for ($i = 1; $i <= 15; ++$i) { + $dateTime = \DateTimeImmutable::createFromMutable(\DateTime::createFromFormat('U', time() + $i * 5 * 60 * 60)); + $this->assertNotFalse($dateTime); + $record = $this->getRecord(Level::Info, 'record' . $i, $dateTime); + $handler->handle($record); + } + + $handler->close(); + } + + /** + * @throws \Exception + */ + private function getCUT($batchSize = 1000): CloudWatch + { + return new CloudWatch($this->clientMock, $this->groupName, $this->streamName, 14, $batchSize); + } + + /** + * @param \Monolog\Level $level + * @param string $message + * @param \DateTimeImmutable|null $dateTimeImmutable + * @return LogRecord + */ + private function getRecord(Level $level = Level::Warning, string $message = 'test', \DateTimeImmutable $dateTimeImmutable = null): LogRecord + { + if ($dateTimeImmutable === null) { + $dateTimeImmutable = new \DateTimeImmutable(); + } + + return new LogRecord($dateTimeImmutable, $this->groupName, $level, $message, []); + } + + /** + * @return array + */ + private function getMultipleRecords(): array + { + return [ + $this->getRecord(Level::Debug, 'debug message 1'), + $this->getRecord(Level::Debug, 'debug message 2'), + $this->getRecord(Level::Info, 'information'), + $this->getRecord(Level::Warning, 'warning'), + $this->getRecord(Level::Error, 'error'), + ]; + } +}