diff --git a/contrib/Otlp/Exporter.php b/contrib/Otlp/Exporter.php new file mode 100644 index 000000000..2cee29d11 --- /dev/null +++ b/contrib/Otlp/Exporter.php @@ -0,0 +1,159 @@ +endpointURL = getenv('OTEL_EXPORTER_OTLP_ENDPOINT') ?: 'localhost:55680'; + $this->protocol = getenv('OTEL_EXPORTER_OTLP_PROTOCOL') ?: 'json'; + $this->insecure = getenv('OTEL_EXPORTER_OTLP_INSECURE') ?: 'false'; + $this->certificateFile = getenv('OTEL_EXPORTER_OTLP_CERTIFICATE') ?: 'none'; + $this->headers[] = getenv('OTEL_EXPORTER_OTLP_HEADERS') ?: 'none'; + $this->compression = getenv('OTEL_EXPORTER_OTLP_COMPRESSION') ?: 'none'; + $this->timeout =(int) getenv('OTEL_EXPORTER_OTLP_TIMEOUT') ?: 10; + + $this->client = $client ?? $this->createDefaultClient(); + $this->spanConverter = new SpanConverter($serviceName); + } + + /** + * Exports the provided Span data via the OTLP protocol + * + * @param iterable $spans Array of Spans + * @return int return code, defined on the Exporter interface + */ + public function export(iterable $spans): int + { + if (!$this->running) { + return Exporter::FAILED_NOT_RETRYABLE; + } + + if (empty($spans)) { + return Trace\Exporter::SUCCESS; + } + + $convertedSpans = []; + foreach ($spans as $span) { + array_push($convertedSpans, $this->spanConverter->convert($span)); + } + + try { + $json = json_encode($convertedSpans); + + $this->headers[] = ''; + + if ($this->protocol == 'json') { + $headers = ['content-type' => 'application/json', 'Content-Encoding' => 'gzip']; + } + + $request = new Request('POST', $this->endpointURL, $this->headers, $json); + $response = $this->client->sendRequest($request); + } catch (RequestExceptionInterface $e) { + return Trace\Exporter::FAILED_NOT_RETRYABLE; + } catch (NetworkExceptionInterface | ClientExceptionInterface $e) { + return Trace\Exporter::FAILED_RETRYABLE; + } + + if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { + return Trace\Exporter::FAILED_NOT_RETRYABLE; + } + + if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) { + return Trace\Exporter::FAILED_RETRYABLE; + } + + return Trace\Exporter::SUCCESS; + } + + public function shutdown(): void + { + $this->running = false; + } + + protected function createDefaultClient(): ClientInterface + { + $container = []; + $history = Middleware::history($container); + $stack = HandlerStack::create(); + // Add the history middleware to the handler stack. + $stack->push($history); + + return Client::createWithConfig([ + 'handler' => $stack, + 'timeout' => 30, + ]); + } +} diff --git a/contrib/Otlp/SpanConverter.php b/contrib/Otlp/SpanConverter.php new file mode 100644 index 000000000..a4688f4cd --- /dev/null +++ b/contrib/Otlp/SpanConverter.php @@ -0,0 +1,77 @@ +serviceName = $serviceName; + } + + private function sanitiseTagValue($value) + { + // Casting false to string makes an empty string + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + // OTLP tags must be strings, but opentelemetry + // accepts strings, booleans, numbers, and lists of each. + if (is_array($value)) { + return join(',', array_map([$this, 'sanitiseTagValue'], $value)); + } + + // Floats will lose precision if their string representation + // is >=14 or >=17 digits, depending on PHP settings. + // Can also throw E_RECOVERABLE_ERROR if $value is an object + // without a __toString() method. + // This is possible because OpenTelemetry\Trace\Span does not verify + // setAttribute() $value input. + return (string) $value; + } + + public function convert(Span $span) + { + $spanParent = $span->getParent(); + $row = [ + 'id' => $span->getContext()->getSpanId(), + 'traceId' => $span->getContext()->getTraceId(), + 'parentId' => $spanParent ? $spanParent->getSpanId() : null, + 'localEndpoint' => [ + 'serviceName' => $this->serviceName, + ], + 'name' => $span->getSpanName(), + 'timestamp' => (int) ($span->getStartEpochTimestamp() / 1e3), // RealtimeClock in microseconds + 'duration' => (int) (($span->getEnd() - $span->getStart()) / 1e3), // Diff in microseconds + ]; + + foreach ($span->getAttributes() as $k => $v) { + if (!array_key_exists('tags', $row)) { + $row['tags'] = []; + } + $row['tags'][$k] = $this->sanitiseTagValue($v->getValue()); + } + + foreach ($span->getEvents() as $event) { + if (!array_key_exists('annotations', $row)) { + $row['annotations'] = []; + } + $row['annotations'][] = [ + 'timestamp' => (int) ($event->getTimestamp() / 1e3), // RealtimeClock in microseconds + 'value' => $event->getName(), + ]; + } + + return $row; + } +} diff --git a/examples/AlwaysOnOTLPExample.php b/examples/AlwaysOnOTLPExample.php new file mode 100644 index 000000000..171a61cbd --- /dev/null +++ b/examples/AlwaysOnOTLPExample.php @@ -0,0 +1,64 @@ +shouldSample( + null, + md5((string) microtime(true)), + substr(md5((string) microtime(true)), 16), + 'io.opentelemetry.example', + API\SpanKind::KIND_INTERNAL +); +$Exporter = new OTLPExporter( + 'OTLP Example Service' +); + +if (SamplingResult::RECORD_AND_SAMPLED === $samplingResult->getDecision()) { + echo 'Starting OTLPExample'; + $tracer = (new TracerProvider()) + ->addSpanProcessor(new SimpleSpanProcessor($Exporter)) + ->getTracer('io.opentelemetry.contrib.php'); + + for ($i = 0; $i < 5; $i++) { + // start a span, register some events + $timestamp = Clock::get()->timestamp(); + $span = $tracer->startAndActivateSpan('session.generate.span.' . microtime(true)); + + $spanParent = $span->getParent(); + echo sprintf( + PHP_EOL . 'Exporting Trace: %s, Parent: %s, Span: %s', + $span->getContext()->getTraceId(), + $spanParent ? $spanParent->getSpanId() : 'None', + $span->getContext()->getSpanId() + ); + + $span->setAttribute('remote_ip', '1.2.3.4') + ->setAttribute('country', 'USA'); + + $span->addEvent('found_login' . $i, $timestamp, new Attributes([ + 'id' => $i, + 'username' => 'otuser' . $i, + ])); + $span->addEvent('generated_session', $timestamp, new Attributes([ + 'id' => md5((string) microtime(true)), + ])); + + $tracer->endActiveSpan(); + } + echo PHP_EOL . 'OTLPExample complete! '; +} else { + echo PHP_EOL . 'OTLPExample tracing is not enabled'; +} + +echo PHP_EOL; diff --git a/tests/Contrib/Unit/OTLPExporterTest.php b/tests/Contrib/Unit/OTLPExporterTest.php new file mode 100644 index 000000000..c6cec50ed --- /dev/null +++ b/tests/Contrib/Unit/OTLPExporterTest.php @@ -0,0 +1,104 @@ +method('sendRequest')->willReturn( + new Response($responseStatus) + ); + + $exporter = new Exporter('test.otlp', $client); + + $this->assertEquals( + $expected, + $exporter->export([new Span('test.otlp.span', SpanContext::generate())]) + ); + } + + public function exporterResponseStatusesDataProvider() + { + return [ + 'ok' => [200, Exporter::SUCCESS], + 'not found' => [404, Exporter::FAILED_NOT_RETRYABLE], + 'not authorized' => [401, Exporter::FAILED_NOT_RETRYABLE], + 'bad request' => [402, Exporter::FAILED_NOT_RETRYABLE], + 'too many requests' => [429, Exporter::FAILED_NOT_RETRYABLE], + 'server error' => [500, Exporter::FAILED_RETRYABLE], + 'timeout' => [503, Exporter::FAILED_RETRYABLE], + 'bad gateway' => [502, Exporter::FAILED_RETRYABLE], + ]; + } + + /** + * @test + * @dataProvider clientExceptionsShouldDecideReturnCodeDataProvider + */ + public function clientExceptionsShouldDecideReturnCode($exception, $expected) + { + $client = self::createMock(ClientInterface::class); + $client->method('sendRequest')->willThrowException($exception); + + $exporter = new Exporter('test.otlp'); + + $this->assertEquals( + $expected, + $exporter->export([new Span('test.otlp.span', SpanContext::generate())]) + ); + } + + public function clientExceptionsShouldDecideReturnCodeDataProvider() + { + return [ + 'client' => [ + self::createMock(ClientExceptionInterface::class), + Exporter::FAILED_RETRYABLE, + ], + 'network' => [ + self::createMock(NetworkExceptionInterface::class), + Exporter::FAILED_RETRYABLE, + ], + ]; + } + + /** + * @test + */ + public function shouldBeOkToExporterEmptySpansCollection() + { + $this->assertEquals( + Exporter::SUCCESS, + (new Exporter('test.otlp'))->export([]) + ); + } + /** + * @test + */ + public function failsIfNotRunning() + { + $exporter = new Exporter('test.otlp'); + $span = $this->createMock(Span::class); + $exporter->shutdown(); + + $this->assertEquals(\OpenTelemetry\Sdk\Trace\Exporter::FAILED_NOT_RETRYABLE, $exporter->export([$span])); + } +} diff --git a/tests/Contrib/Unit/OTLPSpanConverterTest.php b/tests/Contrib/Unit/OTLPSpanConverterTest.php new file mode 100644 index 000000000..4a7e0b89a --- /dev/null +++ b/tests/Contrib/Unit/OTLPSpanConverterTest.php @@ -0,0 +1,131 @@ +getTracer('OpenTelemetry.OtlpTest'); + + $timestamp = Clock::get()->timestamp(); + + /** @var Span $span */ + $span = $tracer->startAndActivateSpan('guard.validate'); + $span->setAttribute('service', 'guard'); + $span->addEvent('validators.list', $timestamp, new Attributes(['job' => 'stage.updateTime'])); + $span->end(); + + $converter = new SpanConverter('test.name'); + $row = $converter->convert($span); + + $this->assertSame($span->getContext()->getSpanId(), $row['id']); + $this->assertSame($span->getContext()->getTraceId(), $row['traceId']); + + $this->assertSame('test.name', $row['localEndpoint']['serviceName']); + $this->assertSame($span->getSpanName(), $row['name']); + + $this->assertIsInt($row['timestamp']); + // timestamp should be in microseconds + $this->assertGreaterThan(1e15, $row['timestamp']); + + $this->assertIsInt($row['duration']); + $this->assertGreaterThan(0, $row['duration']); + + $this->assertCount(1, $row['tags']); + + /** @var Attribute $attribute */ + $attribute = $span->getAttribute('service'); + $this->assertEquals($attribute->getValue(), $row['tags']['service']); + + $this->assertCount(1, $row['annotations']); + [$annotation] = $row['annotations']; + $this->assertEquals('validators.list', $annotation['value']); + + [$event] = \iterator_to_array($span->getEvents()); + $this->assertIsInt($annotation['timestamp']); + + // timestamp should be in microseconds + $this->assertGreaterThan(1e15, $annotation['timestamp']); + } + + /** + * @test + */ + public function durationShouldBeInMicroseconds() + { + $span = new Span('duration.test', SpanContext::generate()); + + $row = (new SpanConverter('duration.test'))->convert($span); + + $this->assertEquals( + (int) (($span->getEnd() - $span->getStart()) / 1000), + $row['duration'] + ); + } + + /** + * @test + */ + public function tagsAreCoercedCorrectlyToStrings() + { + $span = new Span('tags.test', SpanContext::generate()); + + $listOfStrings = ['string-1','string-2']; + $listOfNumbers = [1,2,3,3.1415,42]; + $listOfBooleans = [true,true,false,true]; + $listOfRandoms = [true,[1,2,3],false,'string-1',3.1415]; + + $span->setAttribute('string', 'string'); + $span->setAttribute('integer-1', 1024); + $span->setAttribute('integer-2', 0); + $span->setAttribute('float', '1.2345'); + $span->setAttribute('boolean-1', true); + $span->setAttribute('boolean-2', false); + $span->setAttribute('list-of-strings', $listOfStrings); + $span->setAttribute('list-of-numbers', $listOfNumbers); + $span->setAttribute('list-of-booleans', $listOfBooleans); + $span->setAttribute('list-of-random', $listOfRandoms); + + $tags = (new SpanConverter('tags.test'))->convert($span)['tags']; + + // Check that we can convert all attributes to tags + $this->assertCount(10, $tags); + + // Tags destined for Otlp must be pairs of strings + foreach ($tags as $tagKey => $tagValue) { + $this->assertIsString($tagKey); + $this->assertIsString($tagValue); + } + + $this->assertEquals($tags['string'], 'string'); + $this->assertEquals($tags['integer-1'], '1024'); + $this->assertEquals($tags['integer-2'], '0'); + $this->assertEquals($tags['float'], '1.2345'); + $this->assertEquals($tags['boolean-1'], 'true'); + $this->assertEquals($tags['boolean-2'], 'false'); + + // Lists must be casted to strings and joined with a separator + $this->assertEquals($tags['list-of-strings'], join(',', $listOfStrings)); + $this->assertEquals($tags['list-of-numbers'], join(',', $listOfNumbers)); + $this->assertEquals($tags['list-of-booleans'], 'true,true,false,true'); + + // This currently works, but OpenTelemetry\Trace\Span should stop arrays + // containing multiple value types from being passed to the Exporter. + $this->assertEquals($tags['list-of-random'], 'true,1,2,3,false,string-1,3.1415'); + } +}