Repository: open-telemetry/opentelemetry-php (php)

Issue:
Create B3 Propagator
**Is your feature request related to a problem?**
`W3C tracecontext` is not yet supported "everywere" plus B3 propagation is part of the [Spec Compliance Matrix](https://github.com/open-telemetry/opentelemetry-specification/blob/main/spec-compliance-matrix.md#context-propagation).


**Describe the solution you'd like**
Two Propagator implementations. One for B3 [Single Headers](https://github.com/openzipkin/b3-propagation#single-header), one for B3 [Multiple Headers](https://github.com/openzipkin/b3-propagation#multiple-headers).


Pull request description:
Added B3MultiPropagater
Closes #500.

Patch:
diff --git a/src/API/Trace/Propagation/B3MultiPropagator.php b/src/API/Trace/Propagation/B3MultiPropagator.php
new file mode 100644
index 00000000..1a2e9610
--- /dev/null
+++ b/src/API/Trace/Propagation/B3MultiPropagator.php
@@ -0,0 +1,175 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\API\Trace\Propagation;
+
+use OpenTelemetry\API\Trace\AbstractSpan;
+use OpenTelemetry\API\Trace\SpanContext;
+use OpenTelemetry\API\Trace\SpanContextInterface;
+use OpenTelemetry\Context\Context;
+use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
+use OpenTelemetry\Context\Propagation\PropagationGetterInterface;
+use OpenTelemetry\Context\Propagation\PropagationSetterInterface;
+use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface;
+
+/**
+ * B3Multi is a propagator that supports the specification for the header
+ * "b3" used for trace context propagation across service boundaries.
+ * (https://github.com/openzipkin/b3-propagation)
+ */
+final class B3MultiPropagator implements TextMapPropagatorInterface
+{
+    /**
+     * The X-B3-TraceId header is required and is encoded as 32 or 16 lower-hex characters.
+     * For example, a 128-bit TraceId header might look like: X-B3-TraceId: 463ac35c9f6413ad48485a3953bb6124 .
+     * Unless propagating only the Sampling State, the X-B3-TraceId header is required.
+     *
+     * @see https://github.com/openzipkin/b3-propagation#traceid-1
+     */
+    public const TRACE_ID = 'X-B3-TraceId';
+
+    /**
+     * The X-B3-SpanId header is required and is encoded as 16 lower-hex characters.
+     * For example, a SpanId header might look like: X-B3-SpanId: a2fb4a1d1a96d312 .
+     * Unless propagating only the Sampling State, the X-B3-SpanId header is required.
+     *
+     * @see https://github.com/openzipkin/b3-propagation#spanid-1
+     */
+    public const SPAN_ID = 'X-B3-SpanId';
+
+    /**
+     * The X-B3-ParentSpanId header must be present on a child span and absent on the root span.
+     * It is encoded as 16 lower-hex characters.
+     * For example, a ParentSpanId header might look like: X-B3-ParentSpanId: 0020000000000001
+     *
+     * @see https://github.com/openzipkin/b3-propagation#parentspanid-1
+     */
+    public const PARENT_SPAN_ID = 'X-B3-ParentSpanId';
+
+    /**
+     * An accept sampling decision is encoded as X-B3-Sampled: 1 and a deny as X-B3-Sampled: 0.
+     * Absent means defer the decision to the receiver of this header.
+     * For example, a Sampled header might look like: X-B3-Sampled: 1
+     *
+     * Note: Before this specification was written, some tracers propagated X-B3-Sampled as true or false as opposed to 1 or 0.
+     * While you shouldn't encode X-B3-Sampled as true or false, a lenient implementation may accept them.
+     *
+     * @see https://github.com/openzipkin/b3-propagation#sampling-state-1
+     */
+    public const SAMPLED = 'X-B3-Sampled';
+
+    /**
+     * Debug is encoded as X-B3-Flags: 1.
+     * Absent or any other value can be ignored.
+     * Debug implies an accept decision, so don't also send the X-B3-Sampled header.
+     *
+     * @see https://github.com/openzipkin/b3-propagation#debug-flag
+     */
+    public const DEBUG_FLAG = 'X-B3-Flags';
+
+    private const IS_SAMPLED = '1';
+    private const IS_NOT_SAMPLED = '0';
+
+    public const FIELDS = [
+        self::TRACE_ID,
+        self::SPAN_ID,
+        self::PARENT_SPAN_ID,
+        self::SAMPLED,
+        self::DEBUG_FLAG,
+    ];
+
+    private static ?self $instance = null;
+
+    public static function getInstance(): self
+    {
+        if (null === self::$instance) {
+            self::$instance = new self();
+        }
+
+        return self::$instance;
+    }
+
+    /** {@inheritdoc} */
+    public function fields(): array
+    {
+        return self::FIELDS;
+    }
+
+    /** {@inheritdoc} */
+    public function inject(&$carrier, PropagationSetterInterface $setter = null, Context $context = null): void
+    {
+        $setter = $setter ?? ArrayAccessGetterSetter::getInstance();
+        $context = $context ?? Context::getCurrent();
+        $spanContext = AbstractSpan::fromContext($context)->getContext();
+
+        if (!$spanContext->isValid()) {
+            return;
+        }
+
+        $setter->set($carrier, self::TRACE_ID, $spanContext->getTraceId());
+        $setter->set($carrier, self::SPAN_ID, $spanContext->getSpanId());
+        $setter->set($carrier, self::SAMPLED, $spanContext->isSampled() ? self::IS_SAMPLED : self::IS_NOT_SAMPLED);
+    }
+
+    public function extract($carrier, PropagationGetterInterface $getter = null, Context $context = null): Context
+    {
+        $getter = $getter ?? ArrayAccessGetterSetter::getInstance();
+        $context = $context ?? Context::getCurrent();
+
+        $spanContext = self::extractImpl($carrier, $getter);
+        if (!$spanContext->isValid()) {
+            return $context;
+        }
+
+        return $context->withContextValue(AbstractSpan::wrap($spanContext));
+    }
+
+    private static function getSampledValue($carrier, PropagationGetterInterface $getter): ?int
+    {
+        $value = $getter->get($carrier, self::SAMPLED);
+
+        if ($value === null) {
+            return null;
+        }
+
+        if ($value === '0' || $value === '1') {
+            return (int) $value;
+        }
+
+        if (strtolower($value) === 'true') {
+            return 1;
+        }
+        if (strtolower($value) === 'false') {
+            return 0;
+        }
+
+        return null;
+    }
+
+    private static function extractImpl($carrier, PropagationGetterInterface $getter): SpanContextInterface
+    {
+        $traceId = $getter->get($carrier, self::TRACE_ID);
+        $spanId = $getter->get($carrier, self::SPAN_ID);
+        $sampled = self::getSampledValue($carrier, $getter);
+
+        if ($traceId === null || $spanId === null) {
+            return SpanContext::getInvalid();
+        }
+
+        // Validates the traceId, spanId and sampled
+        // Returns an invalid spanContext if any of the checks fail
+        if (!SpanContext::isValidTraceId($traceId) || !SpanContext::isValidSpanId($spanId)) {
+            return SpanContext::getInvalid();
+        }
+
+        $isSampled = ($sampled === SpanContext::SAMPLED_FLAG);
+
+        // Only traceparent header is extracted. No tracestate.
+        return SpanContext::createFromRemoteParent(
+            $traceId,
+            $spanId,
+            $isSampled ? SpanContextInterface::TRACE_FLAG_SAMPLED : SpanContextInterface::TRACE_FLAG_DEFAULT
+        );
+    }
+}


Test patch:
diff --git a/tests/Unit/API/Trace/Propagation/B3MultiPropagatorTest.php b/tests/Unit/API/Trace/Propagation/B3MultiPropagatorTest.php
new file mode 100644
index 00000000..d08a7089
--- /dev/null
+++ b/tests/Unit/API/Trace/Propagation/B3MultiPropagatorTest.php
@@ -0,0 +1,312 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\Tests\API\Unit\Trace\Propagation;
+
+use OpenTelemetry\API\Trace\Propagation\B3MultiPropagator;
+use OpenTelemetry\API\Trace\SpanContext;
+use OpenTelemetry\API\Trace\SpanContextInterface;
+use OpenTelemetry\Context\Context;
+use OpenTelemetry\SDK\Trace\Span;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers OpenTelemetry\API\Trace\Propagation\B3MultiPropagator
+ */
+class B3MultiPropagatorTest extends TestCase
+{
+    private const TRACE_ID_BASE16 = 'ff000000000000000000000000000041';
+    private const SPAN_ID_BASE16 = 'ff00000000000041';
+    private const IS_SAMPLED = '1';
+    private const IS_NOT_SAMPLED = '0';
+
+    private B3MultiPropagator $b3MultiPropagator;
+
+    protected function setUp(): void
+    {
+        $this->b3MultiPropagator = B3MultiPropagator::getInstance();
+    }
+
+    public function test_fields(): void
+    {
+        $this->assertSame(
+            ['X-B3-TraceId', 'X-B3-SpanId', 'X-B3-ParentSpanId', 'X-B3-Sampled', 'X-B3-Flags'],
+            $this->b3MultiPropagator->fields()
+        );
+    }
+
+    public function test_inject_empty(): void
+    {
+        $carrier = [];
+        $this->b3MultiPropagator->inject($carrier);
+        $this->assertEmpty($carrier);
+    }
+
+    public function test_inject_invalid_context(): void
+    {
+        $carrier = [];
+        $this
+            ->b3MultiPropagator
+            ->inject(
+                $carrier,
+                null,
+                $this->withSpanContext(
+                    SpanContext::create(
+                        SpanContext::INVALID_TRACE,
+                        SpanContext::INVALID_SPAN,
+                        SpanContext::SAMPLED_FLAG
+                    ),
+                    Context::getCurrent()
+                )
+            );
+        $this->assertEmpty($carrier);
+    }
+
+    public function test_inject_sampled_context(): void
+    {
+        $carrier = [];
+        $this
+            ->b3MultiPropagator
+            ->inject(
+                $carrier,
+                null,
+                $this->withSpanContext(
+                    SpanContext::create(self::TRACE_ID_BASE16, self::SPAN_ID_BASE16, SpanContextInterface::TRACE_FLAG_SAMPLED),
+                    Context::getCurrent()
+                )
+            );
+
+        $this->compareKeyCaseInsensitive(
+            [
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ],
+            $carrier
+        );
+    }
+
+    public function test_inject_non_sampled_context(): void
+    {
+        $carrier = [];
+        $this
+            ->b3MultiPropagator
+            ->inject(
+                $carrier,
+                null,
+                $this->withSpanContext(
+                    SpanContext::create(self::TRACE_ID_BASE16, self::SPAN_ID_BASE16),
+                    Context::getCurrent()
+                )
+            );
+
+        $this->compareKeyCaseInsensitive(
+            [
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+                B3MultiPropagator::SAMPLED => self::IS_NOT_SAMPLED,
+            ],
+            $carrier
+        );
+    }
+
+    public function test_extract_nothing(): void
+    {
+        $this->assertSame(
+            Context::getCurrent(),
+            $this->b3MultiPropagator->extract([])
+        );
+    }
+
+    /**
+     * @dataProvider sampledValueProvider
+     */
+    public function test_extract_sampled_context($sampledValue): void
+    {
+        $carrier = $this->getLowerCaseKeys([
+            B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+            B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+            B3MultiPropagator::SAMPLED => $sampledValue,
+        ]);
+
+        $this->assertEquals(
+            SpanContext::createFromRemoteParent(self::TRACE_ID_BASE16, self::SPAN_ID_BASE16, SpanContextInterface::TRACE_FLAG_SAMPLED),
+            $this->getSpanContext($this->b3MultiPropagator->extract($carrier))
+        );
+    }
+
+    public function sampledValueProvider()
+    {
+        return [
+            'String sampled value' => ['1'],
+            'Boolean(lower string) sampled value' => ['true'],
+            'Boolean(upper string) sampled value' => ['TRUE'],
+            'Boolean(camel string) sampled value' => ['True'],
+        ];
+    }
+
+    /**
+     * @dataProvider notSampledValueProvider
+     */
+    public function test_extract_non_sampled_context($sampledValue): void
+    {
+        $carrier = $this->getLowerCaseKeys([
+            B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+            B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+            B3MultiPropagator::SAMPLED => $sampledValue,
+        ]);
+
+        $this->assertEquals(
+            SpanContext::createFromRemoteParent(self::TRACE_ID_BASE16, self::SPAN_ID_BASE16),
+            $this->getSpanContext($this->b3MultiPropagator->extract($carrier))
+        );
+    }
+
+    public function notSampledValueProvider()
+    {
+        return [
+            'String sampled value' => ['0'],
+            'Boolean(lower string) sampled value' => ['false'],
+            'Boolean(upper string) sampled value' => ['FALSE'],
+            'Boolean(camel string) sampled value' => ['False'],
+        ];
+    }
+
+    /**
+     * @dataProvider invalidSampledValueProvider
+     */
+    public function test_extract_invalid_sampled_context($sampledValue): void
+    {
+        $carrier = $this->getLowerCaseKeys([
+            B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+            B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+            B3MultiPropagator::SAMPLED => $sampledValue,
+        ]);
+
+        $this->assertEquals(
+            SpanContext::createFromRemoteParent(self::TRACE_ID_BASE16, self::SPAN_ID_BASE16),
+            $this->getSpanContext($this->b3MultiPropagator->extract($carrier))
+        );
+    }
+
+    public function invalidSampledValueProvider()
+    {
+        return [
+            'wrong sampled value' => ['wrong'],
+            'null sampled value' => [null],
+            'empty sampled value' => [[]],
+        ];
+    }
+
+    public function test_extract_and_inject(): void
+    {
+        $extractCarrier = $this->getLowerCaseKeys([
+            B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+            B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+            B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+        ]);
+        $context = $this->b3MultiPropagator->extract($extractCarrier);
+        $injectCarrier = [];
+        $this->b3MultiPropagator->inject($injectCarrier, null, $context);
+        $this->assertSame($injectCarrier, $extractCarrier);
+    }
+
+    public function test_extract_empty_trace_id(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => '',
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    public function test_extract_empty_span_id(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+                B3MultiPropagator::SPAN_ID => '',
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    public function test_invalid_trace_id(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => 'abcdefghijklmnopabcdefghijklmnop',
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    public function test_invalid_trace_id_size(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16 . '00',
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16,
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    public function test_invalid_span_id(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+                B3MultiPropagator::SPAN_ID => 'abcdefghijklmnop',
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    public function test_invalid_span_id_size(): void
+    {
+        $this->assertInvalid(
+            $this->getLowerCaseKeys([
+                B3MultiPropagator::TRACE_ID => self::TRACE_ID_BASE16,
+                B3MultiPropagator::SPAN_ID => self::SPAN_ID_BASE16 . '00',
+                B3MultiPropagator::SAMPLED => self::IS_SAMPLED,
+            ])
+        );
+    }
+
+    private function assertInvalid(array $carrier): void
+    {
+        $this->assertSame(
+            Context::getCurrent(),
+            $this->b3MultiPropagator->extract($carrier),
+        );
+    }
+
+    private function getSpanContext(Context $context): SpanContextInterface
+    {
+        return Span::fromContext($context)->getContext();
+    }
+
+    private function withSpanContext(SpanContextInterface $spanContext, Context $context): Context
+    {
+        return $context->withContextValue(Span::wrap($spanContext));
+    }
+
+    private function compareKeyCaseInsensitive(array $expected, array $actual): void
+    {
+        $expectedLower = $this->getLowerCaseKeys($expected);
+        $this->assertSame(
+            $expectedLower,
+            $actual
+        );
+    }
+
+    private function getLowerCaseKeys(array $carrier): array
+    {
+        return array_change_key_case($carrier, CASE_LOWER);
+    }
+}