diff --git a/src/Extension/Propagator/Instana/InstanaMultiPropagator.php b/src/Extension/Propagator/Instana/InstanaMultiPropagator.php new file mode 100644 index 000000000..e7cdc3dd0 --- /dev/null +++ b/src/Extension/Propagator/Instana/InstanaMultiPropagator.php @@ -0,0 +1,163 @@ +getContext(); + + if (!$spanContext->isValid()) { + return; + } + + // Inject multiple Instana headers + $setter->set($carrier, self::INSTANA_TRACE_ID_HEADER, $spanContext->getTraceId()); + $setter->set($carrier, self::INSTANA_SPAN_ID_HEADER, $spanContext->getSpanId()); + $setter->set($carrier, self::INSTANA_LEVEL_HEADER, $spanContext->isSampled() ? self::IS_SAMPLED : self::IS_NOT_SAMPLED); + } + + public function extract($carrier, ?PropagationGetterInterface $getter = null, ?ContextInterface $context = null): ContextInterface + { + $getter ??= ArrayAccessGetterSetter::getInstance(); + $context ??= Context::getCurrent(); + + $traceId = self::readHeader($carrier, $getter, self::INSTANA_TRACE_ID_HEADER); + $spanId = self::readHeader($carrier, $getter, self::INSTANA_SPAN_ID_HEADER); + $level = self::getSampledValue($carrier, $getter); + + $spanContext = self::extractImpl($carrier, $getter); + + if (($traceId === '' && $spanId === '') && $level !== null) { + return (new NonRecordingSpan($spanContext)) + ->storeInContext($context); + + } elseif (!$spanContext->isValid()) { + return $context; + } + + return $context->withContextValue(Span::wrap($spanContext)); + + } + + private static function readHeader($carrier, PropagationGetterInterface $getter, string $key): string + { + $header = $getter->get($carrier, $key) ?: ''; + + // Return the header or an empty string if not found + return $header; + } + private static function getSampledValue($carrier, PropagationGetterInterface $getter): ?int + { + $value = $getter->get($carrier, self::INSTANA_LEVEL_HEADER); + + if ($value === null) { + return null; + } + + if (in_array(strtolower($value), self::VALID_SAMPLED)) { + return (int) self::IS_SAMPLED; + } + if (in_array(strtolower($value), self::VALID_NON_SAMPLED)) { + return (int) self::IS_NOT_SAMPLED; + } + + return null; + } + + private static function extractImpl($carrier, PropagationGetterInterface $getter): SpanContextInterface + { + $traceId = self::readHeader($carrier, $getter, self::INSTANA_TRACE_ID_HEADER); + $spanId = self::readHeader($carrier, $getter, self::INSTANA_SPAN_ID_HEADER); + $level = self::getSampledValue($carrier, $getter); + + if ($traceId && strlen($traceId) < 32) { + $traceId = str_pad($traceId, 32, '0', STR_PAD_LEFT); + } + + if ($spanId && strlen($spanId) < 16) { + $spanId = str_pad($spanId, 16, '0', STR_PAD_LEFT); + } + + return SpanContext::createFromRemoteParent( + $traceId, + $spanId, + $level ? TraceFlags::SAMPLED : TraceFlags::DEFAULT + ); + + } +} diff --git a/src/Extension/Propagator/Instana/README.md b/src/Extension/Propagator/Instana/README.md new file mode 100644 index 000000000..45e62d083 --- /dev/null +++ b/src/Extension/Propagator/Instana/README.md @@ -0,0 +1,60 @@ +OpenTelemetry Instana Propagator + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + + +The OpenTelemetry Propagator for Instana provides HTTP header propagation for systems that are using IBM Observability by Instana. +This propagator translates the Instana trace correlation headers (`X-INSTANA-T/X-INSTANA-S/X-INSTANA-L`) into the OpenTelemetry `SpanContext`, and vice versa. +It does not handle `TraceState`. + + +## Installation + +```sh +composer require @instana/opentelemetry-php-propagator-instana +``` + +## Usage + + + +## Propagator Details + +There are three headers that the propagator handles: `X-INSTANA-T` (the trace ID), `X-INSTANA-S` (the parent span ID), and `X-INSTANA-L` (the sampling level). + +Example header triplet: + +* `X-INSTANA-T: 80f198ee56343ba864fe8b2a57d3eff7`, +* `X-INSTANA-S: e457b5a2e4d86bd1`, +* `X-INSTANA-L: 1`. + +A short summary for each of the headers is provided below. More details are available at . + +### X-INSTANA-T -- trace ID + +* A string of either 16 or 32 characters from the alphabet `0-9a-f`, representing either a 64 bit or 128 bit ID. +* This header corresponds to the [OpenTelemetry TraceId](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/overview.md#spancontext). +* If the propagator receives an X-INSTANA-T header value that is shorter than 32 characters when _extracting_ headers into the OpenTelemetry span context, it will left-pad the string with the character "0" to length 32. +* No length transformation is applied when _injecting_ the span context into headers. + +### X-INSTANA-S -- parent span ID + +* Format: A string of 16 characters from the alphabet `0-9a-f`, representing a 64 bit ID. +* This header corresponds to the [OpenTelemetry SpanId](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/overview.md#spancontext). + +### X-INSTANA-L - sampling level + +* The only two valid values are `1` and `0`. +* A level of `1` means that this request is to be sampled, a level of `0` means that the request should not be sampled. +* This header corresponds to the sampling bit of the [OpenTelemetry TraceFlags](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/overview.md#spancontext). + +## Useful links + +* For more information on Instana, visit and [Instana' documentation](https://www.ibm.com/docs/en/obi/current). +* For more information on OpenTelemetry, visit: + + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. diff --git a/src/Extension/Propagator/Instana/_register.php b/src/Extension/Propagator/Instana/_register.php new file mode 100644 index 000000000..b994b8f55 --- /dev/null +++ b/src/Extension/Propagator/Instana/_register.php @@ -0,0 +1,13 @@ +instanaMultiPropagator = InstanaMultiPropagator::getInstance(); + $instanaMultiFields = $this->instanaMultiPropagator->fields(); + $this->TRACE_ID = $instanaMultiFields[0]; + $this->SPAN_ID = $instanaMultiFields[1]; + $this->SAMPLED = $instanaMultiFields[2]; + } + + public function test_fields(): void + { + $this->assertSame( + ['X-INSTANA-T', 'X-INSTANA-S', 'X-INSTANA-L'], + $this->instanaMultiPropagator->fields() + ); + } + + public function test_inject_empty(): void + { + $carrier = []; + $this->instanaMultiPropagator->inject($carrier); + $this->assertEmpty($carrier); + } + + public function test_inject_invalid_context(): void + { + $carrier = []; + $this + ->instanaMultiPropagator + ->inject( + $carrier, + null, + $this->withSpanContext( + SpanContext::create( + SpanContextValidator::INVALID_TRACE, + SpanContextValidator::INVALID_SPAN, + TraceFlags::SAMPLED + ), + Context::getCurrent() + ) + ); + $this->assertEmpty($carrier); + } + + public function test_inject_sampled_context(): void + { + $carrier = []; + $this + ->instanaMultiPropagator + ->inject( + $carrier, + null, + $this->withSpanContext( + SpanContext::create(self::X_INSTANA_T, self::X_INSTANA_S, TraceFlags::SAMPLED), + Context::getCurrent() + ) + ); + + $this->assertSame( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ], + $carrier + ); + } + + public function test_inject_non_sampled_context(): void + { + $carrier = []; + $this + ->instanaMultiPropagator + ->inject( + $carrier, + null, + $this->withSpanContext( + SpanContext::create(self::X_INSTANA_T, self::X_INSTANA_S), + Context::getCurrent() + ) + ); + + $this->assertSame( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_NOT_SAMPLED, + ], + $carrier + ); + } + + public function test_inject_sampled_context_when_other_traceflags_set(): void + { + $carrier = []; + $this + ->instanaMultiPropagator + ->inject( + $carrier, + null, + $this->withSpanContext( + SpanContext::create(self::X_INSTANA_T, self::X_INSTANA_S, traceFlags: 81), + Context::getCurrent() + ) + ); + + $this->assertSame( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ], + $carrier + ); + } + + public function test_extract_context_with_lowercase_headers(): void + { + $carrier = [ + 'x-instana-t' => self::X_INSTANA_T, + 'x-instana-s' => self::X_INSTANA_S, + 'x-instana-l' => '1', + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent(self::X_INSTANA_T, self::X_INSTANA_S, TraceFlags::SAMPLED), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public function test_extract_context_with_no_span_headers(): void + { + + $this->assertInvalid( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + #[DataProvider('sampledValueProvider')] + public function test_extract_sampled_context($sampledValue): void + { + $carrier = [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => $sampledValue, + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent(self::X_INSTANA_T, self::X_INSTANA_S, TraceFlags::SAMPLED), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public static function sampledValueProvider(): array + { + 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->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => $sampledValue, + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent(self::X_INSTANA_T, self::X_INSTANA_S), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public static function notSampledValueProvider(): array + { + return [ + 'String sampled value' => ['0'], + 'Boolean(lower string) sampled value' => ['false'], + 'Boolean(upper string) sampled value' => ['FALSE'], + 'Boolean(camel string) sampled value' => ['False'], + ]; + } + + #[DataProvider('DefaultSampledValueProvider')] + public function test_extract_default_sampled_context($sampledValue): void + { + $carrier = [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => $sampledValue, + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent(self::X_INSTANA_T, self::X_INSTANA_S, TraceFlags::DEFAULT), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public static function DefaultSampledValueProvider(): array + { + return [ + 'null sampled value' => [null], + 'empty sampled value' => [[]], + ]; + } + + #[DataProvider('InvalidSampledValueProvider')] + public function test_extract_invalid_sampled_context($sampledValue): void + { + $carrier = [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => $sampledValue, + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent(self::X_INSTANA_T, self::X_INSTANA_S), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public static function InvalidSampledValueProvider(): array + { + return [ + 'wrong sampled value 1' => ['wrong'], + 'wrong sampled value 2' => ['abcd'], + ]; + } + + public function test_extract_context_with_sampled_no_trace_and_span_headers(): void + { + $carrier = [ + 'X-INSTANA-L' => '1', + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent( + '00000000000000000000000000000000', + '0000000000000000', + TraceFlags::SAMPLED + ), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public function test_extract_context_with_no_trace_and_span_headers(): void + { + $carrier = [ + 'X-INSTANA-L' => '0', + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent( + '00000000000000000000000000000000', + '0000000000000000', + TraceFlags::DEFAULT + ), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public function test_extract_and_inject(): void + { + $extractCarrier = [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ]; + $context = $this->instanaMultiPropagator->extract($extractCarrier); + $injectCarrier = []; + $this->instanaMultiPropagator->inject($injectCarrier, null, $context); + $this->assertSame($injectCarrier, $extractCarrier); + } + + public function test_extract_empty_trace_id(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => '', + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + public function test_extract_leftpad_spand_id(): void + { + $carrier = [ + $this->TRACE_ID => '4aaba1a52cf8ee09', + $this->SPAN_ID => '7b5a2e4d86bd1', + $this->SAMPLED => '1', + ]; + + $this->assertEquals( + SpanContext::createFromRemoteParent('00000000000000004aaba1a52cf8ee09', '0007b5a2e4d86bd1', TraceFlags::SAMPLED), + $this->getSpanContext($this->instanaMultiPropagator->extract($carrier)) + ); + } + + public function test_invalid_trace_id(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => 'abcdefghijklmnopabcdefghijklmnop', + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + public function test_invalid_trace_id_size(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => self::X_INSTANA_T . '00', + $this->SPAN_ID => self::X_INSTANA_S, + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + public function test_extract_empty_span_id(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => '', + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + public function test_invalid_span_id(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => 'abcdefghijklmnop', + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + public function test_invalid_span_id_size(): void + { + $this->assertInvalid( + [ + $this->TRACE_ID => self::X_INSTANA_T, + $this->SPAN_ID => self::X_INSTANA_S . '00', + $this->SAMPLED => self::IS_SAMPLED, + ] + ); + } + + private function assertInvalid(array $carrier): void + { + $this->assertSame( + Context::getCurrent(), + $this->instanaMultiPropagator->extract($carrier), + ); + } + + private function getSpanContext(ContextInterface $context): SpanContextInterface + { + return Span::fromContext($context)->getContext(); + } + + private function withSpanContext(SpanContextInterface $spanContext, ContextInterface $context): ContextInterface + { + return $context->withContextValue(Span::wrap($spanContext)); + } +}