Monolog ile Amazon CloudWatch Loglama

Merhabalar,

Bugün ki konumuz Amazon CloudWatch kullanarak Monolog ile nasıl loglama yapabileceğimiz üzerine olacak.

Biz çalıştığımız şirkette Symfony kullandığımız için bugün ki örneğim genel olarak Symfony üzerinden yürüyecek ama halihazırda Laravel gibi frameworkler de Monolog kullanıyor, PHP dünyasında baya de-facto olmuş bir Logging Paketi :)

ELK Stack kullanıyorsanız Cloudwatch verisiyle ELK Stacki besleyebilirsiniz:

https://github.com/gilt/cloudwatch-elk

Yukarıdaki adresten detaylarını inceleyebilirsiniz.

Genel olarak CloudWatch kullanmak size minimum eforla maksimum ölçeklenebilme imkanı sağlıyor. ( Amazonun ve bütçenin el verdiği kadar en azından :) )

Hemen nasıl implement edeceğimiz konusuna geçelim,

Öncelikle Composer ile AWS özelliklerini PHP üzerinden rahatça kullanmak için AWS-SDK-PHP paketine ihtiyacımız var, composer.json dosyamızın require kısmına aşağıdaki tanımlamayı yapıyoruz.

"aws/aws-sdk-php": "~3.18"

Bunu ekledikten sonra Dependencylerimizi güncelliyoruz,

composer update

Dependencylerimiz bizi bekleye dursun biz AWS kısmımızı konfigüre edelim.

Öncelikle AWS Dashboardımızdan IAM bölümüne girip "ADD USER" kısmına tıklayarak Programatic Access kullanan yeni bir kullanıcı oluşturuyoruz.

"Attach existing policies directly" bölümünden Userımız için "CloudWatchFullAccess" yetkisini atıyoruz.

Kullanıcımızı oluşturduktan sonra son kısımda bize verilen Access Key ID ve Access Key Secret kısımlarını bir yere not edelim, ilerleyen kısımda API bağlantısı için ihtiyacımız olacak.

Şimdi Cloudwatch kısmına giriyoruz ve soldan Logs kısmına tıklıyoruz.

Actions kısmından "Create Log Group" kısmına tıklayarak ilk log grubumuzu oluşturuyoruz.

Ben ismini "test-group" veriyorum.

Şimdi log grubumuzun içine girince Loglarımızı yazmamız için streamler oluşturmamız gerektiğini göreceksiniz.

Ben bu örnekte sadece 1 log stream kullanacağım, dilerseniz siz per-instance, per-subject vs şeklinde proje gereksinimleriniz doğrultusunda çoğaltabilirsiniz.

Ben ismini "test-group-stream" veriyorum.

Buraya kadar AWS paneliyle işimiz bitti.

PHP tarafına geri dönelim, projenin başında da bahsettiğim gibi bu örnekte Symfony 3 kullanıyorum.

Öncelikle bir kaç DI tanımlamasıyla CloudwatchLogsClient'imizi oluşturacağız.

Bunları Symfony'de kullanmak istediğiniz Bundle'ın resources/config klasörü altındaki services.yml dosyası içinde yapabilirsiniz.

    sdk.cloudwatch_logs_client_credentials:
        class: Aws\Credentials\Credentials
        arguments: [%amazon_cloudwatch_key%, %amazon_cloudwatch_secret%]

    sdk.cloudwatch_logs_client:
        class: Aws\CloudWatchLogs\CloudWatchLogsClient
        arguments:
            -
                credentials: '@sdk.cloudwatch_logs_client_credentials'
                region: %amazon_cloudwatch_region%
                version: latest

Bu kısım basitçe şunları yapıyor.

Aws\Credentials\Credentials classının içine key ve secret şeklinde iki parametre göndererek classı instantiate ediyor.

Aws\CloudWatchLogs\CloudWatchLogsClient classının içine bir array gönderiyor.

Key-Value pair şeklinde Credentials instantiate ettiğimiz class, region biz bu örnekte eu-central-1 kullanacağız. Ve version latest diyoruz bu aslında production ortamında bir değere sabitlenmesi APIdeki değişikliklerden minumum etkilenmek için önerilen bir yaklaşımdır ama biz şuan dev environmentinde olduğumuz için latest seçip devam ediyoruz.

Şimdi Monolog için yeni bir Handler oluşturmamız gerekli, Monolog içinde CloudWatch Handler'i built-in olarak bulunmamaktadır, Bu yüzden AbstractProcessingHandler classını extend ederek kendi handlerımızı oluşturuyoruz.

<?php

namespace SdkBundle\Service\Logger;

use Monolog\Handler\AbstractProcessingHandler;  
use Aws\CloudWatchLogs\CloudWatchLogsClient;  
use Monolog\Formatter\LineFormatter;

class CloudWatchHandler extends AbstractProcessingHandler  
{
    /**
     * @var Connection
     */
    protected $client;

    /**
     * @var string
     */
    protected $group;

    /**
     * @var string
     */
    protected $stream;

    /**
     * @var boolean
     */
    protected $initialized;

    /**
     * @var string
     */
    protected $sequenceToken;

    /**
     * Sets the CloudWatch Logs Client
     *
     * @param CloudWatchLogsClient $client Instance to the CloudWatch Logs Client
     */
    public function setClient(CloudWatchLogsClient $client)
    {
        $this->client = $client;
    }

    /**
     * Sets the group name
     *
     * @param string $group Log group name
     */
    public function setGroup($group)
    {
        $this->group = $group;
    }

    /**
     * Sets the stream name
     *
     * @param string $stream Log stream name
     */
    public function setStream($stream)
    {
        $this->stream = $stream;
    }

    /**
     * Write logs
     * 
     * @param  array  $record Log record
     */
    public function write(array $record)
    {
        // not handle, only for buffered usage
    }

    /**
     * Write buffered logs
     * 
     * @param  array  $records Log records
     */
    public function handleBatch(array $records)
    {
        if ($this->initialized !== true) {
            $this->initialize();
        }

        $entities = array_map(function ($value) {
            return $this->formatRecord($value);
        }, $records);

        $data = [
            'logGroupName'  => $this->group,
            'logStreamName' => $this->stream,
            'logEvents'     => $entities
        ];

        if (!empty($this->sequenceToken)) {
            $data['sequenceToken'] = $this->sequenceToken;
        }

        try {
            $response = $this->client->putLogEvents($data);

            $this->sequenceToken = $response->get('nextSequenceToken');
        } catch (\Exception $e) {}
    }

    /**
     * http://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html
     *
     * @param $record
     * @return int
     */
    private function getMessageSize($record)
    {
        return strlen($record['message']) + 26;
    }

    /**
     * @param array $entry
     * @return array
     */
    private function formatRecord(array $entry)
    {
        return [
            'message'   => json_encode($entry, true),
            'timestamp' => $entry['datetime']->getTimestamp() * 1000
        ];
    }

    protected function initialize()
    {
        // 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)) {
            $this
                ->client
                ->createLogGroup(
                    [
                        'logGroupName' => $this->group
                    ]
                );
        }

        // 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()
    {
        return new LineFormatter("%level_name%: %message% %context% %extra%\n");
    }
}

Bu Handlerda özellikle bahsetmek istediğim konu Buffering Strategy üzerine olacak, eğer normal bir Handler yazıyor olsaydık genellikle logging durumlarımızı write metodunda hallediyor olacaktık. Fakat bu kısımda biz handleBatch metodunu kullandık, peki bu ne işe yarıyor. Öncelikle burada yapılan her bir logging işlemi 1 Network Request olduğu için bunun sırf bir requestte loglanması gereken 8 kısım varsa, 8x bir Network Overhead getirmesi anlamına gelmektedir. Bu küçük bir load üzerinde pek bir etki göstermese de yüksek load durumlarında büyük sıkıntılara sebep olabilir.

handleBatch metodu yapılan her logging işlemini bir buffer içinde kaydetmektedir. Ve en sonunda php uygulamanız kapanırken register_shutdown_function() fonksiyonunu kullanarak bu buffer'ı release eder, yani tek bir request ile o an gerçekleşen tüm logging işlemleri gönderilmektedir. Bu size en kötü durumda eşit network performansı en iyi durumda Logging Per Request oranında bir performans getirir.

Önceden oluşturmadıysanız sınıfımız içinde ayrıca bu log group ve log stream bulunmuyorsa initialize kısmında bunları otomatik oluşturan kodlar bulunmaktadır.

Loglama kısmındaki client requestindeki boş try-catch bloğuna dikkatinizi çekmek istiyorum, bu kısım herhangi bir network timeout durumunda bir lazy error checking yapmaktadır. Siz production ortamlarında bir logging fallback yapısı oluşturarak örneğin CloudWatch'a yazılamayan logları dosya sistemine yazabilirsiniz. (Bu yinede kendi içinde bazı problemlere sebep olabilir)

Şimdi yapmamız gereken yeni Handlerımızı Dependency Injection Container'ımızın içine tanımlamak.

    sdk.cloudwatch_handler:
        class: SdkBundle\Service\Logger\CloudWatchHandler
        calls:
            - [setClient, ["@sdk.cloudwatch_logs_client"]]
            - [setGroup, ["%cloudwatch_group_name%"]]
            - [setStream, ["%cloudwatch_stream_name%"]]

Şimdi Dependency Injection Container'ımızın içinde kullandığımız parametreleri parameters.yml dosyamıza tanımlıyoruz (Continuous Integration süreçlerimizle uyumlu çalışması için parameters.yml.dist dosyasına da ekleme yapmayı unutmamalıyız)

    amazon_cloudwatch_key: cloudwatch_key
    amazon_cloudwatch_secret: cloudwatch_secret
    amazon_cloudwatch_region: eu-central-1

    cloudwatch_group_name: test-group
    cloudwatch_stream_name: test-group-stream

Siz bu kısımda key ve secret kısımlarını IAM tarafından aldığınız key ve secret ile değiştirmelisiniz.

Son olarak monolog ayarlamalarımızı yapalım, configdev.yml ve configprod.yml dosyalarından platform bağımsız ayrı ayrı logging tanımlamaları yapabilirsiniz.

Biz development ortamında olduğumuz için config_dev.yml dosyasına küçük bir düzenleme yapıyoruz.

monolog:  
    channels: ["site"]
    handlers:
        site:
            level:    debug
            type:     buffer     # Buffer stratejisini kullanıyoruz
            channels: ["site"]
            handler:  cloudwatch
        cloudwatch:
            type: service
            id: sdk.cloudwatch_handler

Burada en basit anlamıyla site isminde bir channelımız var bu channel'a yazdığımız loglar cloudwatch handlerımız tarafından handle ediliyor olacak.

Her adımı doğru biçimde yaptıysak artık log yazmaya hazırız demektir.

use Psr\Log\LogLevel; // Bunu servisin en yukarısında 

$monolog = $this->container->get('monolog.logger.site'); // site channelımızın ismi Symfony DIC üzerinden direk bu şekilde resolve 

$monolog->log(
    LogLevel::ERROR,
    'Çok kötü şeyler olduğunu hissediyorum',
    ['instance' => 'i-24252526456', 'degree' => 'A'] // Log detayları bu kısıma gönderdiğimiz verileri daha sonra CloudWatch ile filtreleyebiliriz.
);

Siz logger sınıfınızını kendinize göre soyutlayabilirsiniz. Temel olarak bu şekilde CloudWatch üzerine loglarınızı yazabilirsiniz.

Herkese mutlu günler :)

Batıkan Senemoğlu

Read more posts by this author.

İstanbul, Turkey https://batikansenemoglu.com