diff --git a/Makefile b/Makefile index 0ad5300b..7a13e451 100644 --- a/Makefile +++ b/Makefile @@ -5,11 +5,14 @@ install: update: $(DC_RUN_PHP) composer update test: - $(DC_RUN_PHP) php ./vendor/bin/phpunit --colors=always + $(DC_RUN_PHP) php ./vendor/bin/phpunit --colors=always --coverage-text phan: $(DC_RUN_PHP) env PHAN_DISABLE_XDEBUG_WARN=1 php ./vendor/bin/phan examples: FORCE + docker-compose up -d $(DC_RUN_PHP) php ./examples/AlwaysOnTraceExample.php + $(DC_RUN_PHP) php ./examples/AlwaysOffTraceExample.php + $(DC_RUN_PHP) php ./examples/JaegerExporterExample.php bash: $(DC_RUN_PHP) bash style: diff --git a/README.md b/README.md index 35897ead..185a3626 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OpenTelemetry php library [![Gitter](https://badges.gitter.im/open-telemetry/opentelemetry-php.svg)](https://gitter.im/open-telemetry/opentelemetry-php?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Build Status](https://travis-ci.org/open-telemetry/opentelemetry-php.svg?branch=master)](https://travis-ci.org/open-telemetry/opentelemetry-php) -[![codecov](https://codecov.io/gh/open-telemetry/opentelemetry-php/branch/master/graph/badge.svg)](https://codecov.io/gh/opentelemety/opentelemetry-php) +[![codecov](https://codecov.io/gh/open-telemetry/opentelemetry-php/branch/master/graph/badge.svg)](https://codecov.io/gh/open-telemetry/opentelemetry-php) ## Communication Most of our communication is done on gitter.im in the [opentelemetry-php](https://gitter.im/open-telemetry/opentelemetry-php) channel. diff --git a/docker-compose.yaml b/docker-compose.yaml index bc7e1270..9596dd9a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,3 +10,10 @@ services: image: openzipkin/zipkin-slim ports: - 9411:9411 + jaeger: + image: jaegertracing/all-in-one + environment: + COLLECTOR_ZIPKIN_HTTP_PORT: 9412 + ports: + - 9412:9412 + - 16686:16686 diff --git a/examples/AlwaysOffTraceExample.php b/examples/AlwaysOffTraceExample.php index c7244fab..9e3ede3f 100644 --- a/examples/AlwaysOffTraceExample.php +++ b/examples/AlwaysOffTraceExample.php @@ -36,5 +36,7 @@ if (SamplingResult::RECORD_AND_SAMPLED === $samplingResult) { $span->end(); // pass status as an optional argument print_r($span); // print the span as a resulting output } else { - echo 'Sampling is not enabled'; + echo PHP_EOL . 'Sampling is not enabled'; } + +echo PHP_EOL; diff --git a/examples/AlwaysOnTraceExample.php b/examples/AlwaysOnTraceExample.php index 4717f9b0..ed81e496 100644 --- a/examples/AlwaysOnTraceExample.php +++ b/examples/AlwaysOnTraceExample.php @@ -29,9 +29,12 @@ if (SamplingResult::RECORD_AND_SAMPLED === $samplingResult->getDecision()) { [new SimpleSpanProcessor($zipkinExporter)] )) ->getTracer('io.opentelemetry.contrib.php'); + + echo PHP_EOL . sprintf('Trace with id %s started ', $tracer->getActiveSpan()->getContext()->getTraceId()); + for ($i = 0; $i < 5; $i++) { // start a span, register some events - $span = $tracer->startAndActivateSpan('session.generate.span' . $i); + $span = $tracer->startAndActivateSpan('session.generate.span.' . time()); $tracer->setActiveSpan($span); $span->setAttribute('remote_ip', '1.2.3.4') @@ -47,7 +50,9 @@ if (SamplingResult::RECORD_AND_SAMPLED === $samplingResult->getDecision()) { $tracer->endActiveSpan(); } - echo 'AlwaysOnTraceExample complete! See the results at http://localhost:9411/'; + echo PHP_EOL . 'AlwaysOnTraceExample complete! See the results at http://localhost:9411/'; } else { - echo 'Sampling is not enabled'; + echo PHP_EOL . 'Sampling is not enabled'; } + +echo PHP_EOL; diff --git a/examples/JaegerExporterExample.php b/examples/JaegerExporterExample.php new file mode 100644 index 00000000..0bb6b5d1 --- /dev/null +++ b/examples/JaegerExporterExample.php @@ -0,0 +1,51 @@ +shouldSample(); + +$exporter = new JaegerExporter( + 'jaegerExporterExample', + 'http://jaeger:9412/api/v2/spans' +); + +if ($sampler) { + echo 'Starting JaegerExporterExample'; + $tracer = (TracerProvider::getInstance( + [new SimpleSpanProcessor($exporter)] + )) + ->getTracer('io.opentelemetry.contrib.php'); + + echo PHP_EOL . sprintf('Trace with id %s started ', $tracer->getActiveSpan()->getContext()->getTraceId()); + + for ($i = 0; $i < 5; $i++) { + // start a span, register some events + $span = $tracer->startAndActivateSpan('session.generate.span' . time()); + $tracer->setActiveSpan($span); + + $span->setAttribute('remote_ip', '1.2.3.4') + ->setAttribute('country', 'USA'); + + $span->addEvent('found_login' . $i, new Attributes([ + 'id' => $i, + 'username' => 'otuser' . $i, + ])); + $span->addEvent('generated_session', new Attributes([ + 'id' => md5((string) microtime(true)), + ])); + + $tracer->endActiveSpan(); + } + echo PHP_EOL . 'JaegerExporterExample complete! See the results at http://localhost:16686/'; +} else { + echo PHP_EOL . 'Sampling is not enabled'; +} + +echo PHP_EOL; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index deb03cd4..18b3e337 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -32,7 +32,10 @@ - src + sdk + + sdk/Tests + diff --git a/sdk/Tests/TracingTest.php b/sdk/Tests/TracingTest.php index 088cb509..0c581282 100644 --- a/sdk/Tests/TracingTest.php +++ b/sdk/Tests/TracingTest.php @@ -10,9 +10,7 @@ use OpenTelemetry\Sdk\Trace\Attributes; use OpenTelemetry\Sdk\Trace\SpanContext; use OpenTelemetry\Sdk\Trace\SpanStatus; use OpenTelemetry\Sdk\Trace\Tracer; -use OpenTelemetry\Sdk\Trace\ZipkinExporter; use PHPUnit\Framework\TestCase; -use ReflectionMethod; class TracingTest extends TestCase { @@ -222,34 +220,4 @@ class TracingTest extends TestCase $this->assertNull($global->getParent()); $this->assertNotNull($request->getParent()); } - - public function testZipkinConverter() - { - $tracer = new Tracer(); - $span = $tracer->startAndActivateSpan('guard.validate'); - $span->setAttribute('service', 'guard'); - $span->addEvent('validators.list', new Attributes(['job' => 'stage.updateTime'])); - $span->end(); - - $method = new ReflectionMethod(ZipkinExporter::class, 'convertSpan'); - $method->setAccessible(true); - - $exporter = new ZipkinExporter( - 'test.name', - 'http://host:123/path' - ); - - $row = $method->invokeArgs($exporter, ['span' => $span]); - $this->assertSame($row['name'], $span->getSpanName()); - - self::assertCount(1, $row['tags']); - self::assertEquals($span->getAttribute('service')->getValue(), $row['tags']['service']); - - self::assertCount(1, $row['annotations']); - [$annotation] = $row['annotations']; - self::assertEquals('validators.list', $annotation['value']); - - [$event] = \iterator_to_array($span->getEvents()); - self::assertEquals($event->getTimestamp(), $annotation['timestamp']); - } } diff --git a/sdk/Trace/JaegerExporter.php b/sdk/Trace/JaegerExporter.php new file mode 100644 index 00000000..74793e06 --- /dev/null +++ b/sdk/Trace/JaegerExporter.php @@ -0,0 +1,95 @@ +endpointUrl = $endpointUrl; + + $this->spanConverter = $spanConverter ?? new SpanConverter($name); + } + + /** + * Exports the provided Span data via the Zipkin protocol + * + * @param iterable $spans Array of Spans + * @return int return code, defined on the Exporter interface + */ + public function export(iterable $spans) : int + { + if (empty($spans)) { + return Exporter::SUCCESS; + } + + $convertedSpans = []; + foreach ($spans as &$span) { + array_push($convertedSpans, $this->spanConverter->convert($span)); + } + + try { + $json = json_encode($convertedSpans); + $contextOptions = [ + 'http' => [ + 'method' => 'POST', + 'header' => 'Content-Type: application/json', + 'content' => $json, + ], + ]; + $context = stream_context_create($contextOptions); + @file_get_contents($this->endpointUrl, false, $context); + } catch (Exception $e) { + return Exporter::FAILED_RETRYABLE; + } + + return Exporter::SUCCESS; + } + + public function shutdown(): void + { + // TODO: Implement shutdown() method. + } +} diff --git a/sdk/Trace/Tracer.php b/sdk/Trace/Tracer.php index a680f7e0..d7fc652e 100644 --- a/sdk/Trace/Tracer.php +++ b/sdk/Trace/Tracer.php @@ -100,14 +100,18 @@ class Tracer implements API\Tracer public function endActiveSpan(?string $timestamp = null) { - // todo: should processors be called before or after end()? - if ($this->getActiveSpan()->isRecording()) { + /** + * a span should be ended before is sent to exporters, because the exporters need's span duration. + */ + $span = $this->getActiveSpan(); + $wasRecording = $span->isRecording(); + $span->end(); + + if ($wasRecording) { foreach ($this->spanProcessors as $spanProcessor) { - $spanProcessor->onEnd($this->getActiveSpan()); + $spanProcessor->onEnd($span); } } - - $this->getActiveSpan()->end($timestamp); } private function generateSpanInstance($name, API\SpanContext $context): Span diff --git a/sdk/Trace/Zipkin/SpanConverter.php b/sdk/Trace/Zipkin/SpanConverter.php new file mode 100644 index 00000000..cfe431ea --- /dev/null +++ b/sdk/Trace/Zipkin/SpanConverter.php @@ -0,0 +1,58 @@ +serviceName = $serviceName; + } + + public function convert(Span $span) + { + $row = [ + 'id' => $span->getContext()->getSpanId(), + 'traceId' => $span->getContext()->getTraceId(), + 'parentId' => $span->getParent() ? $span->getParent()->getSpanId() : null, + 'localEndpoint' => [ + 'serviceName' => $this->serviceName, + ], + 'name' => $span->getSpanName(), + 'timestamp' => (int) ((float) $span->getStartTimestamp() * 1000), + 'duration' => (int) ((float) $span->getEndTimestamp() * 1000 - (float) $span->getStartTimestamp() * 1000), + ]; + + foreach ($span->getAttributes() as $k => $v) { + if (!array_key_exists('tags', $row)) { + $row['tags'] = []; + } + $v = $v->getValue(); + if (is_bool($v)) { + $v = (string) $v; + } + $row['tags'][$k] = $v; + } + + foreach ($span->getEvents() as $event) { + if (!array_key_exists('annotations', $row)) { + $row['annotations'] = []; + } + $row['annotations'][] = [ + 'timestamp' => (int) round((float) $event->getTimestamp() * 1000), + 'value' => $event->getName(), + ]; + } + + return $row; + } +} diff --git a/sdk/Trace/ZipkinExporter.php b/sdk/Trace/ZipkinExporter.php index f32a29bf..ad272856 100644 --- a/sdk/Trace/ZipkinExporter.php +++ b/sdk/Trace/ZipkinExporter.php @@ -7,6 +7,7 @@ namespace OpenTelemetry\Sdk\Trace; use Exception; use InvalidArgumentException; +use OpenTelemetry\Sdk\Trace\Zipkin\SpanConverter; use OpenTelemetry\Trace as API; /** @@ -15,23 +16,35 @@ use OpenTelemetry\Trace as API; */ class ZipkinExporter implements Exporter { + /** + * @var string + */ + private $endpointUrl; /** - * @var $endpoint array to send Spans to + * @var SpanConverter */ - private $endpoint; - private $name; + private $spanConverter; - public function __construct($name, string $endpointDsn) + public function __construct($name, string $endpointUrl, SpanConverter $spanConverter = null) { - $parsedDsn = parse_url($endpointDsn); + $parsedDsn = parse_url($endpointUrl); if (!is_array($parsedDsn)) { throw new InvalidArgumentException('Unable to parse provided DSN'); } - $this->setEndpoint($parsedDsn); - $this->name = $name; + if (!isset($parsedDsn['scheme']) + || !isset($parsedDsn['host']) + || !isset($parsedDsn['port']) + || !isset($parsedDsn['path']) + ) { + throw new InvalidArgumentException('Endpoint should have scheme, host, port and path'); + } + + $this->endpointUrl = $endpointUrl; + + $this->spanConverter = $spanConverter ?? new SpanConverter($name); } /** @@ -48,7 +61,7 @@ class ZipkinExporter implements Exporter $convertedSpans = []; foreach ($spans as &$span) { - array_push($convertedSpans, $this->convertSpan($span)); + array_push($convertedSpans, $this->spanConverter->convert($span)); } try { @@ -61,7 +74,7 @@ class ZipkinExporter implements Exporter ], ]; $context = stream_context_create($contextOptions); - file_get_contents($this->getEndpointUrl(), false, $context); + @file_get_contents($this->endpointUrl, false, $context); } catch (Exception $e) { return Exporter::FAILED_RETRYABLE; } @@ -69,93 +82,6 @@ class ZipkinExporter implements Exporter return Exporter::SUCCESS; } - /** - * Converts spans to Zipkin format for export - * - * @param API\Span $span - * @return array - */ - private function convertSpan(API\Span $span) : array - { - $row = [ - 'id' => $span->getContext()->getSpanId(), - 'traceId' => $span->getContext()->getTraceId(), - 'parentId' => $span->getParent() ? $span->getParent()->getSpanId() : null, - 'localEndpoint' => [ - 'serviceName' => $this->name, - 'port' => $this->getEndpoint()['port'] ?? 0, - ], - 'name' => $span->getSpanName(), - 'timestamp' => (int) round((float) $span->getStartTimestamp()), - 'duration' => (int) round((float) $span->getEndTimestamp()) - round((float) $span->getStartTimestamp()), - ]; - - foreach ($span->getAttributes() as $k => $v) { - if (!array_key_exists('tags', $row)) { - $row['tags'] = []; - } - $v = $v->getValue(); - if (is_bool($v)) { - $v = (string) $v; - } - $row['tags'][$k] = $v; - } - - foreach ($span->getEvents() as $event) { - if (!array_key_exists('annotations', $row)) { - $row['annotations'] = []; - } - $row['annotations'][] = [ - 'timestamp' => $event->getTimestamp(), - 'value' => $event->getName(), - ]; - } - - return $row; - } - - /** - * Gets the configured endpoint for the Zipkin exporter - * - * @return array |null - */ - public function getEndpoint(): ?array - { - return $this->endpoint; - } - - /** - * Sets the configured endpoint for the zipkin exportedr - * - * @param array $endpoint - * @return $this - */ - public function setEndpoint(array $endpoint) : self - { - if (!isset($endpoint['scheme']) - || !isset($endpoint['host']) - || !isset($endpoint['port']) - || !isset($endpoint['path']) - ) { - throw new InvalidArgumentException('Endpoint should have scheme, host, port and path'); - } - - $this->endpoint = $endpoint; - - return $this; - } - - protected function getEndpointUrl(): string - { - return sprintf( - '%s://%s:%s%s', - $this->endpoint['scheme'], - $this->endpoint['host'], - $this->endpoint['port'], - $this->endpoint['path'] - ); - } - public function shutdown(): void { // TODO: Implement shutdown() method. diff --git a/tests/unit/Exporter/JaegerExporterTest.php b/tests/unit/Exporter/JaegerExporterTest.php new file mode 100644 index 00000000..c98cc9e7 --- /dev/null +++ b/tests/unit/Exporter/JaegerExporterTest.php @@ -0,0 +1,37 @@ +expectException(InvalidArgumentException::class); + + new JaegerExporter('test.zipkin', $invalidDsn); + } + + public function invalidDsnDataProvider() + { + return [ + 'missing scheme' => ['host:123/path'], + 'missing host' => ['scheme://123/path'], + 'missing port' => ['scheme://host/path'], + 'missing path' => ['scheme://host:123'], + 'invalid port' => ['scheme://host:port/path'], + 'invalid scheme' => ['1234://host:port/path'], + 'invalid host' => ['scheme:///end:1234/path'], + 'unimplemented path' => ['scheme:///host:1234/api/v1/spans'], + ]; + } +} diff --git a/tests/unit/Exporter/ZipkinExporterTest.php b/tests/unit/Exporter/ZipkinExporterTest.php index 28b21492..0fbdaa25 100644 --- a/tests/unit/Exporter/ZipkinExporterTest.php +++ b/tests/unit/Exporter/ZipkinExporterTest.php @@ -10,19 +10,6 @@ use PHPUnit\Framework\TestCase; class ZipkinExporterTest extends TestCase { - /** - * @test - */ - public function shouldParseAnValidDsn() - { - $exporter = new ZipkinExporter('test.zipkin', 'scheme://host:1234/path'); - - $this->assertArrayHasKey('scheme', $exporter->getEndpoint()); - $this->assertArrayHasKey('host', $exporter->getEndpoint()); - $this->assertArrayHasKey('port', $exporter->getEndpoint()); - $this->assertArrayHasKey('path', $exporter->getEndpoint()); - } - /** * @test * @dataProvider invalidDsnDataProvider diff --git a/tests/unit/Exporter/ZipkinSpanConverterTest.php b/tests/unit/Exporter/ZipkinSpanConverterTest.php new file mode 100644 index 00000000..97107010 --- /dev/null +++ b/tests/unit/Exporter/ZipkinSpanConverterTest.php @@ -0,0 +1,54 @@ +startAndActivateSpan('guard.validate'); + $span->setAttribute('service', 'guard'); + $span->addEvent('validators.list', 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']); + $this->assertEquals($span->getAttribute('service')->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']); + } +}