├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Client ├── ClientWrapper.php ├── DatadogClient.php ├── DatadogDns.php ├── DatadogFactory.php ├── DatadogFactoryInterface.php ├── DogStatsInterface.php ├── MockClient.php └── NullDatadogClient.php ├── DependencyInjection ├── CompilerPass │ ├── PushDatadogHandlerPass.php │ └── SqlLoggerPass.php ├── Configuration.php └── OkvpnDatadogExtension.php ├── Dumper ├── ContextDumperInterface.php ├── DatadogEvent.php └── MonologContextDumper.php ├── EventListener ├── DatadogFlushBufferListener.php ├── ExceptionListener.php └── ResponseTimeListener.php ├── Logging ├── ArtifactsStorageInterface.php ├── DatadogHandler.php ├── DatadogSqlLogger.php ├── DeduplicationDatadogLogger.php ├── ErrorBag.php ├── LocalArtifactsStorage.php └── Watcher │ ├── ContextWatcherInterface.php │ └── DefaultWatcher.php ├── OkvpnDatadogBundle.php ├── Resources ├── config │ ├── listener.yml │ ├── services.yml │ └── sqllogger.yml └── docs │ ├── 1.png │ ├── consumers.png │ ├── dashboard.png │ ├── exception.png │ ├── jira.png │ └── telegram.png ├── Services ├── ExceptionHashService.php └── SkipCaptureService.php └── Stream └── UdpStreamWriter.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | .github export-ignore 3 | .travis.yml export-ignore 4 | phpunit.xml.dist export-ignore 5 | symfony.lock export-ignore 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /phpunit.xml 2 | /build/ 3 | /composer.lock 4 | /vendor/* 5 | /var/* 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2019 Uladzimir Tsykun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony datadog integration 2 | 3 | Symfony [Datadog][1] integration to monitor and track for application errors and send notifications about them. 4 | 5 | [![Tests](https://github.com/okvpn/datadog-symfony/actions/workflows/tests.yml/badge.svg)](https://github.com/okvpn/datadog-symfony/actions/workflows/tests.yml) [![Latest Stable Version](https://poser.pugx.org/okvpn/datadog-symfony/v/stable)](https://packagist.org/packages/okvpn/datadog-symfony) [![Latest Unstable Version](https://poser.pugx.org/okvpn/datadog-symfony/v/unstable)](https://packagist.org/packages/okvpn/datadog-symfony) [![Total Downloads](https://poser.pugx.org/okvpn/datadog-symfony/downloads)](https://packagist.org/packages/okvpn/datadog-symfony) [![License](https://poser.pugx.org/okvpn/datadog-symfony/license)](https://packagist.org/packages/okvpn/datadog-symfony) 6 | 7 | ## Benefits 8 | 9 | Use datadog-symfony for: 10 | 11 | * Monitor production applications in realtime. 12 | * Application performance insights to see when performance is geting degradated. 13 | * Access to the `okvpn_datadog.client` through the container. 14 | * Send notification about errors in Slack, email, telegram, etc. 15 | * Create JIRA issue when some alarm/exception triggers using this [plugin][4] 16 | 17 | ## Install 18 | Install using [composer][2] following the official Composer [documentation][3]: 19 | 20 | 1. Install via composer: 21 | ``` 22 | composer require okvpn/datadog-symfony 23 | ``` 24 | 25 | 2. And add this bundle to your AppKernel: 26 | 27 | For Symfony 4+ add bundle to `config/bundles.php` 28 | 29 | ```php 30 | ['all' => true], 34 | ... 35 | ] 36 | ``` 37 | 38 | 3. Base configuration to enable the datadog client in your `config.yml` 39 | 40 | ```yaml 41 | okvpn_datadog: 42 | clients: 43 | default: 'datadog://127.0.0.1/namespace' 44 | 45 | ## More clients 46 | i2pd_client: 'datadog://10.10.1.1:8125/app?tags=tg1,tg2' 47 | 'null': null://null 48 | mock: mock://mock 49 | dns: '%env(DD_CLIENT)%' 50 | ``` 51 | 52 | Where env var looks like: 53 | ``` 54 | DD_CLIENT=datadog://127.0.0.1:8125/app1?tags=tg1,tg2 55 | ``` 56 | 57 | Access to client via DIC: 58 | 59 | ```php 60 | $client = $this->container->get('okvpn_datadog.client'); // Default public alias 61 | 62 | // okvpn_datadog.client.default - private services 63 | // okvpn_datadog.client.i2pd_client 64 | // okvpn_datadog.client.null 65 | 66 | class FeedController 67 | { 68 | public function __construct(private DogStatsInterface $dogStats){} // default 69 | } 70 | 71 | class FeedController 72 | { 73 | public function __construct(private DogStatsInterface $i2pdClient){} // i2pd_client 74 | } 75 | ``` 76 | 77 | ```php 78 | class FeedController extends Controller 79 | { 80 | // Inject via arg for Symfony 4+ 81 | #[Route(path: '/', name: 'feeds')] 82 | public function feedsAction(DogStatsInterface $dogStats, DogStatsInterface $i2pdClient): Response 83 | { 84 | $dogStats->decrement('feed'); 85 | 86 | return $this->render('feed/feeds.html.twig'); 87 | } 88 | } 89 | ``` 90 | 91 | ## Custom metrics that provided by OkvpnDatadogBundle 92 | 93 | Where `app` metrics namespace. 94 | 95 | | Name | Type | Description | 96 | |-------------------------------|:------------:|:--------------------------------------------------------------------------:| 97 | | app.exception | counter | Track how many exception occurred in application per second | 98 | | app.doctrine.median | gauge | Median execute time of sql query (ms.) | 99 | | app.doctrine.avg | gauge | Avg execute time of sql query (ms.) | 100 | | app.doctrine.count | rate | Count of sql queries per second | 101 | | app.doctrine.95percentile | gauge | 95th percentile of execute time of sql query (ms.) | 102 | | app.exception | event | Event then exception is happens | 103 | | app.http_request | timing | Measure timing how long it takes to fully render a page | 104 | 105 | ## Configuration 106 | 107 | A more complex setup look like this `config/packages/ddog.yml`: 108 | 109 | ``` 110 | 111 | okvpn_datadog: 112 | profiling: true # Default false: enable exception, http request etc. 113 | namespace: app # Metric namespace 114 | port: 8125 # datadog udp port 115 | host: 127.0.0.1 116 | tags: # Default tags which sent with every request 117 | - example.com 118 | - cz1 119 | doctrine: true # Enable timing for sql query 120 | exception: all # Send event on exception 121 | # *all* - handle all exceptions: logger error context, console error, http error. 122 | # *uncaught* - handle uncaught exceptions: console error, http error. 123 | # *none* - disable exceptions handler 124 | 125 | dedup_path: null # Path to save duplicates log records across multiple requests. 126 | # Used to prevent send multiple event on the same error 127 | 128 | dedup_keep_time: 86400 # Time in seconds during which duplicate entries should be suppressed after a given log is sent through 129 | artifacts_path: null # Long events is aggregate as artifacts, because datadog event size is limited to 4000 characters. 130 | 131 | handle_exceptions: # Skip exceptions 132 | skip_instanceof: 133 | - Symfony\Component\Console\Exception\ExceptionInterface 134 | - Symfony\Component\HttpKernel\Exception\HttpExceptionInterface 135 | skip_command: # Skip exception for console command 136 | - okvpn:message-queue:consume 137 | ``` 138 | 139 | ## Usage 140 | 141 | ```php 142 | class FeedController extends Controller 143 | { 144 | // Inject via arg for Symfony 4+ 145 | #[Route(path: '/', name: 'feeds')] 146 | public function feedsAction(DogStatsInterface $dogStats): Response 147 | { 148 | $dogStats->decrement('feed'); 149 | 150 | return $this->render('feed/feeds.html.twig'); 151 | } 152 | } 153 | 154 | // or use service directly for 3.4 155 | $client = $this->container->get('okvpn_datadog.client'); 156 | 157 | /* 158 | * Increment/Decriment 159 | * 160 | * Counters track how many times something happens per second, such as page views. 161 | * @link https://docs.datadoghq.com/developers/dogstatsd/data_types/#counters 162 | * 163 | * @param string $metrics Metric(s) to increment 164 | * @param int $delta Value to decrement the metric by 165 | * @param float $sampleRate Sample rate of metric 166 | * @param string[] $tags List of tags for this metric 167 | * 168 | * @return DogStatsInterface 169 | */ 170 | $client->increment('page.views', 1); 171 | $client->increment('page.load', 1, 0.5, ['tag1' => 'http']); 172 | ``` 173 | 174 | ### Sets 175 | 176 | ```php 177 | 178 | $consumerPid = getmypid(); 179 | $client->set('consumers', $consumerPid); 180 | ``` 181 | 182 | ### Timing 183 | 184 | ```php 185 | $client->timing('http.response_time', 256); 186 | ``` 187 | 188 | See more metrics here [DogStatsInterface](src/Client/DogStatsInterface.php) 189 | 190 | ## Impact on performance 191 | 192 | Datadog bundle use UDP protocol to send custom metrics to DogStatsD collector, that usually running on localhost (127.0.0.1). 193 | Because it uses UDP, your application can send metrics without waiting for a response. DogStatsD aggregates multiple data 194 | points for each unique metric into a single data point over a period of time called the flush interval and sends it to Datadog where 195 | it is stored and available for graphing alongside the rest of your metrics. 196 | 197 | ![1](src/Resources/docs/1.png) 198 | 199 | ## Screencasts. 200 | 201 | What can be done using datadog. 202 | 203 | ### Datadog custom symfony dashboard 204 | 205 | ![dashboard](src/Resources/docs/dashboard.png) 206 | 207 | ### Datadog Anomaly Monitor of running consumers 208 | 209 | ![consumers](src/Resources/docs/consumers.png) 210 | 211 | ### Live exception event stream 212 | 213 | ![exception](src/Resources/docs/exception.png) 214 | 215 | ### Send notification about errors in telegram. 216 | 217 | ![telegram](src/Resources/docs/telegram.png) 218 | 219 | ### Create JIRA issue when some alarm/exception triggers 220 | 221 | ![jira](src/Resources/docs/jira.png) 222 | 223 | License 224 | ------- 225 | MIT License. See [LICENSE](LICENSE). 226 | 227 | [1]: https://docs.datadoghq.com/getting_started/ 228 | [2]: https://getcomposer.org/ 229 | [3]: https://getcomposer.org/download/ 230 | [4]: https://www.datadoghq.com/blog/jira-issue-tracking/ 231 | 232 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"okvpn/datadog-symfony", 3 | "type":"symfony-bundle", 4 | "license":"MIT", 5 | "description": "Symfony Datadog integration", 6 | "keywords": ["DogStatsD", "datadog-symfony", "php-datadog"], 7 | "authors": [ 8 | { 9 | "name": "Uladzimir Tsykun", 10 | "homepage": "https://github.com/vtsykun", 11 | "email": "vtsykun@okvpn.org" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "Okvpn\\Bundle\\DatadogBundle\\": "src" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { "Okvpn\\Bundle\\DatadogBundle\\Tests\\": "tests/" } 21 | }, 22 | "require": { 23 | "php":">=7.4", 24 | "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.0 || ^7.0", 25 | "graze/dog-statsd": "^0.4 || ^1.0" 26 | }, 27 | "require-dev": { 28 | "ext-pdo_sqlite": "*", 29 | "doctrine/doctrine-bundle": "^2.6", 30 | "doctrine/orm": "^2.7", 31 | "phpunit/phpunit": "^8.5 || ^9.0 || ^10.0", 32 | "symfony/browser-kit": "^4.4 || ^5.4 || ^6.0 || ^7.0", 33 | "symfony/console": "^4.4 || ^5.4 || ^6.0 || ^7.0", 34 | "symfony/security-bundle": "^4.4 || ^5.4 || ^6.0 || ^7.0", 35 | "symfony/phpunit-bridge": "^4.4 || ^5.4 || ^6.0 || ^7.0", 36 | "symfony/var-dumper": "^4.4 || ^5.4 || ^6.0 || ^7.0", 37 | "symfony/yaml": "^4.4 || ^5.4 || ^6.0 || ^7.0", 38 | "symfony/flex": "^1.10 || ^2.0", 39 | "symfony/monolog-bundle": "^3.2" 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "symfony/flex": true 44 | } 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-master": "1.2-dev" 49 | }, 50 | "symfony": { 51 | "allow-contrib": false 52 | } 53 | }, 54 | "minimum-stability": "dev", 55 | "prefer-stable": true 56 | } 57 | -------------------------------------------------------------------------------- /src/Client/ClientWrapper.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 33 | parent::__construct($instanceId); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function configure(array $options = []): Client 40 | { 41 | $this->options = $options; 42 | return parent::configure($options); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function event($title, $text, array $metadata = [], array $tags = []): Client 49 | { 50 | if (!$this->dataDog) { 51 | return $this; 52 | } 53 | 54 | $prefix = $this->namespace ? $this->namespace . '.' : ''; 55 | $title = $prefix . $title; 56 | 57 | $text = str_replace(["\r", "\n"], ['', "\\n"], $text); 58 | // Todo bugfix 59 | $metric = sprintf('_e{%d,%d}', strlen($title), strlen($text)); 60 | $value = sprintf('%s|%s', $title, $text); 61 | 62 | foreach ($metadata as $key => $data) { 63 | if (isset($this->eventMetaData[$key])) { 64 | $value .= sprintf('|%s:%s', $this->eventMetaData[$key], $data); 65 | } 66 | } 67 | 68 | $value .= $this->formatTags(array_merge($this->tags, $tags)); 69 | 70 | return $this->sendMessages([ 71 | sprintf('%s:%s', $metric, $value), 72 | ]); 73 | } 74 | 75 | /** 76 | * Get option 77 | * 78 | * @param string $option 79 | * @param null|mixed $default 80 | * @return mixed 81 | */ 82 | public function getOption(string $option, $default = null) 83 | { 84 | return $this->options[$option] ?? $default; 85 | } 86 | 87 | /** 88 | * Array of option 89 | * 90 | * @return array 91 | */ 92 | public function getOptions(): array 93 | { 94 | return $this->options; 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | */ 100 | protected function sendMessages(array $messages): Client 101 | { 102 | if ($this->repeatCount >= self::MAX_REPEAT_COUNT) { 103 | return false; 104 | } 105 | 106 | try { 107 | if (is_null($this->stream)) { 108 | $this->stream = new UdpStreamWriter( 109 | $this->instanceId, 110 | $this->host, 111 | $this->port, 112 | $this->onError, 113 | $this->timeout 114 | ); 115 | } 116 | $this->message = implode("\n", $messages); 117 | $this->written = $this->stream->write($this->message); 118 | 119 | return $this; 120 | } catch (\Throwable $exception) { 121 | $this->repeatCount++; 122 | if ($this->logger) { 123 | $this->logger->error($exception->getMessage(), ['message' => $messages]); 124 | } 125 | return false; 126 | } 127 | } 128 | 129 | /** 130 | * @param string[] $tags A list of tags to apply to each message 131 | * 132 | * @return string 133 | */ 134 | private function formatTags(array $tags = []) 135 | { 136 | if (!$this->dataDog || count($tags) === 0) { 137 | return ''; 138 | } 139 | 140 | $result = []; 141 | foreach ($tags as $key => $value) { 142 | if (is_numeric($key)) { 143 | $result[] = $value; 144 | } else { 145 | $result[] = sprintf('%s:%s', $key, $value); 146 | } 147 | } 148 | 149 | return sprintf('|#%s', implode(',', $result)); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Client/DatadogClient.php: -------------------------------------------------------------------------------- 1 | configure($options); 25 | $this->options = $options; 26 | $this->wrapped = $statsd; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function increment(string $metrics, int $delta = 1, float $sampleRate = 1.0, array $tags = []) 33 | { 34 | $this->wrapped->increment($metrics, $delta, $sampleRate, $tags); 35 | return $this; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function decrement(string $metrics, int $delta = 1, float $sampleRate = 1.0, array $tags = []) 42 | { 43 | $this->wrapped->decrement($metrics, $delta, $sampleRate, $tags); 44 | return $this; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function timing(string $metric, float $time, array $tags = []) 51 | { 52 | $this->wrapped->timing($metric, $time, $tags); 53 | return $this; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function time(string $metric, callable $func, array $tags = []) 60 | { 61 | $this->wrapped->time($metric, $func, $tags); 62 | return $this; 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function gauge(string $metric, int $value, array $tags = []) 69 | { 70 | $this->wrapped->gauge($metric, $value, $tags); 71 | return $this; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function histogram(string $metric, float $value, float $sampleRate = 1.0, array $tags = []) 78 | { 79 | $this->wrapped->histogram($metric, $value, $sampleRate, $tags); 80 | return $this; 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function set(string $metric, int $value, array $tags = []) 87 | { 88 | $this->wrapped->set($metric, $value, $tags); 89 | return $this; 90 | } 91 | 92 | /** 93 | * {@inheritdoc} 94 | */ 95 | public function event(string $title, string $text, array $metadata = [], array $tags = []) 96 | { 97 | $this->wrapped->event($title, $text, $metadata, $tags); 98 | return $this; 99 | } 100 | 101 | /** 102 | * {@inheritdoc} 103 | */ 104 | public function serviceCheck(string $name, int $status, array $metadata = [], array $tags = []) 105 | { 106 | $this->wrapped->serviceCheck($name, $status, $metadata, $tags); 107 | return $this; 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | */ 113 | public function getOptions(): array 114 | { 115 | return $this->wrapped->getOptions(); 116 | } 117 | 118 | /** 119 | * {@inheritdoc} 120 | */ 121 | public function getOption(string $name, $default = null) 122 | { 123 | return $this->wrapped->getOption($name, $default); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Client/DatadogDns.php: -------------------------------------------------------------------------------- 1 | parser($dsn); 19 | } 20 | 21 | public static function fromString(string $dsn): self 22 | { 23 | return new DatadogDns($dsn); 24 | } 25 | 26 | private function parser(string $dsn): void 27 | { 28 | $this->originalDsn = $dsn; 29 | 30 | if (false === $dsn = parse_url($dsn)) { 31 | throw new \InvalidArgumentException('The datadog DSN is invalid.'); 32 | } 33 | 34 | if (!isset($dsn['scheme'])) { 35 | throw new \InvalidArgumentException('The datadog DSN must contain a scheme.'); 36 | } 37 | 38 | $this->scheme = $dsn['scheme']; 39 | $this->host = $dsn['host'] ?? '127.0.0.1'; 40 | $this->port = $dsn['port'] ?? 8125; 41 | $this->namespace = str_replace('/', '', $dsn['path'] ?? 'app'); 42 | 43 | if (isset($dsn['query'])) { 44 | parse_str($dsn['query'], $query); 45 | if (isset($query['tags'])) { 46 | $this->tags = explode(',', $query['tags']); 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function getOriginalDsn(): string 55 | { 56 | return $this->originalDsn; 57 | } 58 | 59 | /** 60 | * @return string 61 | */ 62 | public function getScheme(): string 63 | { 64 | return $this->scheme; 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | public function getHost(): string 71 | { 72 | return $this->host; 73 | } 74 | 75 | /** 76 | * @return int 77 | */ 78 | public function getPort(): int 79 | { 80 | return $this->port; 81 | } 82 | 83 | /** 84 | * @return string 85 | */ 86 | public function getNamespace(): string 87 | { 88 | return $this->namespace; 89 | } 90 | 91 | /** 92 | * @return array 93 | */ 94 | public function getTags(): array 95 | { 96 | return $this->tags; 97 | } 98 | 99 | public function toArray(): array 100 | { 101 | return [ 102 | 'scheme' => $this->getScheme(), 103 | 'namespace' => $this->getNamespace(), 104 | 'tags' => $this->getTags(), 105 | 'host' => $this->getHost(), 106 | 'port' => $this->getPort(), 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Client/DatadogFactory.php: -------------------------------------------------------------------------------- 1 | NullDatadogClient::class, 11 | 'mock' => MockClient::class, 12 | 'datadog' => DatadogClient::class, 13 | ]; 14 | 15 | /** 16 | * {@inheritdoc} 17 | */ 18 | public function createClient(array $options): DogStatsInterface 19 | { 20 | if (isset($options['dsn'])) { 21 | $dsn = DatadogDns::fromString($options['dsn']); 22 | $options = $dsn->toArray() + $options; 23 | } 24 | 25 | $scheme = $options['scheme'] ?? 'datadog'; 26 | if (!static::$clientFactories[$scheme]) { 27 | throw new \InvalidArgumentException('The datadog DSN scheme "%s" does not exists. Allowed "%s"', $scheme, implode(",", static::$clientFactories)); 28 | } 29 | 30 | return new static::$clientFactories[$scheme]($options); 31 | } 32 | 33 | public static function setClientFactory(string $alias, string $className): void 34 | { 35 | static::$clientFactories[$alias] = $className; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Client/DatadogFactoryInterface.php: -------------------------------------------------------------------------------- 1 | data['increments'][] = [ 14 | 'metric' => $metrics, 15 | 'delta' => $delta, 16 | 'sampleRate' => $sampleRate, 17 | 'tags' => $tags, 18 | ]; 19 | 20 | return $this; 21 | } 22 | 23 | public function getIncrements(string $metric = null): array 24 | { 25 | return $this->get('increments', $metric); 26 | } 27 | 28 | public function decrement(string $metric, int $delta = 1, float $sampleRate = 1.0, array $tags = []) 29 | { 30 | $this->data['decrements'][] = [ 31 | 'metric' => $metric, 32 | 'delta' => $delta, 33 | 'sampleRate' => $sampleRate, 34 | 'tags' => $tags, 35 | ]; 36 | 37 | return $this; 38 | } 39 | 40 | public function getDecrements(string $metric = null): array 41 | { 42 | return $this->get('decrements', $metric); 43 | } 44 | 45 | public function timing(string $metric, float $time, array $tags = []) 46 | { 47 | $this->data['timings'][] = [ 48 | 'metric' => $metric, 49 | 'time' => $time, 50 | 'tags' => $tags, 51 | ]; 52 | 53 | return $this; 54 | } 55 | 56 | public function getTimings(string $metric = null): array 57 | { 58 | return $this->get('timings', $metric); 59 | } 60 | 61 | public function time(string $metric, callable $func, array $tags = []) 62 | { 63 | $timerStart = microtime(true); 64 | $func(); 65 | $timerEnd = microtime(true); 66 | $time = round(($timerEnd - $timerStart) * 1000, 4); 67 | 68 | return $this->timing($metric, $time, $tags); 69 | } 70 | 71 | public function gauge(string $metric, int $value, array $tags = []) 72 | { 73 | $this->data['gauges'][] = [ 74 | 'metric' => $metric, 75 | 'value' => $value, 76 | 'tags' => $tags, 77 | ]; 78 | 79 | return $this; 80 | } 81 | 82 | public function getGauges(string $metric = null): array 83 | { 84 | return $this->get('gauges', $metric); 85 | } 86 | 87 | public function histogram(string $metric, float $value, float $sampleRate = 1.0, array $tags = []) 88 | { 89 | $this->data['histograms'][] = [ 90 | 'metric' => $metric, 91 | 'value' => $value, 92 | 'sampleRate' => $sampleRate, 93 | 'tags' => $tags, 94 | ]; 95 | 96 | return $this; 97 | } 98 | 99 | public function getHistograms(string $metric = null): array 100 | { 101 | return $this->get('histograms', $metric); 102 | } 103 | 104 | public function set(string $metric, int $value, array $tags = []) 105 | { 106 | $this->data['sets'][] = [ 107 | 'metric' => $metric, 108 | 'value' => $value, 109 | 'tags' => $tags, 110 | ]; 111 | 112 | return $this; 113 | } 114 | 115 | public function getSets(string $metric = null): array 116 | { 117 | return $this->get('sets', $metric); 118 | } 119 | 120 | public function event(string $title, string $text, array $metadata = [], array $tags = []) 121 | { 122 | $this->data['events'][] = [ 123 | 'title' => $title, 124 | 'text' => $text, 125 | 'metadata' => $metadata, 126 | 'tags' => $tags, 127 | ]; 128 | 129 | return $this; 130 | } 131 | 132 | public function getEvents(string $title = null): array 133 | { 134 | return $this->get('events', $title); 135 | } 136 | 137 | public function serviceCheck(string $name, int $status, array $metadata = [], array $tags = []) 138 | { 139 | $this->data['serviceChecks'][] = [ 140 | 'name' => $name, 141 | 'status' => $status, 142 | 'metadata' => $metadata, 143 | 'tags' => $tags, 144 | ]; 145 | 146 | return $this; 147 | } 148 | 149 | public function getServiceChecks(string $name = null): array 150 | { 151 | return $this->get('serviceChecks', $name); 152 | } 153 | 154 | public function getOptions(): array 155 | { 156 | return []; 157 | } 158 | 159 | public function getOption(string $name, $default = null) 160 | { 161 | return $default; 162 | } 163 | 164 | private function get(string $type, string $metric = null): array 165 | { 166 | if ($metric === null) { 167 | return $this->data[$type]; 168 | } 169 | 170 | if ($type === 'events') { 171 | $key = 'title'; 172 | } elseif ($type === 'serviceChecks') { 173 | $key = 'name'; 174 | } else { 175 | $key = 'metric'; 176 | } 177 | 178 | return array_filter($this->data[$type], static function (array $data) use ($key, $metric) { 179 | return $data[$key] === $metric; 180 | }); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Client/NullDatadogClient.php: -------------------------------------------------------------------------------- 1 | getParameter('okvpn_datadog.logging') || false === $container->getParameter('okvpn_datadog.profiling')) { 19 | return; 20 | } 21 | 22 | $loggerByChanel = array_map( 23 | function ($channel) { 24 | return sprintf('monolog.logger.%s', $channel); 25 | }, 26 | $container->getParameter('okvpn_datadog.monolog_channels') 27 | ); 28 | 29 | $loggerByChanel[] = 'monolog.logger'; 30 | foreach ($container->findTaggedServiceIds('monolog.logger') as $id => $tags) { 31 | foreach ($tags as $tag) { 32 | if (empty($tag['channel'])) { 33 | continue; 34 | } 35 | 36 | $resolvedChannel = $container->getParameterBag()->resolveValue($tag['channel']); 37 | $loggerByChanel[] = sprintf('monolog.logger.%s', $resolvedChannel); 38 | } 39 | } 40 | 41 | $loggerByChanel = array_unique($loggerByChanel); 42 | foreach ($loggerByChanel as $loggerId) { 43 | if ($container->hasDefinition($loggerId)) { 44 | $definition = $container->getDefinition($loggerId); 45 | $definition->addMethodCall('pushHandler', [new Reference('okvpn_datadog.monolog.log_handler')]); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/SqlLoggerPass.php: -------------------------------------------------------------------------------- 1 | connections = $connections; 19 | } 20 | 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function process(ContainerBuilder $container): void 25 | { 26 | if (false === $container->hasDefinition('okvpn_datadog.logger.sql')) { 27 | return; 28 | } 29 | 30 | foreach ($this->connections as $name) { 31 | $configuration = sprintf('doctrine.dbal.%s_connection.configuration', $name); 32 | if (false === $container->hasDefinition($configuration)) { 33 | continue; 34 | } 35 | $loggerId = 'okvpn_datadog.sql_logger.' . $name; 36 | $container->setDefinition($loggerId, new ChildDefinition('okvpn_datadog.logger.sql')); 37 | $configuration = $container->getDefinition($configuration); 38 | if ($configuration->hasMethodCall('setSQLLogger')) { 39 | $chainLoggerId = 'doctrine.dbal.logger.chain.' . $name; 40 | if ($container->hasDefinition($chainLoggerId)) { 41 | $chainLogger = $container->getDefinition($chainLoggerId); 42 | $chainLogger->addMethodCall('addLogger', [new Reference($loggerId)]); 43 | } else { 44 | $configuration->addMethodCall('setSQLLogger', [new Reference($loggerId)]); 45 | } 46 | } else { 47 | $configuration->addMethodCall('setSQLLogger', [new Reference($loggerId)]); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 25 | 26 | $rootNode->children() 27 | ->arrayNode('handle_exceptions') 28 | ->children() 29 | ->arrayNode('skip_instanceof') 30 | ->prototype('scalar')->end() 31 | ->end() 32 | ->arrayNode('skip_capture') 33 | ->prototype('scalar')->end() 34 | ->end() 35 | ->arrayNode('skip_hash') 36 | ->prototype('scalar')->end() 37 | ->end() 38 | ->arrayNode('skip_wildcard') 39 | ->prototype('scalar')->end() 40 | ->end() 41 | ->arrayNode('skip_command') 42 | ->prototype('scalar')->end() 43 | ->end() 44 | ->end() 45 | ->end() 46 | ->enumNode('exception') 47 | ->values(['all', 'uncaught', 'none']) 48 | ->defaultValue('all') 49 | ->end() 50 | ->scalarNode('host') 51 | ->defaultValue('127.0.0.1') 52 | ->end() 53 | ->integerNode('port') 54 | ->defaultValue(8125) 55 | ->end() 56 | ->scalarNode('namespace') 57 | ->cannotBeEmpty() 58 | ->end() 59 | ->arrayNode('tags') 60 | ->prototype('scalar')->end() 61 | ->end() 62 | ->booleanNode('doctrine') 63 | ->defaultTrue() 64 | ->end() 65 | ->scalarNode('dedup_path') 66 | ->defaultNull() 67 | ->end() 68 | ->scalarNode('artifacts_path') 69 | ->defaultNull() 70 | ->end() 71 | ->integerNode('dedup_keep_time') 72 | ->defaultValue(7 * 86400) 73 | ->end() 74 | ->booleanNode('profiling') 75 | ->defaultFalse() 76 | ->end(); 77 | 78 | $this->addClientsSection($rootNode); 79 | 80 | return $treeBuilder; 81 | } 82 | 83 | private function addClientsSection(ArrayNodeDefinition $rootNode): void 84 | { 85 | $rootNode->children() 86 | ->arrayNode('clients') 87 | ->useAttributeAsKey('alias', false) 88 | ->beforeNormalization() 89 | ->always() 90 | ->then(static function ($v) { 91 | if (is_iterable($v)) { 92 | foreach ($v as $name => $client) { 93 | if (is_string($client)) { 94 | $client = ['dsn' => $client]; 95 | } 96 | $client['alias'] = $name; 97 | $v[$name] = $client; 98 | } 99 | } 100 | return $v; 101 | }) 102 | ->end() 103 | ->arrayPrototype() 104 | ->children() 105 | ->scalarNode('alias')->isRequired()->end() 106 | ->scalarNode('dsn')->isRequired()->end() 107 | ->end() 108 | ->end() 109 | ->end(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/DependencyInjection/OkvpnDatadogExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 25 | 26 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 27 | $loader->load('services.yml'); 28 | $container->getDefinition('okvpn_datadog.services.skip_capture') 29 | ->replaceArgument(1, $this->defaultHandlerExceptions($config)); 30 | $container->getDefinition('okvpn_datadog.logger') 31 | ->replaceArgument(5, $config['dedup_path']) 32 | ->replaceArgument(6, $config['dedup_keep_time']); 33 | $container->getDefinition('okvpn_datadog.logger.artifact_storage') 34 | ->replaceArgument(0, $config['artifacts_path'] ?: $container->getParameter('kernel.logs_dir')); 35 | 36 | $container->getDefinition('okvpn_datadog.client') 37 | ->replaceArgument(0, $config); 38 | 39 | if (isset($config['clients'])) { 40 | foreach ($config['clients'] as $client) { 41 | $this->loadClient($container, $client); 42 | } 43 | 44 | if (isset($config['clients']['default'])) { 45 | $container->removeDefinition('okvpn_datadog.client'); 46 | $container->setAlias('okvpn_datadog.client', 'okvpn_datadog.client.default')->setPublic(true); 47 | $container->setAlias(DogStatsInterface::class, 'okvpn_datadog.client.default')->setPublic(true); 48 | } 49 | } 50 | 51 | if (true === $config['profiling']) { 52 | if (true === $config['doctrine']) { 53 | $loader->load('sqllogger.yml'); 54 | } 55 | $loader->load('listener.yml'); 56 | } 57 | 58 | $container->setParameter('okvpn_datadog.logging', $config['exception']); 59 | $container->setParameter('okvpn_datadog.profiling', $config['profiling']); 60 | } 61 | 62 | protected function loadClient(ContainerBuilder $container, array $client) 63 | { 64 | $ddDef = new Definition(DogStatsInterface::class, [$client]); 65 | $ddDef->setFactory([new Reference(DatadogFactoryInterface::class), 'createClient']); 66 | 67 | $container->setDefinition($id = sprintf('okvpn_datadog.client.%s', $client['alias']), $ddDef); 68 | $container->registerAliasForArgument($id, DogStatsInterface::class, $client['alias']); 69 | } 70 | 71 | protected function defaultHandlerExceptions(array $config): array 72 | { 73 | $config = $config['handle_exceptions'] ?? []; 74 | $config['skip_instanceof'] = array_merge( 75 | $config['skip_instanceof'] ?? [], 76 | [ 77 | 'Symfony\Component\Console\Exception\ExceptionInterface', //command argument invalid 78 | 'Symfony\Component\HttpKernel\Exception\HttpExceptionInterface', //Http exceptions 79 | ] 80 | ); 81 | 82 | return $config; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Dumper/ContextDumperInterface.php: -------------------------------------------------------------------------------- 1 | message = $message; 56 | $this->fullMessage = $fullMessage; 57 | $this->shortMessage = $shortMessage; 58 | $this->title = $title; 59 | $this->tags = $tags; 60 | $this->cause = $rootCause; 61 | $this->datetime = $datetime ?: time(); 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getMessage(): string 68 | { 69 | return $this->message; 70 | } 71 | 72 | /** 73 | * @return string 74 | */ 75 | public function getFullMessage(): string 76 | { 77 | return $this->fullMessage; 78 | } 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function getTitle(): string 84 | { 85 | return $this->title; 86 | } 87 | 88 | /** 89 | * @return array 90 | */ 91 | public function getTags(): array 92 | { 93 | return $this->tags; 94 | } 95 | 96 | /** 97 | * @return mixed|null 98 | */ 99 | public function getCause() 100 | { 101 | return $this->cause; 102 | } 103 | 104 | /** 105 | * @return int 106 | */ 107 | public function getDatetime(): int 108 | { 109 | return $this->datetime; 110 | } 111 | 112 | /** 113 | * @return string 114 | */ 115 | public function getShortMessage(): string 116 | { 117 | return $this->shortMessage; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Dumper/MonologContextDumper.php: -------------------------------------------------------------------------------- 1 | $val) { 27 | if ($val instanceof \Throwable) { 28 | $exception = $val; 29 | unset($context[$key]); 30 | break; 31 | } 32 | } 33 | 34 | $tags = []; 35 | if (isset($context['tags'])) { 36 | $tags = $context['tags']; 37 | unset($context['tags']); 38 | } 39 | 40 | $strOutput = ''; 41 | 42 | // symfony/var-dumper is optional dependency 43 | if (class_exists(CliDumper::class)) { 44 | try { 45 | $cloner = new VarCloner(); 46 | $dumper = new CliDumper(); 47 | $cloner->setMaxString(static::MAX_STRING_LENGTH); 48 | $cloner->setMaxItems(static::MAX_CONTEXT_ITEMS); 49 | $data = $cloner->cloneVar($context); 50 | $data = $data->withMaxDepth(3) 51 | ->withRefHandles(false); 52 | $dumper->dump( 53 | $data, 54 | function ($line, $depth) use (&$strOutput) { 55 | // A negative depth means "end of dump" 56 | if ($depth >= 0) { 57 | $strOutput .= str_repeat(' ', $depth).$line."\n"; 58 | } 59 | } 60 | ); 61 | } catch (\Throwable $e) { 62 | } 63 | } 64 | 65 | if ($exception) { 66 | $message = '[' . get_class($exception) . '] ' . $message; 67 | $strOutput = (string) $exception . "\n\n" . $strOutput; 68 | } 69 | if ($strOutput) { 70 | $strOutput = str_replace('\n', '', $strOutput); 71 | } 72 | 73 | $pattern = '{{{placeholder}}}'; 74 | $newMessage = substr($message, 0, 1600) . $this->openSeparator . $pattern . $this->closeSeparator; 75 | 76 | //Datadog event api accept only < 4000 charsets 77 | $maxLength = 4000 - strlen($newMessage) - 120; //reserve 120 charset for artifact code; 78 | $newMessage = str_replace($pattern, substr($strOutput, 0, $maxLength), $newMessage); 79 | 80 | return new DatadogEvent( 81 | $newMessage, 82 | preg_replace('{[\r\n].*}', '', substr($message, 0, 160)), 83 | $strOutput, 84 | $exception ? 'exception' : 'log', 85 | $tags, 86 | $exception 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EventListener/DatadogFlushBufferListener.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 30 | $this->errorBag = $errorBag; 31 | } 32 | 33 | public function onKernelTerminate(): void 34 | { 35 | if ($record = $this->errorBag->rootError()) { 36 | $this->errorBag->flush(); 37 | try { 38 | $context = $record['context']; 39 | $context['tags'] = ['error:http', 'channel:' . $record['channel']]; 40 | $this->logger->warning($record['message'], $context); 41 | } catch (\Exception $exception) {} 42 | } 43 | } 44 | 45 | /** 46 | * @param ConsoleTerminateEvent $event 47 | */ 48 | public function onCliTerminate(ConsoleTerminateEvent $event): void 49 | { 50 | if ($record = $this->errorBag->rootError()) { 51 | $this->errorBag->flush(); 52 | try { 53 | $context = $record['context']; 54 | $context['tags'] = ['error:console', 'channel:' . $record['channel']]; 55 | if ($event->getOutput()->isDecorated()) { 56 | $context['tags'][] = 'tty'; 57 | } 58 | 59 | $this->logger->warning($record['message'], $context); 60 | } catch (\Exception $exception) {} 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/EventListener/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 25 | $this->skipCaptureService = $skipCaptureService; 26 | } 27 | 28 | /** 29 | * @param ExceptionEvent $event 30 | */ 31 | public function onKernelException(ExceptionEvent $event): void 32 | { 33 | $exception = $event->getThrowable(); 34 | if ($this->skipCaptureService->shouldExceptionCaptureBeSkipped($exception)) { 35 | return; 36 | } 37 | 38 | $this->captureException($exception, ['error:http']); 39 | } 40 | 41 | /** 42 | * @param ConsoleEvent $event 43 | */ 44 | public function onConsoleError(ConsoleErrorEvent $event) 45 | { 46 | $command = $event->getCommand(); 47 | $exception = $event->getError(); 48 | 49 | if ($this->skipCaptureService->shouldExceptionCaptureBeSkipped($exception) || $this->skipCaptureService->shouldMessageCaptureBeSkipped($command->getName())) { 50 | return; 51 | } 52 | 53 | $tags = ['command:' . str_replace(':', '_', $command->getName()), 'error:console']; 54 | if ($event->getOutput()->isDecorated()) { 55 | $tags[] = 'tty'; 56 | } 57 | 58 | $this->captureException($exception, $tags); 59 | } 60 | 61 | private function captureException(\Throwable $throwable, $tags) 62 | { 63 | $this->logger->error($throwable->getMessage(), ['exception' => $throwable, 'tags' => $tags]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/EventListener/ResponseTimeListener.php: -------------------------------------------------------------------------------- 1 | kernel = $kernel; 19 | $this->dogStats = $dogStats; 20 | } 21 | 22 | public function onKernelTerminate(): void 23 | { 24 | if (null !== $this->kernel) { 25 | if ($this->kernel->getStartTime() > 0) { 26 | $responseTime = round(microtime(true) - $this->kernel->getStartTime(), 4); 27 | } else { 28 | /** @var OkvpnDatadogBundle $datadogBundle */ 29 | $datadogBundle = $this->kernel->getBundle('OkvpnDatadogBundle'); 30 | $responseTime = round(microtime(true) - $datadogBundle->getStartTime(), 4); 31 | } 32 | 33 | $this->dogStats->timing('http_request', $responseTime); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Logging/ArtifactsStorageInterface.php: -------------------------------------------------------------------------------- 1 | errorBag = $errorBag; 25 | $this->skipCaptureService = $skipCaptureService; 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | protected function write($record): void 32 | { 33 | if ($record instanceof LogRecord) { 34 | $record = $record->toArray(); 35 | } 36 | 37 | $exception = false; 38 | if (isset($record) && \is_array($record['context'])) { 39 | foreach ($record['context'] as $value) { 40 | if ($value instanceof \Throwable) { 41 | $exception = $value; 42 | break; 43 | } 44 | } 45 | 46 | if ($exception && false === $this->skipCaptureService->shouldExceptionCaptureBeSkipped($exception) && false === $this->skipCaptureService->shouldMessageCaptureBeSkipped($record['message'])) { 47 | $this->errorBag->pushError($record); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Logging/DatadogSqlLogger.php: -------------------------------------------------------------------------------- 1 | statsd = $statsd; 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function startQuery($sql, array $params = null, array $types = null) 34 | { 35 | $this->queryStartTime = microtime(true); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function stopQuery() 42 | { 43 | $mtime = round(microtime(true) - $this->queryStartTime, 5) * 1000; 44 | $this->statsd->histogram('doctrine', $mtime); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Logging/DeduplicationDatadogLogger.php: -------------------------------------------------------------------------------- 1 | DogStatsInterface::ALERT_ERROR, 41 | LogLevel::ALERT => DogStatsInterface::ALERT_ERROR, 42 | LogLevel::CRITICAL => DogStatsInterface::ALERT_ERROR, 43 | LogLevel::ERROR => DogStatsInterface::ALERT_ERROR, 44 | LogLevel::WARNING => DogStatsInterface::ALERT_WARNING, 45 | LogLevel::NOTICE => DogStatsInterface::ALERT_INFO, 46 | LogLevel::INFO => DogStatsInterface::ALERT_INFO, 47 | LogLevel::DEBUG => DogStatsInterface::ALERT_INFO, 48 | ]; 49 | 50 | public function __construct(DogStatsInterface $statsd, ArtifactsStorageInterface $artifactStorage, ContextWatcherInterface $watcher, ContextDumperInterface $contextDumper, ExceptionHashService $exceptionHashService, string $deduplicationStore = null, int $deduplicationKeepTime = 86400 * 7) 51 | { 52 | $this->statsd = $statsd; 53 | $this->artifactStorage = $artifactStorage; 54 | $this->watcher = $watcher; 55 | $this->deduplicationKeepTime = $deduplicationKeepTime; 56 | $this->contextDumper = $contextDumper; 57 | $this->exceptionHashService = $exceptionHashService; 58 | $deduplicationFileName = '/datadog-dedup-' . substr(md5(__FILE__), 0, 8) .'.log'; 59 | $this->deduplicationStore = $deduplicationStore === null ? sys_get_temp_dir() . '/' . $deduplicationFileName : $deduplicationStore . '/' . $deduplicationFileName; 60 | $this->fs = new Filesystem(); 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function log($level, $message, array $context = []): void 67 | { 68 | $this->touch(); 69 | $context = array_merge( 70 | $context, 71 | $this->watcher->watch() 72 | ); 73 | $datadogEvent = $this->contextDumper->dumpContext($message, $context); 74 | if (null !== $datadogEvent->getCause()) { 75 | $this->statsd->increment('exception', 1, 1, $datadogEvent->getTags()); 76 | } 77 | $cause = $datadogEvent->getCause(); 78 | $causeCode = $cause instanceof \Throwable ? $this->exceptionHashService->hash($cause) : sha1($message); 79 | 80 | if (!$this->isDuplicate($causeCode)) { 81 | $this->appendRecord($datadogEvent, $causeCode); 82 | $message = $datadogEvent->getMessage(); 83 | try { 84 | $artifactUrl = $this->artifactStorage->save($datadogEvent->getFullMessage()); 85 | $message = sprintf("Artifact code: %s \n%s", $artifactUrl, $message); 86 | } catch (\Throwable $exception) {} 87 | 88 | $this->statsd->event( 89 | $datadogEvent->getTitle(), 90 | $message, 91 | [ 92 | 'time' => $datadogEvent->getDatetime(), 93 | 'alert' => $this->formatLevelMap[$level], 94 | ], 95 | $datadogEvent->getTags() 96 | ); 97 | } 98 | 99 | if ($this->gc) { 100 | $this->collectLogs(); 101 | } 102 | } 103 | 104 | /** 105 | * @return array 106 | */ 107 | public function clearDeduplicationStore() 108 | { 109 | $logs = $this->deduplicationLogs(); 110 | if ($logs) { 111 | file_put_contents($this->deduplicationStore, ''); 112 | @chmod($this->deduplicationStore, 0777); 113 | } 114 | 115 | return $logs; 116 | } 117 | 118 | /** 119 | * @return array 120 | */ 121 | public function deduplicationLogs() 122 | { 123 | if (!file_exists($this->deduplicationStore)) { 124 | return []; 125 | } 126 | 127 | $store = file($this->deduplicationStore, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 128 | return $store ?: []; 129 | } 130 | 131 | protected function isDuplicate(string $causeCode): bool 132 | { 133 | if (!file_exists($this->deduplicationStore)) { 134 | return false; 135 | } 136 | 137 | $store = file($this->deduplicationStore, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 138 | if (!is_array($store)) { 139 | return false; 140 | } 141 | 142 | $timestampValidity = time() - $this->deduplicationKeepTime; 143 | for ($i = count($store) - 1; $i >= 0; $i--) { 144 | if ($store[$i] && strpos($store[$i], ':') !== 10) { 145 | $this->gc = true; 146 | continue; 147 | } 148 | [$timestamp, $message] = explode(':', $store[$i], 3); 149 | 150 | if ($message === $causeCode && $timestamp > $timestampValidity) { 151 | return true; 152 | } 153 | 154 | if ($timestamp < $timestampValidity) { 155 | $this->gc = true; 156 | } 157 | } 158 | 159 | return false; 160 | } 161 | 162 | protected function collectLogs() 163 | { 164 | if (!file_exists($this->deduplicationStore)) { 165 | return; 166 | } 167 | 168 | $handle = fopen($this->deduplicationStore, 'rw+'); 169 | flock($handle, LOCK_EX); 170 | $validLogs = []; 171 | 172 | $timestampValidity = time() - $this->deduplicationKeepTime; 173 | 174 | while (!feof($handle)) { 175 | $log = fgets($handle); 176 | if ($log && substr($log, 0, 10) >= $timestampValidity) { 177 | $validLogs[] = $log; 178 | } 179 | } 180 | 181 | ftruncate($handle, 0); 182 | rewind($handle); 183 | foreach ($validLogs as $log) { 184 | fwrite($handle, $log); 185 | } 186 | 187 | flock($handle, LOCK_UN); 188 | fclose($handle); 189 | $this->gc = false; 190 | } 191 | 192 | protected function appendRecord(DatadogEvent $event, string $hash) 193 | { 194 | file_put_contents($this->deduplicationStore, $event->getDatetime() . ':' . $hash . ':' . preg_replace('{[\r\n].*}', '', $event->getShortMessage()) . "\n", FILE_APPEND); 195 | } 196 | 197 | protected function touch() 198 | { 199 | $path = dirname($this->deduplicationStore); 200 | if (!is_dir($path)) { 201 | mkdir($path, 0777, true); 202 | } 203 | 204 | if (!file_exists($this->deduplicationStore)) { 205 | file_put_contents($this->deduplicationStore, ''); 206 | @chmod($this->deduplicationStore, 0777); 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Logging/ErrorBag.php: -------------------------------------------------------------------------------- 1 | bufferSize = $bufferSize; 19 | } 20 | 21 | /** 22 | * @param array $record 23 | */ 24 | public function pushError(array $record): void 25 | { 26 | $this->errors[$this->currentIndex] = $record; 27 | $this->currentIndex = ($this->currentIndex+1) % $this->bufferSize; 28 | } 29 | 30 | public function flush(): void 31 | { 32 | $this->errors = []; 33 | $this->currentIndex = 0; 34 | } 35 | 36 | public function rootError(): ?array 37 | { 38 | return $this->errors[0] ?? null; 39 | } 40 | 41 | public function getErrors(): array 42 | { 43 | $sortErrors = []; 44 | for ($i = $this->currentIndex + $this->bufferSize - 1; $i >= 0; $i--) { 45 | $index = ($i % $this->bufferSize); 46 | if (!isset($this->errors[$index]) || count($sortErrors) >= $this->bufferSize) { 47 | break; 48 | } 49 | 50 | $sortErrors[] = $this->errors[$index]; 51 | } 52 | 53 | return $sortErrors; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Logging/LocalArtifactsStorage.php: -------------------------------------------------------------------------------- 1 | fs = new Filesystem(); 18 | $this->baseDir = $baseDir; 19 | } 20 | 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function save(string $content): string 25 | { 26 | $code = sha1(uniqid('', true)); 27 | $this->fs->dumpFile($this->filename($code), $content); 28 | 29 | return $code; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getContent(string $code): ?string 36 | { 37 | if ($this->fs->exists($this->filename($code))) { 38 | return file_get_contents($this->filename($code)); 39 | } 40 | 41 | return null; 42 | } 43 | 44 | private function filename(string $code): string 45 | { 46 | return sprintf('%s/%s%s.log', $this->baseDir, $this->prefix, $code); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Logging/Watcher/ContextWatcherInterface.php: -------------------------------------------------------------------------------- 1 | skipHeaders = $skipHttpHeaders; 26 | } 27 | 28 | /** 29 | * @param RequestStack $requestStack 30 | */ 31 | public function setRequestStack(RequestStack $requestStack = null): void 32 | { 33 | $this->requestStack = $requestStack; 34 | } 35 | 36 | /** 37 | * @param TokenStorageInterface $tokenStorage 38 | */ 39 | public function setTokenStorage(TokenStorageInterface $tokenStorage = null): void 40 | { 41 | $this->tokenStorage = $tokenStorage; 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public function watch(): array 48 | { 49 | $context = []; 50 | if ($this->tokenStorage instanceof TokenStorageInterface) { 51 | $token = $this->tokenStorage->getToken(); 52 | if (null !== $token) { 53 | $context['token'] = method_exists($token, '__toString') ? $token->__toString() : $token->serialize(); 54 | } 55 | } 56 | 57 | if ($this->requestStack instanceof RequestStack && $request = $this->requestStack->getCurrentRequest()) { 58 | $request = preg_replace("/\r\n/", "\n", (string) $request); 59 | foreach ($this->skipHeaders as $filteredHeader) { 60 | $request = preg_replace('#'. $filteredHeader .".+\n#i", '', $request); 61 | } 62 | $context['request'] = $request; 63 | } 64 | 65 | return $context; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/OkvpnDatadogBundle.php: -------------------------------------------------------------------------------- 1 | startTime = microtime(true); 22 | } 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function boot(): void 28 | { 29 | if (null === $this->startTime) { 30 | $this->startTime = microtime(true); 31 | } 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function shutdown(): void 38 | { 39 | $this->startTime = null; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function build(ContainerBuilder $container): void 46 | { 47 | $container->addCompilerPass(new SqlLoggerPass(['default'])); 48 | $container->addCompilerPass(new PushDatadogHandlerPass()); 49 | } 50 | 51 | /** 52 | * @return float|null 53 | */ 54 | public function getStartTime(): ?float 55 | { 56 | return $this->startTime; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Resources/config/listener.yml: -------------------------------------------------------------------------------- 1 | services: 2 | okvpn_datadog.exception_listener: 3 | class: Okvpn\Bundle\DatadogBundle\EventListener\ExceptionListener 4 | arguments: 5 | - '@okvpn_datadog.logger' 6 | - '@okvpn_datadog.services.skip_capture' 7 | tags: 8 | - { name: kernel.event_listener, event: kernel.exception, method: onKernelException } 9 | - { name: kernel.event_listener, event: console.error, method: onConsoleError } 10 | - { name: kernel.event_listener, event: console.exception, method: onConsoleError } 11 | 12 | okvpn_datadog.timming_http_listener: 13 | class: Okvpn\Bundle\DatadogBundle\EventListener\ResponseTimeListener 14 | arguments: ['@okvpn_datadog.client', '@?kernel'] 15 | tags: 16 | - { name: kernel.event_listener, event: kernel.terminate, method: onKernelTerminate } 17 | 18 | okvpn_datadog.flush_buffer.listener: 19 | class: Okvpn\Bundle\DatadogBundle\EventListener\DatadogFlushBufferListener 20 | arguments: ['@okvpn_datadog.logger', '@okvpn_datadog.error_bag'] 21 | tags: 22 | - { name: kernel.event_listener, event: kernel.terminate, method: onKernelTerminate } 23 | - { name: kernel.event_listener, event: console.terminate, method: onCliTerminate } 24 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | okvpn_datadog.monolog_channels: [] 3 | okvpn_datadog.logging: null 4 | okvpn_datadog.profiling: false 5 | 6 | services: 7 | okvpn_datadog.logger: 8 | class: Okvpn\Bundle\DatadogBundle\Logging\DeduplicationDatadogLogger 9 | public: true 10 | lazy: true 11 | arguments: 12 | - '@okvpn_datadog.client' 13 | - '@okvpn_datadog.logger.artifact_storage' 14 | - '@okvpn_datadog.logger.context_watcher' 15 | - '@okvpn_datadog.dumper.context' 16 | - '@okvpn_datadog.services.hash_exception' 17 | - null 18 | - null 19 | 20 | Okvpn\Bundle\DatadogBundle\Client\DatadogFactory: ~ 21 | 22 | Okvpn\Bundle\DatadogBundle\Client\DatadogFactoryInterface: 23 | alias: Okvpn\Bundle\DatadogBundle\Client\DatadogFactory 24 | 25 | okvpn_datadog.client: 26 | class: Okvpn\Bundle\DatadogBundle\Client\DatadogClient 27 | factory: ['@Okvpn\Bundle\DatadogBundle\Client\DatadogFactoryInterface', 'createClient'] 28 | arguments: [[]] 29 | public: true 30 | 31 | Okvpn\Bundle\DatadogBundle\Client\DogStatsInterface: 32 | alias: okvpn_datadog.client 33 | 34 | okvpn_datadog.logger.artifact_storage: 35 | class: Okvpn\Bundle\DatadogBundle\Logging\LocalArtifactsStorage 36 | arguments: ['%kernel.logs_dir%'] 37 | public: true 38 | 39 | okvpn_datadog.error_bag: 40 | class: Okvpn\Bundle\DatadogBundle\Logging\ErrorBag 41 | public: true 42 | 43 | okvpn_datadog.dumper.context: 44 | class: Okvpn\Bundle\DatadogBundle\Dumper\MonologContextDumper 45 | public: false 46 | 47 | okvpn_datadog.services.hash_exception: 48 | class: Okvpn\Bundle\DatadogBundle\Services\ExceptionHashService 49 | arguments: ['%kernel.cache_dir%'] 50 | public: false 51 | 52 | okvpn_datadog.services.skip_capture: 53 | class: Okvpn\Bundle\DatadogBundle\Services\SkipCaptureService 54 | arguments: ['@okvpn_datadog.services.hash_exception', ~] 55 | public: false 56 | 57 | okvpn_datadog.monolog.log_handler: 58 | class: Okvpn\Bundle\DatadogBundle\Logging\DatadogHandler 59 | public: false 60 | arguments: ['@okvpn_datadog.services.skip_capture', '@okvpn_datadog.error_bag'] 61 | 62 | okvpn_datadog.logger.context_watcher: 63 | class: Okvpn\Bundle\DatadogBundle\Logging\Watcher\DefaultWatcher 64 | public: false 65 | arguments: 66 | - ['Cookie:', 'X-Wsse:', 'Authorization:'] 67 | calls: 68 | - [setRequestStack, ['@?request_stack']] 69 | - [setTokenStorage, ['@?security.token_storage']] 70 | -------------------------------------------------------------------------------- /src/Resources/config/sqllogger.yml: -------------------------------------------------------------------------------- 1 | services: 2 | okvpn_datadog.logger.sql: 3 | class: Okvpn\Bundle\DatadogBundle\Logging\DatadogSqlLogger 4 | arguments: ['@okvpn_datadog.client'] 5 | public: false 6 | -------------------------------------------------------------------------------- /src/Resources/docs/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/1.png -------------------------------------------------------------------------------- /src/Resources/docs/consumers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/consumers.png -------------------------------------------------------------------------------- /src/Resources/docs/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/dashboard.png -------------------------------------------------------------------------------- /src/Resources/docs/exception.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/exception.png -------------------------------------------------------------------------------- /src/Resources/docs/jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/jira.png -------------------------------------------------------------------------------- /src/Resources/docs/telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okvpn/datadog-symfony/eadb1fee396debf308b1ccee343e1887d21e813e/src/Resources/docs/telegram.png -------------------------------------------------------------------------------- /src/Services/ExceptionHashService.php: -------------------------------------------------------------------------------- 1 | cacheDirPrefix = $cacheDirPrefix; 15 | } 16 | 17 | /** 18 | * This function returns a unique identifier for the exception. 19 | * This id can be used as a hash key for find duplicate exceptions 20 | * 21 | * @param \Throwable $exception 22 | * @return string 23 | */ 24 | public function hash(\Throwable $exception): string 25 | { 26 | $hash = ''; 27 | $trace = $exception->getTrace(); 28 | $trace[] = [ 29 | 'file' => $exception->getFile(), 30 | 'line' => $exception->getLine() 31 | ]; 32 | 33 | foreach ($trace as $place) { 34 | if (isset($place['file'], $place['line']) && $place['file'] && $place['line'] > 0 && strpos($place['file'], $this->cacheDirPrefix) === false) { 35 | $hash .= $place['file'] . ':' . $place['line'] . "\n"; 36 | } 37 | } 38 | 39 | return sha1($hash); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Services/SkipCaptureService.php: -------------------------------------------------------------------------------- 1 | hashService = $hashService; 34 | $this->skipCapture = $skipConfig['skip_capture'] ?? []; 35 | $this->skipCommand = $skipConfig['skip_command'] ?? []; 36 | $this->skipInstanceof = $skipConfig['skip_instanceof'] ?? []; 37 | $this->skipHash = $skipConfig['skip_hash'] ?? []; 38 | $this->skipWildcard = $skipConfig['skip_wildcard'] ?? []; 39 | } 40 | 41 | /** 42 | * Check that exception should be skip 43 | * 44 | * @param \Throwable $exception 45 | * @return bool 46 | */ 47 | public function shouldExceptionCaptureBeSkipped(\Throwable $exception): bool 48 | { 49 | if (in_array(get_class($exception), $this->skipCapture, true)) { 50 | return true; 51 | } 52 | if ($this->skipHash && in_array($this->hashService->hash($exception), $this->skipHash, true)) { 53 | return true; 54 | } 55 | foreach ($this->skipInstanceof as $class) { 56 | if ($exception instanceof $class) { 57 | return true; 58 | } 59 | } 60 | 61 | if (function_exists('fnmatch')) { 62 | $message = $exception->getMessage(); 63 | foreach ($this->skipWildcard as $wildcard) { 64 | if (fnmatch($wildcard, $message)) { 65 | return true; 66 | } 67 | } 68 | } 69 | 70 | return false; 71 | } 72 | 73 | /** 74 | * Check that message or command should be skip 75 | * 76 | * @param string $message 77 | * @return bool 78 | */ 79 | public function shouldMessageCaptureBeSkipped(string $message): bool 80 | { 81 | if (function_exists('fnmatch')) { 82 | foreach ($this->skipWildcard as $wildcard) { 83 | if (fnmatch($wildcard, $message)) { 84 | return true; 85 | } 86 | } 87 | } 88 | if (in_array($message, $this->skipCapture, true)) { 89 | return true; 90 | } 91 | 92 | if (in_array($message, $this->skipCommand, true)) { 93 | return true; 94 | } 95 | return false; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Stream/UdpStreamWriter.php: -------------------------------------------------------------------------------- 1 |