├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .php_cs ├── composer.json ├── config └── logging.php ├── hooks.sh ├── hooks └── pre-commit ├── phpunit.xml ├── readme.md ├── src ├── Exceptions │ └── IncompleteCloudWatchConfig.php └── Logger.php └── tests └── LoggerTest.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | php: 14 | - "8.1" 15 | - "8.2" 16 | - "8.3" 17 | - "8.4" 18 | laravel: 19 | - "^10.0" 20 | - "^11.0" 21 | - "^12.0" 22 | exclude: 23 | - laravel: "^11.0" 24 | php: "8.1" 25 | - laravel: "^12.0" 26 | php: "8.1" 27 | 28 | name: PHP:${{ matrix.php }} / Laravel:${{ matrix.laravel }} / ${{ matrix.stability }} 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup PHP, with composer 35 | uses: shivammathur/setup-php@v2 36 | with: 37 | php-version: ${{ matrix.php }} 38 | tools: composer:v2 39 | coverage: none 40 | 41 | - name: Install Composer dependencies 42 | run: composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update --ansi 43 | 44 | - name: Install Composer dependencies 45 | run: composer update --no-interaction --no-progress --ansi 46 | 47 | - name: Run Unit tests 48 | run: vendor/bin/phpunit --colors=always 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.result.cache 2 | vendor 3 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | setRiskyAllowed(true) 6 | ->setRules(array( 7 | '@Symfony' => true, 8 | '@Symfony:risky' => true, 9 | 'combine_consecutive_unsets' => true, 10 | 'no_extra_consecutive_blank_lines' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block'], 11 | 'no_useless_else' => true, 12 | 'no_useless_return' => true, 13 | 'ordered_class_elements' => true, 14 | 'phpdoc_add_missing_param_annotation' => true, 15 | '@PSR2' => true, 16 | 'array_syntax' => array('syntax' => 'short'), 17 | 'no_closing_tag' => true, 18 | 'phpdoc_summary' => true 19 | )) 20 | ->setFinder( 21 | PhpCsFixer\Finder::create() 22 | ->exclude('vendor') 23 | ->name('*.php') 24 | ->in(__DIR__) 25 | ); 26 | 27 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pagevamp/laravel-cloudwatch-logs", 3 | "description": "Laravel Adapter for AWS CloudWatch", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Naren Chitrakar", 9 | "email": "developer.naren@gmail.com" 10 | }, 11 | { 12 | "name": "Sujan Shrestha", 13 | "email": "najus.shrestha@gmail.com" 14 | } 15 | ], 16 | "autoload": { 17 | "psr-4": { 18 | "Pagevamp\\": "src/", 19 | "Tests\\": "tests/" 20 | } 21 | }, 22 | "require": { 23 | "phpnexus/cwh": "^1.1.14 || ^2.0 || ^3.0.0", 24 | "illuminate/support": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^2.12 || ^2.16|^3.14", 28 | "phpunit/phpunit": "^6.5 || ^8.4 || ^9.0 || ^10.5", 29 | "mockery/mockery": "^1.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'driver' => 'custom', 6 | 'name' => env('CLOUDWATCH_LOG_NAME', ''), 7 | 'region' => env('CLOUDWATCH_LOG_REGION', ''), 8 | 'credentials' => [ 9 | 'key' => env('CLOUDWATCH_LOG_KEY', ''), 10 | 'secret' => env('CLOUDWATCH_LOG_SECRET', ''), 11 | ], 12 | 'stream_name' => env('CLOUDWATCH_LOG_STREAM_NAME', 'laravel_app'), 13 | 'retention' => env('CLOUDWATCH_LOG_RETENTION_DAYS', 14), 14 | 'group_name' => env('CLOUDWATCH_LOG_GROUP_NAME', 'laravel_app'), 15 | 'version' => env('CLOUDWATCH_LOG_VERSION', 'latest'), 16 | 'batch_size' => env('CLOUDWATCH_LOG_BATCH_SIZE', 10000), 17 | 'formatter' => function ($configs) { 18 | return new \Monolog\Formatter\LineFormatter( 19 | '%channel%: %level_name%: %message% %context% %extra%', 20 | null, 21 | false, 22 | true 23 | ); 24 | }, 25 | 'via' => \Pagevamp\Logger::class 26 | ], 27 | ]; 28 | -------------------------------------------------------------------------------- /hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -d ".git" ]; 4 | then 5 | echo "Local Environment: Copy hooks/* to .git/hooks/"; 6 | cp hooks/* .git/hooks/; 7 | chmod +x .git/hooks/pre-commit 8 | fi 9 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f "vendor/bin/php-cs-fixer" ]; 4 | then 5 | echo "vendor/bin/php-cs-fixer not found!" 6 | exit 1 7 | fi 8 | 9 | while read -r file; 10 | do 11 | if [[ $file = *.php ]]; 12 | then 13 | vendor/bin/php-cs-fixer fix "$file" --config=.php_cs -v --using-cache=no --path-mode=intersection 14 | git add "$file" 15 | fi 16 | done < <(git diff --cached --name-status --diff-filter=ACM | awk '{print $2}') 17 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Logger for Aws Cloud Watch 2 | 3 | ### Breaking Change for version 1.0 4 | 5 | When this package started, it started as a listener for log events and would only work with another channel. 6 | This package would listen to log events and just add extra log to cloud watch. So, you did not need to add cloudwatch as a `channel`. 7 | But after `1.0` it works as a custom driver. 8 | So, you MUST add `LOG_CHANNEL` as `cloudwatch` in your logging config for this to work going forward. 9 | 10 | ### Installation 11 | 12 | ``` 13 | composer require pagevamp/laravel-cloudwatch-logs 14 | ``` 15 | 16 | ### Example 17 | 18 | You can use laravel's default `\Log` class to use this 19 | 20 | ``` 21 | \Log::info('user logged in', ['id' => 123, 'name' => 'Naren']); 22 | ``` 23 | 24 | ### Usage with AWS Lambda 25 | 26 | Make sure the AWS Lambda template contains an IAM role with enough access. 27 | So think about Logs:CreateLogGroup, Logs:DescribeLogGroups, Logs:CreateLogStream, Logs:DescribeLogStream, Logs:PutRetentionPolicy and Logs:PutLogEvents 28 | 29 | ### Config 30 | 31 | Config for logging is defined at `config/logging.php`. Add `cloudwatch` to the `channels` array 32 | 33 | ``` 34 | 'channels' => [ 35 | 'cloudwatch' => [ 36 | 'driver' => 'custom', 37 | 'name' => env('CLOUDWATCH_LOG_NAME', ''), 38 | 'region' => env('CLOUDWATCH_LOG_REGION', ''), 39 | 'credentials' => [ 40 | 'key' => env('CLOUDWATCH_LOG_KEY', ''), 41 | 'secret' => env('CLOUDWATCH_LOG_SECRET', '') 42 | ], 43 | 'stream_name' => env('CLOUDWATCH_LOG_STREAM_NAME', 'laravel_app'), 44 | 'retention' => env('CLOUDWATCH_LOG_RETENTION_DAYS', 14), 45 | 'group_name' => env('CLOUDWATCH_LOG_GROUP_NAME', 'laravel_app'), 46 | 'version' => env('CLOUDWATCH_LOG_VERSION', 'latest'), 47 | 'formatter' => \Monolog\Formatter\JsonFormatter::class, 48 | 'batch_size' => env('CLOUDWATCH_LOG_BATCH_SIZE', 10000), 49 | 'via' => \Pagevamp\Logger::class, 50 | ], 51 | ] 52 | ``` 53 | 54 | And set the `LOG_CHANNEL` in your environment variable to `cloudwatch`. 55 | 56 | If the role of your AWS EC2 instance has access to Cloudwatch logs, `CLOUDWATCH_LOG_KEY` and `CLOUDWATCH_LOG_SECRET` need not be defined in your `.env` file. 57 | 58 | ### Contribution 59 | 60 | I have added a `pre-commit` hook to run `php-cs-fixer` whenever you make a commit. To enable this run `sh hooks.sh`. 61 | 62 | -------------------------------------------------------------------------------- /src/Exceptions/IncompleteCloudWatchConfig.php: -------------------------------------------------------------------------------- 1 | app = $app; 18 | } 19 | 20 | public function __invoke(array $config) 21 | { 22 | if($this->app === null) { 23 | $this->app = \app(); 24 | } 25 | 26 | $loggingConfig = $config; 27 | $cwClient = new CloudWatchLogsClient($this->getCredentials()); 28 | 29 | $streamName = $loggingConfig['stream_name']; 30 | $retentionDays = $loggingConfig['retention']; 31 | $groupName = $loggingConfig['group_name']; 32 | $batchSize = isset($loggingConfig['batch_size']) ? $loggingConfig['batch_size'] : 10000; 33 | 34 | $logHandler = new CloudWatch($cwClient, $groupName, $streamName, $retentionDays, $batchSize); 35 | $logger = new \Monolog\Logger($loggingConfig['name']); 36 | 37 | $formatter = $this->resolveFormatter($loggingConfig); 38 | $logHandler->setFormatter($formatter); 39 | $logger->pushHandler($logHandler); 40 | 41 | return $logger; 42 | } 43 | 44 | /** 45 | * This is the way config should be defined in config/logging.php 46 | * in key cloudwatch. 47 | * 48 | * 'cloudwatch' => [ 49 | * 'driver' => 'custom', 50 | * 'name' => env('CLOUDWATCH_LOG_NAME', ''), 51 | * 'region' => env('CLOUDWATCH_LOG_REGION', ''), 52 | * 'credentials' => [ 53 | * 'key' => env('CLOUDWATCH_LOG_KEY', ''), 54 | * 'secret' => env('CLOUDWATCH_LOG_SECRET', '') 55 | * ], 56 | * 'stream_name' => env('CLOUDWATCH_LOG_STREAM_NAME', 'laravel_app'), 57 | * 'retention' => env('CLOUDWATCH_LOG_RETENTION_DAYS', 14), 58 | * 'group_name' => env('CLOUDWATCH_LOG_GROUP_NAME', 'laravel_app'), 59 | * 'version' => env('CLOUDWATCH_LOG_VERSION', 'latest'), 60 | * 'via' => \Pagevamp\Logger::class, 61 | * ] 62 | * 63 | * @return array 64 | * 65 | * @throws \Pagevamp\Exceptions\IncompleteCloudWatchConfig 66 | */ 67 | protected function getCredentials() 68 | { 69 | $loggingConfig = $this->app->make('config')->get('logging.channels'); 70 | 71 | if (!isset($loggingConfig['cloudwatch'])) { 72 | throw new IncompleteCloudWatchConfig('Configuration Missing for Cloudwatch Log'); 73 | } 74 | 75 | $cloudWatchConfigs = $loggingConfig['cloudwatch']; 76 | 77 | if (!isset($cloudWatchConfigs['region'])) { 78 | throw new IncompleteCloudWatchConfig('Missing region key-value'); 79 | } 80 | 81 | $awsCredentials = [ 82 | 'region' => $cloudWatchConfigs['region'], 83 | 'version' => $cloudWatchConfigs['version'], 84 | ]; 85 | 86 | if ($cloudWatchConfigs['credentials']['key']) { 87 | $awsCredentials['credentials'] = $cloudWatchConfigs['credentials']; 88 | } 89 | 90 | return $awsCredentials; 91 | } 92 | 93 | /** 94 | * @return mixed|LineFormatter 95 | * 96 | * @throws IncompleteCloudWatchConfig 97 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 98 | */ 99 | private function resolveFormatter(array $configs) 100 | { 101 | if (!isset($configs['formatter'])) { 102 | return new LineFormatter( 103 | '%channel%: %level_name%: %message% %context% %extra%', 104 | null, 105 | false, 106 | true 107 | ); 108 | } 109 | 110 | $formatter = $configs['formatter']; 111 | 112 | if (\is_string($formatter) && class_exists($formatter)) { 113 | return $this->app->make($formatter); 114 | } 115 | 116 | if (\is_callable($formatter)) { 117 | return $formatter($configs); 118 | } 119 | 120 | throw new IncompleteCloudWatchConfig('Formatter is missing for the logs'); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/LoggerTest.php: -------------------------------------------------------------------------------- 1 | '', 22 | 'region' => 'us-east-1', 23 | 'credentials' => [ 24 | 'key' => '', 25 | 'secret' => '', 26 | ], 27 | 'stream_name' => 'laravel_app', 28 | 'retention' => 14, 29 | 'group_name' => 'laravel_app', 30 | 'version' => 'latest', 31 | 'formatter' => JsonFormatter::class, 32 | ]; 33 | 34 | $config = Mockery::mock(Repository::class); 35 | $config->shouldReceive('get') 36 | ->once() 37 | ->with('logging.channels') 38 | ->andReturn([ 39 | 'cloudwatch' => $cloudwatchConfigs, 40 | ]); 41 | $config->shouldReceive('get') 42 | ->once() 43 | ->with('logging.channels.cloudwatch') 44 | ->andReturn($cloudwatchConfigs); 45 | 46 | $formatter = Mockery::mock(JsonFormatter::class); 47 | 48 | $app = Mockery::mock(Application::class); 49 | $app->shouldReceive('make') 50 | ->once() 51 | ->with('config') 52 | ->andReturn($config); 53 | $app->shouldReceive('make') 54 | ->once() 55 | ->with(JsonFormatter::class) 56 | ->andReturn($formatter); 57 | 58 | $provider = new \Pagevamp\Logger($app); 59 | $logger = $provider($cloudwatchConfigs); 60 | 61 | $this->assertInstanceOf(Logger::class, $logger); 62 | $this->assertNotEmpty($logger->getHandlers()); 63 | $this->assertInstanceOf( 64 | JsonFormatter::class, 65 | $logger->getHandlers()[0]->getFormatter() 66 | ); 67 | } 68 | 69 | public function testGetLoggerShouldResolveDefaultFormatterInstanceWhenConfigIsNull() 70 | { 71 | $cloudwatchConfigs = [ 72 | 'name' => '', 73 | 'region' => 'us-east-1', 74 | 'credentials' => [ 75 | 'key' => '', 76 | 'secret' => '', 77 | ], 78 | 'stream_name' => 'laravel_app', 79 | 'retention' => 14, 80 | 'group_name' => 'laravel_app', 81 | 'version' => 'latest', 82 | 'formatter' => null, 83 | ]; 84 | 85 | $config = Mockery::mock(Repository::class); 86 | $config->shouldReceive('get') 87 | ->once() 88 | ->with('logging.channels') 89 | ->andReturn([ 90 | 'cloudwatch' => $cloudwatchConfigs, 91 | ]); 92 | $config->shouldReceive('get') 93 | ->once() 94 | ->with('logging.channels.cloudwatch') 95 | ->andReturn($cloudwatchConfigs); 96 | 97 | $formatter = Mockery::mock(JsonFormatter::class); 98 | 99 | $app = Mockery::mock(Application::class); 100 | $app->shouldReceive('make') 101 | ->once() 102 | ->with('config') 103 | ->andReturn($config); 104 | $app->shouldReceive('make') 105 | ->once() 106 | ->with(JsonFormatter::class) 107 | ->andReturn($formatter); 108 | 109 | $provider = new \Pagevamp\Logger($app); 110 | $logger = $provider($cloudwatchConfigs); 111 | 112 | $this->assertInstanceOf(Logger::class, $logger); 113 | $this->assertNotEmpty($logger->getHandlers()); 114 | $this->assertInstanceOf( 115 | LineFormatter::class, 116 | $logger->getHandlers()[0]->getFormatter() 117 | ); 118 | } 119 | 120 | public function testGetLoggerShouldResolveDefaultFormatterInstanceWhenConfigIsNotSetted() 121 | { 122 | $cloudwatchConfigs = [ 123 | 'name' => '', 124 | 'region' => 'us-east-1', 125 | 'credentials' => [ 126 | 'key' => '', 127 | 'secret' => '', 128 | ], 129 | 'stream_name' => 'laravel_app', 130 | 'retention' => 14, 131 | 'group_name' => 'laravel_app', 132 | 'version' => 'latest', 133 | ]; 134 | 135 | $config = Mockery::mock(Repository::class); 136 | $config->shouldReceive('get') 137 | ->once() 138 | ->with('logging.channels') 139 | ->andReturn([ 140 | 'cloudwatch' => $cloudwatchConfigs, 141 | ]); 142 | $config->shouldReceive('get') 143 | ->once() 144 | ->with('logging.channels.cloudwatch') 145 | ->andReturn($cloudwatchConfigs); 146 | 147 | $formatter = Mockery::mock(LineFormatter::class); 148 | 149 | $app = Mockery::mock(Application::class); 150 | $app->shouldReceive('make') 151 | ->once() 152 | ->with('config') 153 | ->andReturn($config); 154 | $app->shouldReceive('make') 155 | ->once() 156 | ->with(LineFormatter::class) 157 | ->andReturn($formatter); 158 | 159 | $provider = new \Pagevamp\Logger($app); 160 | $logger = $provider($cloudwatchConfigs); 161 | 162 | $this->assertInstanceOf(Logger::class, $logger); 163 | $this->assertNotEmpty($logger->getHandlers()); 164 | $this->assertInstanceOf( 165 | LineFormatter::class, 166 | $logger->getHandlers()[0]->getFormatter() 167 | ); 168 | } 169 | 170 | public function testGetLoggerShouldResolveCallableFormatter() 171 | { 172 | $cloudwatchConfigs = [ 173 | 'name' => '', 174 | 'region' => 'us-east-1', 175 | 'credentials' => [ 176 | 'key' => '', 177 | 'secret' => '', 178 | ], 179 | 'stream_name' => 'laravel_app', 180 | 'retention' => 14, 181 | 'group_name' => 'laravel_app', 182 | 'version' => 'latest', 183 | 'formatter' => function ($configs) { 184 | return new LogglyFormatter(); 185 | }, 186 | ]; 187 | 188 | $config = Mockery::mock(Repository::class); 189 | $config->shouldReceive('get') 190 | ->once() 191 | ->with('logging.channels') 192 | ->andReturn([ 193 | 'cloudwatch' => $cloudwatchConfigs, 194 | ]); 195 | $config->shouldReceive('get') 196 | ->once() 197 | ->with('logging.channels.cloudwatch') 198 | ->andReturn($cloudwatchConfigs); 199 | 200 | $formatter = Mockery::mock(LogglyFormatter::class); 201 | 202 | $app = Mockery::mock(Application::class); 203 | $app->shouldReceive('make') 204 | ->once() 205 | ->with('config') 206 | ->andReturn($config); 207 | $app->shouldReceive('make') 208 | ->once() 209 | ->with(LogglyFormatter::class) 210 | ->andReturn($formatter); 211 | 212 | $provider = new \Pagevamp\Logger($app); 213 | $logger = $provider($cloudwatchConfigs); 214 | 215 | $this->assertInstanceOf(Logger::class, $logger); 216 | $this->assertNotEmpty($logger->getHandlers()); 217 | $this->assertInstanceOf( 218 | LogglyFormatter::class, 219 | $logger->getHandlers()[0]->getFormatter() 220 | ); 221 | } 222 | 223 | public function testInvalidFormatterWillThrowException() 224 | { 225 | $cloudwatchConfigs = [ 226 | 'name' => '', 227 | 'region' => 'us-east-1', 228 | 'credentials' => [ 229 | 'key' => '', 230 | 'secret' => '', 231 | ], 232 | 'stream_name' => 'laravel_app', 233 | 'retention' => 14, 234 | 'group_name' => 'laravel_app', 235 | 'version' => 'latest', 236 | 'formatter' => 'InvalidFormatter', 237 | ]; 238 | 239 | $config = Mockery::mock(Repository::class); 240 | $config->shouldReceive('get') 241 | ->once() 242 | ->with('logging.channels') 243 | ->andReturn([ 244 | 'cloudwatch' => $cloudwatchConfigs, 245 | ]); 246 | $config->shouldReceive('get') 247 | ->once() 248 | ->with('logging.channels.cloudwatch') 249 | ->andReturn($cloudwatchConfigs); 250 | 251 | $app = Mockery::mock(Application::class); 252 | $app->shouldReceive('make') 253 | ->once() 254 | ->with('config') 255 | ->andReturn($config); 256 | 257 | $this->expectException(IncompleteCloudWatchConfig::class); 258 | $provider = new \Pagevamp\Logger($app); 259 | $provider($cloudwatchConfigs); 260 | } 261 | } 262 | --------------------------------------------------------------------------------