diff --git a/README.md b/README.md index e4073e1..0165cf1 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ use OpenTracing\GlobalTracer; $config = new Config( [ 'sampler' => [ - 'type' => 'const', + 'type' => Jaeger\SAMPLER_TYPE_CONST, 'param' => true, ], 'logging' => true, @@ -48,6 +48,52 @@ $scope->close(); $tracer->flush(); ``` +### Samplers + +List of supported samplers, for more info about samplers, please read [Jaeger Sampling](https://www.jaegertracing.io/docs/1.9/sampling/) guide. + +#### Const sampler +This sampler either samples everything, or nothing. + +##### Configuration +``` +'sampler' => [ + 'type' => Jaeger\SAMPLER_TYPE_CONST, + 'param' => true, // boolean wheter to trace or not +], +``` + +#### Probabilistic sampler +This sampler samples request by given rate. + +##### Configuration +``` +'sampler' => [ + 'type' => Jaeger\SAMPLER_TYPE_PROBABILISTIC, + 'param' => 0.5, // float [0.0, 1.0] +], +``` + +#### Rate limiting sampler +Samples maximum specified number of traces (requests) per second. + +##### Requirements +* `psr/cache` PSR-6 cache component to store and retrieve sampler state between requests. +Cache component is passed to `Jaeger\Config` trough its constructor. +* `hrtime()` function, that can retrieve time in nanoseconds. You need either `php 7.3` or [PECL/hrtime](http://pecl.php.net/package/hrtime) extension. + +##### Configuration +``` +'sampler' => [ + 'type' => Jaeger\SAMPLER_TYPE_RATE_LIMITING, + 'param' => 100 // integer maximum number of traces per second, + 'cache' => [ + 'currentBalanceKey' => 'rate.currentBalance' // string + 'lastTickKey' => 'rate.lastTick' // string + ] +], +``` + ## Testing Tests are located in the `tests` directory. See [tests/README.md](./tests/README.md). diff --git a/composer.json b/composer.json index 929f589..bf4c1cf 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "opentracing/opentracing": "1.0.0-beta5", "packaged/thrift": "^0.10", "phlib/base_convert": "^1.0", + "psr/cache": "^1.0", "psr/log": "^1.0" }, "provide": { @@ -35,7 +36,9 @@ }, "require-dev": { "phpunit/phpunit": "^6.4", - "squizlabs/php_codesniffer": "3.*" + "squizlabs/php_codesniffer": "3.*", + "cache/array-adapter": "^1.0", + "symfony/polyfill-php73": "^1.10" }, "config": { "optimize-autoloader": true, diff --git a/src/Jaeger/Codec/TextCodec.php b/src/Jaeger/Codec/TextCodec.php index ad9b6b7..3114560 100644 --- a/src/Jaeger/Codec/TextCodec.php +++ b/src/Jaeger/Codec/TextCodec.php @@ -130,7 +130,7 @@ public function extract($carrier) return null; } - return new SpanContext($traceId, $spanId, $parentId, $flags); + return new SpanContext($traceId, $spanId, $parentId, $flags, $baggage); } /** diff --git a/src/Jaeger/Config.php b/src/Jaeger/Config.php index 01703f0..a450452 100644 --- a/src/Jaeger/Config.php +++ b/src/Jaeger/Config.php @@ -9,10 +9,13 @@ use Jaeger\Reporter\ReporterInterface; use Jaeger\Sampler\ConstSampler; use Jaeger\Sampler\ProbabilisticSampler; +use Jaeger\Sampler\RateLimitingSampler; use Jaeger\Sampler\SamplerInterface; use Jaeger\Sender\UdpSender; use Jaeger\Thrift\Agent\AgentClient; +use Jaeger\Util\RateLimiter; use OpenTracing\GlobalTracer; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Thrift\Exception\TTransportException; @@ -41,15 +44,25 @@ class Config */ private $logger; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** * Config constructor. * @param array $config * @param string|null $serviceName * @param LoggerInterface|null $logger + * @param CacheItemPoolInterface|null $cache * @throws Exception */ - public function __construct(array $config, string $serviceName = null, LoggerInterface $logger = null) - { + public function __construct( + array $config, + string $serviceName = null, + LoggerInterface $logger = null, + CacheItemPoolInterface $cache = null + ) { $this->config = $config; $this->serviceName = $config['service_name'] ?? $serviceName; @@ -58,6 +71,7 @@ public function __construct(array $config, string $serviceName = null, LoggerInt } $this->logger = $logger ?: new NullLogger(); + $this->cache = $cache; } /** @@ -143,6 +157,7 @@ private function getReporter(): ReporterInterface /** * @return SamplerInterface + * @throws \Psr\Cache\InvalidArgumentException * @throws Exception */ private function getSampler(): SamplerInterface @@ -157,8 +172,20 @@ private function getSampler(): SamplerInterface return new ConstSampler($samplerParam ?? false); } elseif ($samplerType === SAMPLER_TYPE_PROBABILISTIC) { return new ProbabilisticSampler((float)$samplerParam); + } elseif ($samplerType === SAMPLER_TYPE_RATE_LIMITING) { + if (!$this->cache) { + throw new Exception('You cannot use RateLimitingSampler without cache component'); + } + $cacheConfig = $samplerConfig['cache'] ?? []; + return new RateLimitingSampler( + $samplerParam ?? 0, + new RateLimiter( + $this->cache, + $cacheConfig['currentBalanceKey'] ?? 'rate.currentBalance', + $cacheConfig['lastTickKey'] ?? 'rate.lastTick' + ) + ); } - throw new Exception('Unknown sampler type ' . $samplerType); } diff --git a/src/Jaeger/Sampler/RateLimitingSampler.php b/src/Jaeger/Sampler/RateLimitingSampler.php new file mode 100644 index 0000000..d0ea0f1 --- /dev/null +++ b/src/Jaeger/Sampler/RateLimitingSampler.php @@ -0,0 +1,62 @@ +tags = [ + SAMPLER_TYPE_TAG_KEY => SAMPLER_TYPE_RATE_LIMITING, + SAMPLER_PARAM_TAG_KEY => $maxTracesPerSecond, + ]; + + $maxTracesPerNanosecond = $maxTracesPerSecond / 1000000000.0; + $this->rateLimiter = $rateLimiter; + $this->rateLimiter->initialize($maxTracesPerNanosecond, $maxTracesPerSecond > 1.0 ? 1.0 : $maxTracesPerSecond); + } + + /** + * Whether or not the new trace should be sampled. + * + * Implementations should return an array in the format [$decision, $tags]. + * + * @param string $traceId The traceId on the span. + * @param string $operation The operation name set on the span. + * @return array + */ + public function isSampled(string $traceId = '', string $operation = '') + { + return [$this->rateLimiter->checkCredit(1.0), $this->tags]; + } + + /** + * {@inheritdoc} + * + * Only implemented to satisfy the sampler interface. + * + * @return void + */ + public function close() + { + // nothing to do + } +} diff --git a/src/Jaeger/Tracer.php b/src/Jaeger/Tracer.php index fe9338e..cb344a2 100644 --- a/src/Jaeger/Tracer.php +++ b/src/Jaeger/Tracer.php @@ -273,6 +273,7 @@ public function extract($format, $carrier) */ public function flush() { + $this->sampler->close(); $this->reporter->close(); } diff --git a/src/Jaeger/Util/RateLimiter.php b/src/Jaeger/Util/RateLimiter.php new file mode 100644 index 0000000..d767ad4 --- /dev/null +++ b/src/Jaeger/Util/RateLimiter.php @@ -0,0 +1,128 @@ +cache = $cache; + $this->balance = $this->cache->getItem($currentBalanceKey); + $this->lastTick = $this->cache->getItem($lastTickKey); + } + + /** + * @param $itemCost + * @return bool + */ + public function checkCredit($itemCost) + { + if (!$this->creditsPerNanosecond) { + return false; + } + + list($lastTick, $balance) = $this->getState(); + + if (!$lastTick) { + $this->saveState(hrtime(true), 0); + return true; + } + + $currentTick = hrtime(true); + $elapsedTime = $currentTick - $lastTick; + $balance += $elapsedTime * $this->creditsPerNanosecond; + if ($balance > $this->maxBalance) { + $balance = $this->maxBalance; + } + + $result = false; + if ($balance >= $itemCost) { + $balance -= $itemCost; + $result = true; + } + + $this->saveState($currentTick, $balance); + + return $result; + } + + + /** + * Initializes limiter costs and boundaries + * + * @param float $creditsPerNanosecond + * @param float $maxBalance + */ + public function initialize(float $creditsPerNanosecond, float $maxBalance) + { + $this->creditsPerNanosecond = $creditsPerNanosecond; + $this->maxBalance = $maxBalance; + } + + /** + * Method loads last tick and current balance from cache + * + * @return array [$lastTick, $balance] + */ + private function getState() : array + { + return [ + $this->lastTick->get(), + $this->balance->get() + ]; + } + + /** + * Method saves last tick and current balance into cache + * + * @param integer $lastTick + * @param float $balance + */ + private function saveState($lastTick, $balance) + { + $this->lastTick->set($lastTick); + $this->balance->set($balance); + $this->cache->saveDeferred($this->lastTick); + $this->cache->saveDeferred($this->balance); + $this->cache->commit(); + } +} diff --git a/tests/Jaeger/Codec/TextCodecTest.php b/tests/Jaeger/Codec/TextCodecTest.php index 754ef52..143d2f6 100644 --- a/tests/Jaeger/Codec/TextCodecTest.php +++ b/tests/Jaeger/Codec/TextCodecTest.php @@ -2,8 +2,11 @@ namespace Jaeger\Tests\Codec; +use const Jaeger\BAGGAGE_HEADER_PREFIX; use Jaeger\Codec\TextCodec; +use const Jaeger\DEBUG_ID_HEADER_KEY; use Jaeger\SpanContext; +use const Jaeger\TRACE_ID_HEADER; use PHPUnit\Framework\TestCase; class TextCodecTest extends TestCase @@ -11,34 +14,128 @@ class TextCodecTest extends TestCase /** @var TextCodec */ private $textCodec; - /** @var SpanContext */ - private $ctx; - public function setUp() { - $this->ctx = new SpanContext('trace-id', 'span-id', null, null); $this->textCodec = new TextCodec(); } - public function testCanInjectContextInCarrier() + public function testCanInjectSimpleContextInCarrier() { + $context = new SpanContext('trace-id', 'span-id', null, null); $carrier = []; - $this->textCodec->inject($this->ctx, $carrier); + $this->textCodec->inject($context, $carrier); - $this->assertFalse(empty($carrier)); + $this->assertCount(1 , $carrier); + $this->assertArrayHasKey(TRACE_ID_HEADER, $carrier); } - public function testSpanContextParsingFromHeader() + /** + * @dataProvider contextDataProvider + * @param bool $urlEncode + * @param $baggage + */ + public function testCanInjectContextBaggageInCarrier(bool $urlEncode, $baggage, $injectedBaggage) { - $carrier = ['uber-trace-id' => '32834e4115071776:f7802330248418d:f123456789012345:1']; + $carrier = []; - $spanContext = $this->textCodec->extract($carrier); + $context = new SpanContext('trace-id', 'span-id', null, null, $baggage); + $textCodec = new TextCodec($urlEncode); + $textCodec->inject($context, $carrier); + + $this->assertCount(1 + count($baggage) , $carrier); + $this->assertArrayHasKey(TRACE_ID_HEADER, $carrier); + foreach ($injectedBaggage as $key => $value) { + $this->assertArrayHasKey(BAGGAGE_HEADER_PREFIX . $key, $carrier); + $this->assertEquals($carrier[BAGGAGE_HEADER_PREFIX . $key], $value); + } + } + + public function contextDataProvider() + { + return [ + [false, ['baggage-1' => 'baggage value'], ['baggage-1' => 'baggage value']], + [false, ['baggage-1' => 'https://testdomain.sk'], ['baggage-1' => 'https://testdomain.sk']], + [true, ['baggage-1' => 'https://testdomain.sk'], ['baggage-1' => 'https%3A%2F%2Ftestdomain.sk']], + ]; + } + + /** + * @dataProvider carrierDataProvider + * @param $urlEncode + * @param $carrier + * @param $traceId + * @param $spanId + * @param $parentId + * @param $flags + * @param $baggage + * @throws \Exception + */ + public function testSpanContextParsingFromHeader($urlEncode, $carrier, $traceId, $spanId, $parentId, $flags, $baggage) + { + $textCodec = new TextCodec($urlEncode); + $spanContext = $textCodec->extract($carrier); - self::assertEquals("3639838965278119798", $spanContext->getTraceId()); - self::assertEquals("1114643325879075213", $spanContext->getSpanId()); - self::assertEquals("-1070935975401544891", $spanContext->getParentId()); - self::assertEquals(1, $spanContext->getFlags()); + $this->assertEquals($traceId, $spanContext->getTraceId()); + $this->assertEquals($spanId, $spanContext->getSpanId()); + $this->assertEquals($parentId, $spanContext->getParentId()); + $this->assertEquals($flags, $spanContext->getFlags()); + $this->assertCount(count($baggage), $spanContext->getBaggage() ? $spanContext->getBaggage() : []); + foreach ($baggage as $key => $value) { + $this->assertEquals($value, $spanContext->getBaggageItem($key)); + } + } + + public function carrierDataProvider() + { + return [ + [ + false, + [ + TRACE_ID_HEADER => '32834e4115071776:f7802330248418d:f123456789012345:1' + ], + "3639838965278119798", + "1114643325879075213", + "-1070935975401544891", + 1, + [] + ], + [ + false, + [ + TRACE_ID_HEADER => '32834e4115071776:f7802330248418d:f123456789012345:1', + BAGGAGE_HEADER_PREFIX . 'baggage-1' => 'https://testdomain.sk', + ], + "3639838965278119798", + "1114643325879075213", + "-1070935975401544891", + 1, + ['baggage-1' => 'https://testdomain.sk'] + ], + [ + true, + [ + TRACE_ID_HEADER => '32834e4115071776:f7802330248418d:f123456789012345:1', + BAGGAGE_HEADER_PREFIX . 'baggage-1' => 'https%3A%2F%2Ftestdomain.sk', + ], + "3639838965278119798", + "1114643325879075213", + "-1070935975401544891", + 1, + ['baggage-1' => 'https://testdomain.sk'] + ] + ]; + } + + /** + * @expectedException \Exception + * @expectedExceptionMessage baggage without trace ctx + */ + public function testBaggageWithoutTraceContext() + { + $carrier = [BAGGAGE_HEADER_PREFIX.'test' => 'some data']; + + $this->textCodec->extract($carrier); } /** @@ -47,8 +144,28 @@ public function testSpanContextParsingFromHeader() */ public function testInvalidSpanContextParsingFromHeader() { - $carrier = ['uber-trace-id' => 'invalid_data']; + $carrier = [TRACE_ID_HEADER => 'invalid_data']; $this->textCodec->extract($carrier); } + + public function testExtractDebugSpanContext() + { + $carrier = [DEBUG_ID_HEADER_KEY => 'debugId']; + + $spanContext = $this->textCodec->extract($carrier); + + $this->assertEquals('debugId', $spanContext->getDebugId()); + $this->assertNull($spanContext->getTraceId()); + $this->assertNull($spanContext->getSpanId()); + $this->assertNull($spanContext->getParentId()); + $this->assertNull($spanContext->getFlags()); + } + + + public function testExtractEmptySpanContext() + { + $spanContext = $this->textCodec->extract([]); + $this->assertNull($spanContext); + } } diff --git a/tests/Jaeger/ConfigTest.php b/tests/Jaeger/ConfigTest.php index 5d05717..473653f 100644 --- a/tests/Jaeger/ConfigTest.php +++ b/tests/Jaeger/ConfigTest.php @@ -80,4 +80,31 @@ public function shouldSetGlobalTracerAfterInitialize() $tracer = GlobalTracer::get(); $this->assertInstanceOf(Tracer::class, $tracer); } + + /** + * @test + * @expectedException Exception + * @expectedExceptionMessage Unknown sampler type unsupportedSampler + */ + public function shouldThrowExceptionWhenCreatingNotSupportedSampler() + { + $config = new Config(['service_name' => 'test-service-name', 'sampler' => ['type' => 'unsupportedSampler']]); + + $config->initializeTracer(); + } + + /** + * @test + * @expectedException Exception + * @expectedExceptionMessage You cannot use RateLimitingSampler without cache component + */ + public function shouldThrowExceptionWhenCreatingRateLimitingSamplerWithoutCacheComponent() + { + $config = new Config([ + 'service_name' => 'test-service-name', + 'sampler' => ['type' => \Jaeger\SAMPLER_TYPE_RATE_LIMITING]] + ); + + $config->initializeTracer(); + } } diff --git a/tests/Jaeger/Sampler/RateLimitSamplerTest.php b/tests/Jaeger/Sampler/RateLimitSamplerTest.php new file mode 100644 index 0000000..2190245 --- /dev/null +++ b/tests/Jaeger/Sampler/RateLimitSamplerTest.php @@ -0,0 +1,48 @@ +isSampled(); + list($sampled, $tags) = $sampler->isSampled(); + $this->assertEquals($decision, $sampled); + $this->assertEquals([ + SAMPLER_TYPE_TAG_KEY => SAMPLER_TYPE_RATE_LIMITING, + SAMPLER_PARAM_TAG_KEY => $maxTracesPerSecond, + ], $tags); + + $sampler->close(); + } + + public function maxRateProvider() + { + return [ + [1000000, true], + [1, false], + [0, false], + ]; + } +} \ No newline at end of file diff --git a/tests/php-test.sh b/tests/php-test.sh index 05eeb6b..99a5228 100755 --- a/tests/php-test.sh +++ b/tests/php-test.sh @@ -24,6 +24,8 @@ apt-get install -yq git wget unzip zip > /dev/null 2>&1 echo "[INFO]: Install PHP extensions..." docker-php-ext-install bcmath sockets > /dev/null 2>&1 +pecl install hrtime > /dev/null 2>&1 +docker-php-ext-enable hrtime > /dev/null 2>&1 echo "[INFO]: Install Xdebug to enable code coverage..." pecl install xdebug > /dev/null 2>&1