diff --git a/.gitignore b/.gitignore index 481b3f4..b0f7117 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ vendor/* composer.lock .php-cs-fixer.cache build -.phpunit.cache \ No newline at end of file +.phpunit.cache +examples/log_datadog.txt diff --git a/README.md b/README.md index 80edeca..38d0503 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ [![Latest Stable Version](http://poser.pugx.org/cosmastech/statsd-client-adapter/v)](https://packagist.org/packages/cosmastech/statsd-client-adapter) [![Total Downloads](http://poser.pugx.org/cosmastech/statsd-client-adapter/downloads)](https://packagist.org/packages/cosmastech/statsd-client-adapter) [![License](http://poser.pugx.org/cosmastech/statsd-client-adapter/license)](https://packagist.org/packages/cosmastech/statsd-client-adapter) [![PHP Version Require](http://poser.pugx.org/cosmastech/statsd-client-adapter/require/php)](https://packagist.org/packages/cosmastech/statsd-client-adapter) + + # StatsD Client Adapter This package was originally designed to solve the problem of: * I use DataDog on production, but * I don't want to push stats to DataDog on my dev or test environments -Where might I want to push those precious stats? Maybe to a log? Maybe to a locally running [StatsD server](https://github.com/statsd/statsd)? What if in my unit tests, I want to confirm that logs are being pushed, but not go through the hassle of an integration test set up that configures the StatsD server? +Where might I want to push those precious stats? Maybe to a log? Maybe to a locally running [StatsD server](https://github.com/statsd/statsd)? +What if in my unit tests, I want to confirm that logs are being pushed, but not go through the hassle of an integration +test set up that configures the StatsD server? While [PHP League's statsd package](https://github.com/thephpleague/statsd) is great, it doesn't allow for sending DataDog specific stats (such as [histogram](https://docs.datadoghq.com/metrics/types/?tab=histogram) or [distribution](https://docs.datadoghq.com/metrics/types/?tab=distribution)). @@ -12,6 +16,56 @@ Nor does the DataDog client allow for pushing to another StatsD implementation e The aim here is to allow for a single interface that can wrap around both, and be easily extended for different implementations. + +## Adapters + +### InMemoryClientAdapter +This adapter simply records your stats in an object in memory. This is best served as a way to verify stats are recorded in your unit tests. + +See [examples/in_memory.php](examples/in_memory.php) for how you might implement this. + +### DataDogStatsDClientAdapter +This is a wrapper around DataDog's [php-datadogstatsd](https://github.com/dataDog/php-datadogstatsd/) client. + +If you wish to use this adapter, please make sure you install the php-datadogstatsd client. + +```shell +composer require datadog/php-datadogstatsd +``` + +For specifics on their configuration, see the [official DogStatsD documentation](https://docs.datadoghq.com/developers/dogstatsd/?code-lang=php&tab=hostagent#client-instantiation-parameters). + +See [examples/datadog.php](examples/datadog.php) for how you might implement this. + +### DatadogLoggingClient +Envisioned as a client for local development, this adapter writes to a class which implements the [psr-logger interface](https://packagist.org/packages/psr/log). +You can find a [list](https://packagist.org/providers/psr/log-implementation) of packages that implement the interface on packagist. +If you are using a framework like Symfony or Laravel, then you already have one of the most popular and reliable implementations installed: [monolog/monolog](https://github.com/Seldaek/monolog). + +For a local development setup, you could just write the stats to a log. This writes the format exactly as it would be sent to DataDog. + +See [examples/log_datadog.php](examples/log_datadog.php) for how you might implement this. + +### LeagueStatsDClientAdapter +You can also write to an arbitrary statsd server by leveraging [PHP League's statsd package](https://github.com/thephpleague/statsd). + +First ensure that the package has been installed. +```shell +composer require league/statsd +``` + +For information on how to configure Client, [read their documentation](https://github.com/thephpleague/statsd?tab=readme-ov-file#configuring). + +**Note** the `histogram()` and `distribution()` methods are both no-op by default, as they are not available on statsd. + +See [examples/league.php](examples/league.php) for how you might implement this. + ## Gotchas -1. Only increment/decrement on PHPLeague's implementation allow for including the sample rate. If you are using a sample rate with other calls, their sample rate will not be included as part of the stat. -2. There are `histogram()` and `distribution()` methods on `LeagueStatsDClientAdapter`, but they only raise a PHP error and are no-op. +1. Only increment/decrement on DataDog's implementation allow for including the sample rate. If you are using a sample rate with other calls, their sample rate will not be included as part of the stat. +2. There are `histogram()` and `distribution()` methods on `LeagueStatsDClientAdapter`, but they will not be sent to statsd. + + +## Testing +```shell +composer test +``` diff --git a/composer.json b/composer.json index 2acbb6a..2c17043 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,6 @@ ], "require": { "php": "^8.2", - "datadog/php-datadogstatsd": "^1.6.1", "psr/clock": "^1.0.0", "psr/log": "^3.0.0" }, @@ -19,7 +18,8 @@ "phpunit/phpunit": "^11.2.5", "friendsofphp/php-cs-fixer": "^3.59", "league/statsd": "^2.0.0", - "cosmastech/psr-logger-spy": "^0.0.2" + "cosmastech/psr-logger-spy": "^0.0.2", + "datadog/php-datadogstatsd": "^1.6.1" }, "suggest": { "datadog/php-datadogstatsd": "For DataDog stats", diff --git a/examples/datadog.php b/examples/datadog.php new file mode 100644 index 0000000..c193389 --- /dev/null +++ b/examples/datadog.php @@ -0,0 +1,13 @@ +histogram("my-histogram", 11.2); + +// Check DataDog and see that this histogram is recorded diff --git a/examples/in_memory.php b/examples/in_memory.php new file mode 100644 index 0000000..ba07845 --- /dev/null +++ b/examples/in_memory.php @@ -0,0 +1,53 @@ +setDefaultTags(["app_version" => "2.83.0"]); // Set this for tags you want included in all stats. + +$startTimeInMs = timeInMilliseconds(); +makeApiRequest(); + +$inMemoryAdapter->timing("api-response", timeInMilliseconds() - $startTimeInMs, 1.0, ["source" => "github"]); + +/** @var \Cosmastech\StatsDClientAdapter\Adapters\InMemory\Models\InMemoryStatsRecord $stats */ +$stats = $inMemoryAdapter->getStats(); + +var_dump($stats->timing[0]); +/* +object(Cosmastech\StatsDClientAdapter\Adapters\InMemory\Models\InMemoryTimingRecord)#7 (5) { + ["stat"]=> + string(12) "api-response" + ["durationMilliseconds"]=> + float(2000) + ["sampleRate"]=> + float(1) + ["tags"]=> + array(2) { + ["app_version"]=> + string(6) "2.83.0" + ["source"]=> + string(6) "github" + } + ["recordedAt"]=> + object(DateTimeImmutable)#8 (3) { + ["date"]=> + string(26) "2024-07-08 22:25:53.080522" + ["timezone_type"]=> + int(3) + ["timezone"]=> + string(3) "UTC" + } +} +*/ diff --git a/examples/league.php b/examples/league.php new file mode 100644 index 0000000..b76cf8f --- /dev/null +++ b/examples/league.php @@ -0,0 +1,16 @@ + '127.0.0.1', + 'port' => 8125, + 'namespace' => 'example', +]); + +$adapter->gauge("my-stat", 1.1); + +// Confirm in your statsd daemon that the gauge was logged diff --git a/examples/log_datadog.php b/examples/log_datadog.php new file mode 100644 index 0000000..8d66dc0 --- /dev/null +++ b/examples/log_datadog.php @@ -0,0 +1,19 @@ +pushHandler(new \Monolog\Handler\StreamHandler(__DIR__ . '/log_datadog.txt', \Monolog\Level::Debug)); + +$datadog = new \Cosmastech\StatsDClientAdapter\Clients\Datadog\DatadogLoggingClient($logger); + +$adapter = new \Cosmastech\StatsDClientAdapter\Adapters\Datadog\DatadogStatsDClientAdapter($datadog); + +$adapter->increment("logins", 1, ["type" => "successful"], 1); + +// You should see a file named log_datadog.txt in this directory which will have stats +// ex: [2024-07-08T23:59:18.880180+00:00] log_datadog.DEBUG: logins:1|c|#type:successful [] [] diff --git a/src/Adapters/League/LeagueStatsDClientAdapter.php b/src/Adapters/League/LeagueStatsDClientAdapter.php index 5827009..54631f6 100644 --- a/src/Adapters/League/LeagueStatsDClientAdapter.php +++ b/src/Adapters/League/LeagueStatsDClientAdapter.php @@ -2,10 +2,13 @@ namespace Cosmastech\StatsDClientAdapter\Adapters\League; +use Closure; use Cosmastech\StatsDClientAdapter\Adapters\Concerns\HasDefaultTagsTrait; use Cosmastech\StatsDClientAdapter\Adapters\Concerns\TagNormalizerAwareTrait; use Cosmastech\StatsDClientAdapter\Adapters\Contracts\TagNormalizerAware; use Cosmastech\StatsDClientAdapter\Adapters\StatsDClientAdapter; +use Cosmastech\StatsDClientAdapter\TagNormalizers\NoopTagNormalizer; +use Cosmastech\StatsDClientAdapter\TagNormalizers\TagNormalizer; use Cosmastech\StatsDClientAdapter\Utility\SampleRateDecider\Contracts\SampleRateSendDecider as SampleRateSendDeciderInterface; use Cosmastech\StatsDClientAdapter\Utility\SampleRateDecider\SampleRateSendDecider; use League\StatsD\Client; @@ -18,12 +21,19 @@ class LeagueStatsDClientAdapter implements StatsDClientAdapter, TagNormalizerAwa use HasDefaultTagsTrait; use TagNormalizerAwareTrait; + /** + * @var Closure(string, float, float, array):void + */ + protected Closure $unavailableStatHandler; + public function __construct( protected readonly LeagueStatsDClientInterface $leagueStatsDClient, - protected readonly SampleRateSendDeciderInterface $sampleRateSendDecider, + protected readonly SampleRateSendDeciderInterface $sampleRateSendDecider = new SampleRateSendDecider(), array $defaultTags = [], + TagNormalizer $tagNormalizer = new NoopTagNormalizer(), ) { $this->setDefaultTags($defaultTags); + $this->setTagNormalizer($tagNormalizer); } /** @@ -45,6 +55,35 @@ public static function fromConfig( ); } + /** + * @param Closure(string, float, float, array):void $closure + * @return self + */ + public function setUnavailableStatHandler(Closure $closure): self + { + $this->unavailableStatHandler = $closure; + + return $this; + } + + protected function handleUnavailableStat( + string $stat, + float $value, + float $sampleRate = 1.0, + array $tags = [] + ): void { + $this->getUnavailableStatHandler()($stat, $value, $sampleRate, $tags); + } + + /** + * @return Closure(string, float, float, array):void + */ + protected function getUnavailableStatHandler(): Closure + { + return $this->unavailableStatHandler ?? function (): void {}; + } + + /** * @throws ConnectionException */ @@ -79,12 +118,12 @@ public function gauge(string $stat, float $value, float $sampleRate = 1.0, array public function histogram(string $stat, float $value, float $sampleRate = 1.0, array $tags = []): void { - trigger_error("histogram is not implemented for this client"); + $this->handleUnavailableStat($stat, $value, $sampleRate, $tags); } public function distribution(string $stat, float $value, float $sampleRate = 1.0, array $tags = []): void { - trigger_error("distribution is not implemented for this client"); + $this->handleUnavailableStat($stat, $value, $sampleRate, $tags); } /** diff --git a/tests/Adapters/League/LeagueStatsDClientTest.php b/tests/Adapters/League/LeagueStatsDClientTest.php index 5be1f62..afd6332 100644 --- a/tests/Adapters/League/LeagueStatsDClientTest.php +++ b/tests/Adapters/League/LeagueStatsDClientTest.php @@ -9,16 +9,48 @@ class LeagueStatsDClientTest extends BaseTestCase { + protected array $args; + protected function setUp(): void + { + parent::setUp(); + + $this->args = []; + } + #[Test] public function getClient_returnsLeagueStatsDClient(): void { // Given - $leagueStatsDClient = LeagueStatsDClientAdapter::fromConfig([]); + $leagueStatsDClientAdapter = LeagueStatsDClientAdapter::fromConfig([]); // When - $client = $leagueStatsDClient->getClient(); + $client = $leagueStatsDClientAdapter->getClient(); // Then self::assertInstanceOf(StatsDClient::class, $client); } + + #[Test] + public function setUnavailableStatHandler_histogram_callsClosure(): void + { + // Given + $leagueStatsDClientAdapter = LeagueStatsDClientAdapter::fromConfig([]); + + // And + $leagueStatsDClientAdapter->setUnavailableStatHandler($this->saveArgs(...)); + + // When + $leagueStatsDClientAdapter->histogram("some-stat", 12, 0.1, ["my_tag" => true]); + + // Then + self::assertEquals("some-stat", $this->args[0]); + self::assertEquals(12, $this->args[1]); + self::assertEquals(0.1, $this->args[2]); + self::assertEquals(["my_tag" => true], $this->args[3]); + } + + private function saveArgs(): void + { + $this->args = func_get_args(); + } }