back to working
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/.idea/
|
||||
/vendor/
|
||||
/.phpunit.result.cache
|
||||
/composer.lock
|
24
.gitlab-ci.yml
Normal file
24
.gitlab-ci.yml
Normal file
@@ -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
|
142
README.md
Normal file
142
README.md
Normal file
@@ -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
|
||||
<?php
|
||||
|
||||
use Aws\CloudWatchLogs\CloudWatchLogsClient;
|
||||
use Maxbanton\Cwh\Handler\CloudWatch;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
|
||||
$sdkParams = [
|
||||
'region' => '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 🇺🇦
|
28
composer.json
Normal file
28
composer.json
Normal file
@@ -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"
|
||||
}
|
23
phpunit.xml.dist
Normal file
23
phpunit.xml.dist
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
backupStaticAttributes="false"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
convertWarningsToExceptions="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false">
|
||||
<coverage>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Handler Test Suite">
|
||||
<directory suffix=".php">./tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
411
src/Handler/CloudWatch.php
Executable file
411
src/Handler/CloudWatch.php
Executable file
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
namespace Siteworx\MonologHandlers\Handler;
|
||||
|
||||
use Aws\CloudWatchLogs\CloudWatchLogsClient;
|
||||
use Aws\CloudWatchLogs\Exception\CloudWatchLogsException;
|
||||
use Monolog\Formatter\FormatterInterface;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\AbstractProcessingHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
class CloudWatch extends AbstractProcessingHandler
|
||||
{
|
||||
/**
|
||||
* Requests per second limit (https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html)
|
||||
*/
|
||||
const RPS_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Event size limit (https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const EVENT_SIZE_LIMIT = 262118; // 262144 - reserved 26
|
||||
|
||||
/**
|
||||
* The batch of log events in a single PutLogEvents request cannot span more than 24 hours.
|
||||
*/
|
||||
const TIMESPAN_LIMIT = 86400000;
|
||||
|
||||
private CloudWatchLogsClient $client;
|
||||
|
||||
private string $group;
|
||||
|
||||
private string $stream;
|
||||
|
||||
private int $retention;
|
||||
|
||||
private bool $initialized = false;
|
||||
|
||||
private ?string $sequenceToken = null;
|
||||
|
||||
private int $batchSize;
|
||||
|
||||
private array $buffer = [];
|
||||
|
||||
private array $tags = [];
|
||||
|
||||
private bool $createGroup;
|
||||
|
||||
/**
|
||||
* Data amount limit (http://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html)
|
||||
*/
|
||||
private int $dataAmountLimit = 1048576;
|
||||
|
||||
private int $currentDataAmount = 0;
|
||||
|
||||
private int $remainingRequests = self::RPS_LIMIT;
|
||||
|
||||
private \DateTime $savedTime;
|
||||
|
||||
private ?int $earliestTimestamp = null;
|
||||
|
||||
/**
|
||||
* CloudWatchLogs constructor.
|
||||
* @param CloudWatchLogsClient $client
|
||||
*
|
||||
* Log group names must be unique within a region for an AWS account.
|
||||
* Log group names can be between 1 and 512 characters long.
|
||||
* Log group names consist of the following characters: a-z, A-Z, 0-9, '_' (underscore), '-' (hyphen),
|
||||
* '/' (forward slash), and '.' (period).
|
||||
* @param string $group
|
||||
*
|
||||
* Log stream names must be unique within the log group.
|
||||
* Log stream names can be between 1 and 512 characters long.
|
||||
* The ':' (colon) and '*' (asterisk) characters are not allowed.
|
||||
* @param string $stream
|
||||
*
|
||||
* @param int $retention
|
||||
* @param int $batchSize
|
||||
* @param array $tags
|
||||
* @param Level $level
|
||||
* @param bool $bubble
|
||||
* @param bool $createGroup
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct(
|
||||
CloudWatchLogsClient $client,
|
||||
$group,
|
||||
string $stream,
|
||||
int $retention = 14,
|
||||
int $batchSize = 10000,
|
||||
array $tags = [],
|
||||
Level $level = Level::Debug,
|
||||
bool $bubble = true,
|
||||
bool $createGroup = true
|
||||
) {
|
||||
if ($batchSize > 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();
|
||||
}
|
||||
}
|
539
tests/Handler/CloudWatchTest.php
Normal file
539
tests/Handler/CloudWatchTest.php
Normal file
@@ -0,0 +1,539 @@
|
||||
<?php
|
||||
|
||||
namespace Siteworx\MonologHandlers\Test\Handler;
|
||||
|
||||
|
||||
use Aws\CloudWatchLogs\CloudWatchLogsClient;
|
||||
use Aws\CloudWatchLogs\Exception\CloudWatchLogsException;
|
||||
use Aws\Result;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use Siteworx\MonologHandlers\Handler\CloudWatch;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
|
||||
class CloudWatchTest extends TestCase
|
||||
{
|
||||
|
||||
/**
|
||||
* @var MockObject | CloudWatchLogsClient
|
||||
*/
|
||||
private $clientMock;
|
||||
|
||||
/**
|
||||
* @var MockObject | Result
|
||||
*/
|
||||
private MockObject | Result $awsResultMock;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private string $groupName = 'group';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private string $streamName = 'stream';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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'),
|
||||
];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user