Trace context propagation (#213)
* Add W3C traceparent propagation * Add traceparent propagation test * clean up styling * clean up styling * increase test coverage * fix phan faiure * add propagation map tests * PR comments * fix docblock errors
This commit is contained in:
parent
ed07a9e13b
commit
bed3ec20f2
|
|
@ -14,5 +14,5 @@ interface PropagationSetter
|
||||||
* @param string $value
|
* @param string $value
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function set($carrier, string $key, string $value) : void;
|
public function set(&$carrier, string $key, string $value) : void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface TextMapFormatPropagator
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function fields() : array;
|
public static function fields() : array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the given SpanContext into propagator specific format and injects
|
* Encodes the given SpanContext into propagator specific format and injects
|
||||||
|
|
@ -22,7 +22,7 @@ interface TextMapFormatPropagator
|
||||||
* @param PropagationSetter $setter
|
* @param PropagationSetter $setter
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function inject(SpanContext $context, $carrier, PropagationSetter $setter) : void;
|
public static function inject(SpanContext $context, &$carrier, PropagationSetter $setter) : void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves encoded SpanContext using Getter from the associated carrier.
|
* Retrieves encoded SpanContext using Getter from the associated carrier.
|
||||||
|
|
@ -31,5 +31,5 @@ interface TextMapFormatPropagator
|
||||||
* @param PropagationGetter $getter
|
* @param PropagationGetter $getter
|
||||||
* @return SpanContext
|
* @return SpanContext
|
||||||
*/
|
*/
|
||||||
public function extract($carrier, PropagationGetter $getter): SpanContext;
|
public static function extract($carrier, PropagationGetter $getter): SpanContext;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OpenTelemetry\Sdk\Trace;
|
||||||
|
|
||||||
|
use ArrayAccess;
|
||||||
|
use OpenTelemetry\Trace as API;
|
||||||
|
|
||||||
|
class PropagationMap implements API\PropagationGetter, API\PropagationSetter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function get($carrier, string $key): ?string
|
||||||
|
{
|
||||||
|
$lKey = \strtolower($key);
|
||||||
|
if ($carrier instanceof ArrayAccess) {
|
||||||
|
return $carrier->offsetExists($lKey) ? $carrier->offsetGet($lKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_array($carrier)) {
|
||||||
|
if (empty($carrier)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($carrier as $k => $value) {
|
||||||
|
if (strtolower($k) === $lKey) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid carrier of type %s. Unable to get value associated with key:%s',
|
||||||
|
\is_object($carrier) ? \get_class($carrier) : \gettype($carrier),
|
||||||
|
$key
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function set(&$carrier, string $key, string $value): void
|
||||||
|
{
|
||||||
|
if ($key === '') {
|
||||||
|
throw new \InvalidArgumentException('Unable to set value with an empty key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carrier instanceof ArrayAccess || \is_array($carrier)) {
|
||||||
|
$carrier[\strtolower($key)] = $value;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
'Invalid carrier of type %s. Unable to set value associated with key:%s',
|
||||||
|
\is_object($carrier) ? \get_class($carrier) : \gettype($carrier),
|
||||||
|
$key
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OpenTelemetry\Sdk\Trace;
|
||||||
|
|
||||||
|
use OpenTelemetry\Trace as API;
|
||||||
|
|
||||||
|
final class TraceContext implements API\TextMapFormatPropagator
|
||||||
|
{
|
||||||
|
public const TRACEPARENT = 'http_traceparent';
|
||||||
|
public const TRACESTATE = 'http_tracestate';
|
||||||
|
|
||||||
|
// TODO Consolidate these and the validity checks in SpanContext.php
|
||||||
|
private const SUPPORTED_VERSION = '00';
|
||||||
|
private const INVALID_TRACE = '00000000000000000000000000000000';
|
||||||
|
private const INVALID_SPAN = '0000000000000000';
|
||||||
|
private const VALID_VERSION = '/^[0-9a-f]{2}$/';
|
||||||
|
private const VALID_TRACE = '/^[0-9a-f]{32}$/';
|
||||||
|
private const VALID_SPAN = '/^[0-9a-f]{16}$/';
|
||||||
|
private const VALID_TRACEFLAGS = '/^[0-9a-f]{2}$/';
|
||||||
|
private const SAMPLED_FLAG = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public static function fields(): array
|
||||||
|
{
|
||||||
|
return [self::TRACEPARENT, self::TRACESTATE];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public static function inject(API\SpanContext $context, &$carrier, API\PropagationSetter $setter): void
|
||||||
|
{
|
||||||
|
$traceparent = self::SUPPORTED_VERSION . '-' . $context->getTraceId() . '-' . $context->getSpanId() . '-' . ($context->isSampled() ? '01' : '00');
|
||||||
|
$setter->set($carrier, self::TRACEPARENT, $traceparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public static function extract($carrier, API\PropagationGetter $getter): API\SpanContext
|
||||||
|
{
|
||||||
|
$traceparent = $getter->get($carrier, self::TRACEPARENT);
|
||||||
|
if ($traceparent === null) {
|
||||||
|
throw new \InvalidArgumentException('Traceparent not present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traceparent = {version}-{trace-id}-{parent-id}-{trace-flags}
|
||||||
|
$pieces = explode('-', $traceparent);
|
||||||
|
|
||||||
|
$peicesCount = count($pieces);
|
||||||
|
if ($peicesCount != 4) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('Unable to extract traceparent. Expected 4 values, got %d', $peicesCount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the traceparent version. Currently only '00' is supported.
|
||||||
|
$version = $pieces[0];
|
||||||
|
if ((preg_match(self::VALID_VERSION, $version) === 0) || ($version !== self::SUPPORTED_VERSION)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('Only version 00 is supported, got %s', $version)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$traceId = $pieces[1];
|
||||||
|
if ((preg_match(self::VALID_TRACE, $traceId) === 0) || ($traceId === self::INVALID_TRACE)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('TraceID must be exactly 16 bytes (32 chars) and at least one non-zero byte, got %s', $traceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$spanId = $pieces[2];
|
||||||
|
if ((preg_match(self::VALID_SPAN, $spanId) === 0) || ($spanId === self::INVALID_SPAN)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('SpanID must be exactly 8 bytes (16 chars) and at least one non-zero byte, got %s', $spanId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$traceFlags = $pieces[3];
|
||||||
|
if (preg_match(self::VALID_TRACEFLAGS, $traceFlags) === 0) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('TraceFlags must be exactly 1 bytes (1 char) representing a bit field, got %s', $traceFlags)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the sampled flag is extracted from the traceFlags (00000001)
|
||||||
|
$convertedTraceFlags = hexdec($traceFlags);
|
||||||
|
$isSampled = ($convertedTraceFlags & self::SAMPLED_FLAG) === self::SAMPLED_FLAG;
|
||||||
|
|
||||||
|
return SpanContext::restore($traceId, $spanId, $isSampled, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OpenTelemetry\Tests\Sdk\Unit\Trace;
|
||||||
|
|
||||||
|
use ArrayObject;
|
||||||
|
use OpenTelemetry\Sdk\Trace\PropagationMap;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
class PropagationMapTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testGetFromMapArray()
|
||||||
|
{
|
||||||
|
$carrier = ['a' => 'alpha'];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$value = $map->get($carrier, 'a');
|
||||||
|
$this->assertSame('alpha', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testGetFromMapArrayAccess()
|
||||||
|
{
|
||||||
|
$carrier = new ArrayObject(['a' => 'alpha']);
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$value = $map->get($carrier, 'a');
|
||||||
|
$this->assertSame('alpha', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testGetFromUnsupportedCarrier()
|
||||||
|
{
|
||||||
|
$carrier = new stdClass();
|
||||||
|
$map = new PropagationMap();
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$map->get($carrier, 'a');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInvalidGetFromMap()
|
||||||
|
{
|
||||||
|
$carrier = ['a' => 'alpha'];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->assertNull($map->get([], 'a'));
|
||||||
|
$this->assertNull($map->get($carrier, 'b'));
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$value = $map->get('invalid carrier', 'a');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testSetMapArray()
|
||||||
|
{
|
||||||
|
$carrier = ['a' => 'alpha'];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$map->set($carrier, 'b', 'bravo');
|
||||||
|
$value = $map->get($carrier, 'b');
|
||||||
|
$this->assertSame('bravo', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testSetMapArrayAccess()
|
||||||
|
{
|
||||||
|
$carrier = new ArrayObject(['a' => 'alpha']);
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$map->set($carrier, 'b', 'bravo');
|
||||||
|
$value = $map->get($carrier, 'b');
|
||||||
|
$this->assertSame('bravo', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testSetUnsupportedCarrier()
|
||||||
|
{
|
||||||
|
$carrier = new stdClass();
|
||||||
|
$map = new PropagationMap();
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid carrier of type ' . \get_class($carrier) . '. Unable to set value associated with key:a');
|
||||||
|
$map->set($carrier, 'a', 'alpha');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testSetEmptyKey()
|
||||||
|
{
|
||||||
|
$carrier = ['a' => 'alpha'];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unable to set value with an empty key');
|
||||||
|
$map->set($carrier, '', 'alpha');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OpenTelemetry\Tests\Sdk\Unit\Trace;
|
||||||
|
|
||||||
|
use OpenTelemetry\Sdk\Trace\PropagationMap;
|
||||||
|
use OpenTelemetry\Sdk\Trace\SpanContext;
|
||||||
|
use OpenTelemetry\Sdk\Trace\TraceContext;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class TraceContextTest extends TestCase
|
||||||
|
{
|
||||||
|
private const VERSION = '00';
|
||||||
|
private const TRACEID = '00000000000000000000000000000032';
|
||||||
|
private const SPANID = '0000000000000016';
|
||||||
|
private const SAMPLED = '01';
|
||||||
|
private const TRACEPARENTVALUE = self::VERSION . '-' . self::TRACEID . '-' . self::SPANID . '-' . self::SAMPLED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testTraceContextFields()
|
||||||
|
{
|
||||||
|
$fields = TraceContext::fields();
|
||||||
|
$this->assertSame($fields[0], TraceContext::TRACEPARENT);
|
||||||
|
$this->assertSame($fields[1], TraceContext::TRACESTATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testExtractValidTraceContext()
|
||||||
|
{
|
||||||
|
$traceparentValues = [self::TRACEPARENTVALUE, // sampled == true
|
||||||
|
self::VERSION . '-' . self::TRACEID . '-' . self::SPANID . '-00', ]; // sampled == false
|
||||||
|
|
||||||
|
foreach ($traceparentValues as $traceparentValue) {
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $traceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
$extractedTraceparent = '00-' . $context->getTraceId() . '-' . $context->getSpanId() . '-' . ($context->isSampled() ? '01' : '00');
|
||||||
|
$this->assertSame($traceparentValue, $extractedTraceparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testExtractInvalidTraceContext()
|
||||||
|
{
|
||||||
|
$carrier = [];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Traceparent not present');
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInjectValidTraceContext()
|
||||||
|
{
|
||||||
|
$carrier = [];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
$context = SpanContext::restore(self::TRACEID, self::SPANID, true, false);
|
||||||
|
TraceContext::inject($context, $carrier, $map);
|
||||||
|
|
||||||
|
$this->assertSame(self::TRACEPARENTVALUE, $map->get($carrier, TraceContext::TRACEPARENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testTraceparentLength()
|
||||||
|
{
|
||||||
|
$invalidValues = [self::TRACEPARENTVALUE . '-extra', // Length > 4 values
|
||||||
|
self::VERSION . '-' . self::SPANID . '-' . self::SAMPLED, ]; // Length < 4 values
|
||||||
|
|
||||||
|
foreach ($invalidValues as $invalidTraceparentValue) {
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $invalidTraceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unable to extract traceparent. Expected 4 values, got ' . 5);
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInvalidTraceparentVersion()
|
||||||
|
{
|
||||||
|
$invalidValues = ['ff', // invalid hex value
|
||||||
|
'003', // Length > 2
|
||||||
|
'1', // Length < 2
|
||||||
|
'0j', ]; // Hex character != 'a - f or 0 - 9'
|
||||||
|
|
||||||
|
$buildTraceparent = self::TRACEID . '-' . self::SPANID . '-' . self::SAMPLED;
|
||||||
|
|
||||||
|
foreach ($invalidValues as $invalidVersion) {
|
||||||
|
$traceparentValue = $invalidVersion . '-' . $buildTraceparent;
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $traceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Only version 00 is supported, got ' . $invalidVersion);
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInvalidTraceparentTraceId()
|
||||||
|
{
|
||||||
|
$invalidValues = ['00000000000000000000000000000000', // All zeros
|
||||||
|
'000000000000000000000000000000033', // Length > 32
|
||||||
|
'0000000000000000000000000000031', // Length < 32
|
||||||
|
'000000000000000000000g0000000032', ]; // Hex character != 'a - f or 0 - 9'
|
||||||
|
|
||||||
|
foreach ($invalidValues as $invalidTraceId) {
|
||||||
|
$traceparentValue = self::VERSION . '-' . $invalidTraceId . '-' . self::SPANID . '-' . self::SAMPLED;
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $traceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('TraceID must be exactly 16 bytes (32 chars) and at least one non-zero byte, got ' . $invalidTraceId);
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInvalidTraceparentSpanId()
|
||||||
|
{
|
||||||
|
$invalidValues = ['0000000000000000', // All zeros
|
||||||
|
'00000000000000017', // Length > 16
|
||||||
|
'000000000000015', // Length < 16
|
||||||
|
'00000000*0000016', ]; // Hex character != 'a - f or 0 - 9'
|
||||||
|
|
||||||
|
foreach ($invalidValues as $invalidSpanId) {
|
||||||
|
$traceparentValue = self::VERSION . '-' . self::TRACEID . '-' . $invalidSpanId . '-' . self::SAMPLED;
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $traceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('SpanID must be exactly 8 bytes (16 chars) and at least one non-zero byte, got ' . $invalidSpanId);
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
*/
|
||||||
|
public function testInvalidTraceFlags()
|
||||||
|
{
|
||||||
|
$invalidValues = ['003', // Length > 2
|
||||||
|
'1', // Length < 2
|
||||||
|
'0g', ]; // Hex character != 'a - f or 0 - 9'
|
||||||
|
|
||||||
|
$buildTraceperent = self::VERSION . '-' . self::TRACEID . '-' . self::SPANID;
|
||||||
|
foreach ($invalidValues as $invalidTraceFlag) {
|
||||||
|
$traceparentValue = $buildTraceperent . '-' . $invalidTraceFlag;
|
||||||
|
$carrier = [TraceContext::TRACEPARENT => $traceparentValue];
|
||||||
|
$map = new PropagationMap();
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('TraceFlags must be exactly 1 bytes (1 char) representing a bit field, got ' . $invalidTraceFlag);
|
||||||
|
$context = TraceContext::extract($carrier, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue