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

Issue:
OTLP log exporter mangles Body
OTLP exporter does not correctly handle nested maps and arrays in the Body of the LogRecord.

**Steps to reproduce**
```php
$record = (new LogRecord(['foo' => 'bar', 'baz' => 'bat', 'msg' => 'hello world', 'complex' => [1, 'a'], 'nested' => [123, 'abc', ['sub'=>'val']]]))
$logger->emit($record);
```

**What is the expected behavior?**
The following OTLP/JSON Body should we exported (warning: hand-crafted, likely contains typos):
```json
{
    "arrayValue": {
        "values": [
            {
                "key": "foo",
                "value": {
                    "stringValue": "bar"
                }
            },
            {
                "key": "baz",
                "value": {
                    "stringValue": "bat"
                }
            },
            {
                "key": "msg",
                "value": {
                    "stringValue": "hello world"
                }
            },
            {
                "key": "complex",
                "value": {
                    "arrayValue": {
                        "values": [
                            {
                                "intValue": "1"
                            },
                            {
                                "stringValue": "a"
                            }
                        ]
                    }
                }
            },
            {
                "key": "nested",
                "value": {
                    "arrayValue": {
                        "values": [
                            {
                                "intValue": "123"
                            },
                            {
                                "stringValue": "abc"
                            },
                            {
                                "kvlistValue": {
                                    "values":
                                    [
                                        {
                                            "key": "sub",
                                            "value": {
                                                "stringValue": "val"
                                            }
                                        }
                                    ]
                                }
                            }
                        ]
                    }
                }
            }
        ]
    }
}
```

**What is the actual behavior?**
The following Body was exported:
```json
{
    "arrayValue": {
        "values": [
            {
                "stringValue": "bar"
            },
            {
                "stringValue": "bat"
            },
            {
                "stringValue": "hello world"
            },
            {
                "arrayValue": {
                    "values": [
                        {
                            "intValue": "1"
                        },
                        {
                            "stringValue": "a"
                        }
                    ]
                }
            },
            {
                "arrayValue": {
                    "values": [
                        {
                            "intValue": "123"
                        },
                        {
                            "stringValue": "abc"
                        },
                        {
                            "arrayValue": {
                                "values": [
                                    {
                                        "stringValue": "val"
                                    }
                                ]
                            }
                        }
                    ]
                }
            }
        ]
    }
}
```

**Additional context**
Part of https://github.com/open-telemetry/community/issues/1537

Pull request description:
handle KeyValue otlp attributes
if an array is non-simple (has keys), it should be represented as a KeyValue in the generated protobuf-encoded output.
Since we also need to support simple arrays of primitives, I've added a basic check so that those are still represented as ArrayValue.

Fixes #1076 

Patch:
diff --git a/examples/logs/exporters/otlp_http.php b/examples/logs/exporters/otlp_http.php
index 1b6bc894..85cb00c1 100644
--- a/examples/logs/exporters/otlp_http.php
+++ b/examples/logs/exporters/otlp_http.php
@@ -35,7 +35,7 @@ $loggerProvider = new LoggerProvider(
         (new LogRecordLimitsBuilder())->build()->getAttributeFactory()
     )
 );
-$logger = $loggerProvider->getLogger('demo', '1.0', 'http://schema.url', ['foo' => 'bar']);
+$logger = $loggerProvider->getLogger('demo', '1.0', 'https://opentelemetry.io/schemas/1.7.1', ['foo' => 'bar']);
 $eventLogger = new EventLogger($logger, 'my-domain');
 
 $record = (new LogRecord(['foo' => 'bar', 'baz' => 'bat', 'msg' => 'hello world']))
diff --git a/examples/metrics/weak-reference-observables.php b/examples/metrics/weak-reference-observables.php
new file mode 100644
index 00000000..af70481d
--- /dev/null
+++ b/examples/metrics/weak-reference-observables.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+use OpenTelemetry\API\Metrics\ObserverInterface;
+use OpenTelemetry\SDK\Metrics\Data\Temporality;
+use OpenTelemetry\SDK\Metrics\MeterProvider;
+use OpenTelemetry\SDK\Metrics\MetricExporter\ConsoleMetricsExporter;
+use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;
+use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
+
+require 'vendor/autoload.php';
+
+/**
+ * Example of using `weaken` on observer callbacks. Creates a weak reference to the
+ * callback, so that internal references to the callback do not stop garbage collection on
+ * the original object
+ */
+
+$reader = new ExportingReader(
+    new ConsoleMetricsExporter(Temporality::DELTA)
+);
+
+$meterProvider = MeterProvider::builder()
+    ->setResource(ResourceInfoFactory::emptyResource())
+    ->addReader($reader)
+    ->build();
+
+$callback = new class() {
+    public function __invoke(ObserverInterface $observer)
+    {
+        $observer->observe(random_int(1, 10));
+    }
+};
+
+$meterProvider
+    ->getMeter('demo_meter')
+    ->createObservableGauge('number', 'items', 'Random number')
+    ->observe($callback, true); //weak-ref to callback
+
+$reader->collect(); //metrics (data-points) collected (callback invoked)
+unset($callback);
+$reader->collect(); //no metrics (data-points) collected, because the callback was garbage-collected due to weak-ref
diff --git a/src/API/Metrics/ObservableCounterInterface.php b/src/API/Metrics/ObservableCounterInterface.php
index e067027d..2c1620e2 100644
--- a/src/API/Metrics/ObservableCounterInterface.php
+++ b/src/API/Metrics/ObservableCounterInterface.php
@@ -10,6 +10,8 @@ interface ObservableCounterInterface
     /**
      * @param callable(ObserverInterface): void $callback function responsible for
      *        reporting the measurements (as absolute values)
+     * @param bool $weaken Create a weak reference to the callback so that it
+     *        does not stop garbage collection
      * @return ObservableCallbackInterface token to detach callback
      */
     public function observe(callable $callback, bool $weaken = false): ObservableCallbackInterface;
diff --git a/src/API/Metrics/ObservableGaugeInterface.php b/src/API/Metrics/ObservableGaugeInterface.php
index f80cf0d5..6a59b67a 100644
--- a/src/API/Metrics/ObservableGaugeInterface.php
+++ b/src/API/Metrics/ObservableGaugeInterface.php
@@ -10,6 +10,8 @@ interface ObservableGaugeInterface
     /**
      * @param callable(ObserverInterface): void $callback function responsible for
      *        reporting the measurements
+     * @param bool $weaken Create a weak reference to the callback so that it
+     *        does not stop garbage collection
      * @return ObservableCallbackInterface token to detach callback
      */
     public function observe(callable $callback, bool $weaken = false): ObservableCallbackInterface;
diff --git a/src/API/Metrics/ObservableUpDownCounterInterface.php b/src/API/Metrics/ObservableUpDownCounterInterface.php
index 8a140f96..455af4d0 100644
--- a/src/API/Metrics/ObservableUpDownCounterInterface.php
+++ b/src/API/Metrics/ObservableUpDownCounterInterface.php
@@ -10,6 +10,8 @@ interface ObservableUpDownCounterInterface
     /**
      * @param callable(ObserverInterface): void $callback function responsible for
      *        reporting the measurements (as absolute values)
+     * @param bool $weaken Create a weak reference to the callback so that it
+     *        does not stop garbage collection
      * @return ObservableCallbackInterface token to detach callback
      */
     public function observe(callable $callback, bool $weaken = false): ObservableCallbackInterface;
diff --git a/src/Contrib/Otlp/AttributesConverter.php b/src/Contrib/Otlp/AttributesConverter.php
index 8989f73f..4a349ab6 100644
--- a/src/Contrib/Otlp/AttributesConverter.php
+++ b/src/Contrib/Otlp/AttributesConverter.php
@@ -11,6 +11,8 @@ use function is_int;
 use function is_string;
 use Opentelemetry\Proto\Common\V1\AnyValue;
 use Opentelemetry\Proto\Common\V1\ArrayValue;
+use Opentelemetry\Proto\Common\V1\KeyValue;
+use Opentelemetry\Proto\Common\V1\KeyValueList;
 
 final class AttributesConverter
 {
@@ -18,12 +20,21 @@ final class AttributesConverter
     {
         $result = new AnyValue();
         if (is_array($value)) {
-            $values = new ArrayValue();
-            foreach ($value as $element) {
-                /** @psalm-suppress InvalidArgument */
-                $values->getValues()[] = self::convertAnyValue($element);
+            if (self::isSimpleArray($value)) {
+                $values = new ArrayValue();
+                foreach ($value as $element) {
+                    /** @psalm-suppress InvalidArgument */
+                    $values->getValues()[] = self::convertAnyValue($element);
+                }
+                $result->setArrayValue($values);
+            } else {
+                $values = new KeyValueList();
+                foreach ($value as $key => $element) {
+                    /** @psalm-suppress InvalidArgument */
+                    $values->getValues()[] = new KeyValue(['key' => $key, 'value' => self::convertAnyValue($element)]);
+                }
+                $result->setKvlistValue($values);
             }
-            $result->setArrayValue($values);
         }
         if (is_int($value)) {
             $result->setIntValue($value);
@@ -40,4 +51,12 @@ final class AttributesConverter
 
         return $result;
     }
+
+    /**
+     * Test whether an array is simple (non-KeyValue)
+     */
+    public static function isSimpleArray(array $value): bool
+    {
+        return $value === [] || array_key_first($value) === 0;
+    }
 }
diff --git a/src/Contrib/Otlp/LogsConverter.php b/src/Contrib/Otlp/LogsConverter.php
index 07888769..1da53ad1 100644
--- a/src/Contrib/Otlp/LogsConverter.php
+++ b/src/Contrib/Otlp/LogsConverter.php
@@ -110,6 +110,7 @@ class LogsConverter
         $this->setAttributes($pInstrumentationScope, $instrumentationScope->getAttributes());
         $pInstrumentationScope->setDroppedAttributesCount($instrumentationScope->getAttributes()->getDroppedAttributesCount());
         $pScopeLogs->setScope($pInstrumentationScope);
+        $pScopeLogs->setSchemaUrl((string) $instrumentationScope->getSchemaUrl());
 
         return $pScopeLogs;
     }
diff --git a/src/SDK/Common/Attribute/AttributeValidator.php b/src/SDK/Common/Attribute/AttributeValidator.php
new file mode 100644
index 00000000..e9a1f733
--- /dev/null
+++ b/src/SDK/Common/Attribute/AttributeValidator.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\SDK\Common\Attribute;
+
+class AttributeValidator implements AttributeValidatorInterface
+{
+    private const PRIMITIVES = [
+        'string',
+        'integer',
+        'double',
+        'boolean',
+    ];
+    private const NUMERICS = [
+        'double',
+        'integer',
+    ];
+
+    /**
+     * Validate whether a value is a primitive, or a homogeneous array of primitives (treating int/double as equivalent).
+     * @see https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/common/README.md#attribute
+     */
+    public function validate($value): bool
+    {
+        if (is_array($value)) {
+            return $this->validateArray($value);
+        }
+
+        return in_array(gettype($value), self::PRIMITIVES);
+    }
+
+    private function validateArray(array $value): bool
+    {
+        if ($value === []) {
+            return true;
+        }
+        $type = gettype(reset($value));
+        if (!in_array($type, self::PRIMITIVES)) {
+            return false;
+        }
+        foreach ($value as $v) {
+            if (in_array(gettype($v), self::NUMERICS) && in_array($type, self::NUMERICS)) {
+                continue;
+            }
+            if (gettype($v) !== $type) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    public function getInvalidMessage(): string
+    {
+        return 'attribute with non-primitive or non-homogeneous array of primitives dropped';
+    }
+}
diff --git a/src/SDK/Common/Attribute/AttributeValidatorInterface.php b/src/SDK/Common/Attribute/AttributeValidatorInterface.php
new file mode 100644
index 00000000..afbfba6e
--- /dev/null
+++ b/src/SDK/Common/Attribute/AttributeValidatorInterface.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\SDK\Common\Attribute;
+
+interface AttributeValidatorInterface
+{
+    public function validate($value): bool;
+    public function getInvalidMessage(): string;
+}
diff --git a/src/SDK/Common/Attribute/AttributesBuilder.php b/src/SDK/Common/Attribute/AttributesBuilder.php
index fc5af3e6..5c115063 100644
--- a/src/SDK/Common/Attribute/AttributesBuilder.php
+++ b/src/SDK/Common/Attribute/AttributesBuilder.php
@@ -6,7 +6,6 @@ namespace OpenTelemetry\SDK\Common\Attribute;
 
 use function array_key_exists;
 use function count;
-use function gettype;
 use function is_array;
 use function is_string;
 use function mb_substr;
@@ -23,14 +22,20 @@ final class AttributesBuilder implements AttributesBuilderInterface
     private ?int $attributeCountLimit;
     private ?int $attributeValueLengthLimit;
     private int $droppedAttributesCount;
-    private const NUMERICS = ['integer', 'double'];
-
-    public function __construct(array $attributes, ?int $attributeCountLimit, ?int $attributeValueLengthLimit, int $droppedAttributesCount)
-    {
+    private AttributeValidatorInterface $attributeValidator;
+
+    public function __construct(
+        array $attributes,
+        ?int $attributeCountLimit,
+        ?int $attributeValueLengthLimit,
+        int $droppedAttributesCount,
+        ?AttributeValidatorInterface $attributeValidator
+    ) {
         $this->attributes = $attributes;
         $this->attributeCountLimit = $attributeCountLimit;
         $this->attributeValueLengthLimit = $attributeValueLengthLimit;
         $this->droppedAttributesCount = $droppedAttributesCount;
+        $this->attributeValidator = $attributeValidator ?? new AttributeValidator();
     }
 
     public function build(): AttributesInterface
@@ -66,8 +71,9 @@ final class AttributesBuilder implements AttributesBuilderInterface
 
             return;
         }
-        if (is_array($value) && !$this->isHomogeneous($value)) {
-            self::logWarning('attribute with non-homogeneous array values dropped: ' . $offset);
+        if (!$this->attributeValidator->validate($value)) {
+            self::logWarning($this->attributeValidator->getInvalidMessage() . ': ' . $offset);
+            $this->droppedAttributesCount++;
 
             return;
         }
@@ -111,26 +117,4 @@ final class AttributesBuilder implements AttributesBuilderInterface
 
         return $value;
     }
-
-    /**
-     * Test whether an array contains only values of the same type, treating int/double as equivalent.
-     * @see https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/common/README.md#attribute
-     */
-    private function isHomogeneous(array $value): bool
-    {
-        if (count($value) <= 1) {
-            return true;
-        }
-        $type = gettype(reset($value));
-        foreach ($value as $v) {
-            if (in_array(gettype($v), self::NUMERICS) && in_array($type, self::NUMERICS)) {
-                continue;
-            }
-            if (gettype($v) !== $type) {
-                return false;
-            }
-        }
-
-        return true;
-    }
 }
diff --git a/src/SDK/Common/Attribute/AttributesFactory.php b/src/SDK/Common/Attribute/AttributesFactory.php
index 0db02cae..d53ab25a 100644
--- a/src/SDK/Common/Attribute/AttributesFactory.php
+++ b/src/SDK/Common/Attribute/AttributesFactory.php
@@ -18,13 +18,14 @@ final class AttributesFactory implements AttributesFactoryInterface
         $this->attributeValueLengthLimit = $attributeValueLengthLimit;
     }
 
-    public function builder(iterable $attributes = []): AttributesBuilderInterface
+    public function builder(iterable $attributes = [], ?AttributeValidatorInterface $attributeValidator = null): AttributesBuilderInterface
     {
         $builder = new AttributesBuilder(
             [],
             $this->attributeCountLimit,
             $this->attributeValueLengthLimit,
             0,
+            $attributeValidator,
         );
         foreach ($attributes as $key => $value) {
             $builder[$key] = $value;
diff --git a/src/SDK/Common/Attribute/AttributesFactoryInterface.php b/src/SDK/Common/Attribute/AttributesFactoryInterface.php
index 79ef3934..1b74461d 100644
--- a/src/SDK/Common/Attribute/AttributesFactoryInterface.php
+++ b/src/SDK/Common/Attribute/AttributesFactoryInterface.php
@@ -6,5 +6,5 @@ namespace OpenTelemetry\SDK\Common\Attribute;
 
 interface AttributesFactoryInterface
 {
-    public function builder(iterable $attributes = []): AttributesBuilderInterface;
+    public function builder(iterable $attributes = [], ?AttributeValidatorInterface $attributeValidator = null): AttributesBuilderInterface;
 }
diff --git a/src/SDK/Common/Attribute/FilteredAttributesFactory.php b/src/SDK/Common/Attribute/FilteredAttributesFactory.php
index 74026ad2..1d9c4ae1 100644
--- a/src/SDK/Common/Attribute/FilteredAttributesFactory.php
+++ b/src/SDK/Common/Attribute/FilteredAttributesFactory.php
@@ -21,9 +21,9 @@ final class FilteredAttributesFactory implements AttributesFactoryInterface
         $this->rejectedKeys = $rejectedKeys;
     }
 
-    public function builder(iterable $attributes = []): AttributesBuilderInterface
+    public function builder(iterable $attributes = [], ?AttributeValidatorInterface $attributeValidator = null): AttributesBuilderInterface
     {
-        $builder = new FilteredAttributesBuilder($this->factory->builder(), $this->rejectedKeys);
+        $builder = new FilteredAttributesBuilder($this->factory->builder([], $attributeValidator), $this->rejectedKeys);
         foreach ($attributes as $attribute => $value) {
             $builder[$attribute] = $value;
         }
diff --git a/src/SDK/Common/Attribute/LogRecordAttributeValidator.php b/src/SDK/Common/Attribute/LogRecordAttributeValidator.php
new file mode 100644
index 00000000..a09d2637
--- /dev/null
+++ b/src/SDK/Common/Attribute/LogRecordAttributeValidator.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\SDK\Common\Attribute;
+
+class LogRecordAttributeValidator implements AttributeValidatorInterface
+{
+    public function validate($value): bool
+    {
+        return true;
+    }
+
+    public function getInvalidMessage(): string
+    {
+        //not required as this validator always returns true
+        return 'unused';
+    }
+}
diff --git a/src/SDK/Logs/ReadableLogRecord.php b/src/SDK/Logs/ReadableLogRecord.php
index 74666b30..5c653147 100644
--- a/src/SDK/Logs/ReadableLogRecord.php
+++ b/src/SDK/Logs/ReadableLogRecord.php
@@ -10,6 +10,7 @@ use OpenTelemetry\API\Trace\SpanContextInterface;
 use OpenTelemetry\Context\Context;
 use OpenTelemetry\Context\ContextInterface;
 use OpenTelemetry\SDK\Common\Attribute\AttributesInterface;
+use OpenTelemetry\SDK\Common\Attribute\LogRecordAttributeValidator;
 use OpenTelemetry\SDK\Common\Instrumentation\InstrumentationScopeInterface;
 use OpenTelemetry\SDK\Resource\ResourceInfo;
 
@@ -43,7 +44,7 @@ class ReadableLogRecord extends LogRecord
         $this->convertedAttributes = $this->loggerSharedState
             ->getLogRecordLimits()
             ->getAttributeFactory()
-            ->builder($logRecord->attributes)
+            ->builder($logRecord->attributes, new LogRecordAttributeValidator())
             ->build();
     }
 


Test patch:
diff --git a/tests/Unit/API/Behavior/LogsMessagesTraitTest.php b/tests/Unit/API/Behavior/LogsMessagesTraitTest.php
index 7357a053..4e5da5aa 100644
--- a/tests/Unit/API/Behavior/LogsMessagesTraitTest.php
+++ b/tests/Unit/API/Behavior/LogsMessagesTraitTest.php
@@ -16,7 +16,6 @@ use Psr\Log\LogLevel;
 
 /**
  * @covers \OpenTelemetry\API\Behavior\LogsMessagesTrait
- * @covers \OpenTelemetry\API\Behavior\Logging
  */
 class LogsMessagesTraitTest extends TestCase
 {
diff --git a/tests/Unit/Contrib/Otlp/AttributesConverterTest.php b/tests/Unit/Contrib/Otlp/AttributesConverterTest.php
new file mode 100644
index 00000000..c11924ac
--- /dev/null
+++ b/tests/Unit/Contrib/Otlp/AttributesConverterTest.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\Tests\Unit\Contrib\Otlp;
+
+use OpenTelemetry\Contrib\Otlp\AttributesConverter;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \OpenTelemetry\Contrib\Otlp\AttributesConverter
+ */
+class AttributesConverterTest extends TestCase
+{
+    /**
+     * @dataProvider basicTypesProvider
+     */
+    public function test_convert_basic_types($value, string $expected): void
+    {
+        $json = AttributesConverter::convertAnyValue($value)->serializeToJsonString();
+        $this->assertJsonStringEqualsJsonString($json, $expected);
+    }
+
+    public static function basicTypesProvider(): array
+    {
+        return [
+            'string' => ['foo', '{"stringValue":"foo"}'],
+            'bool:true' => [true, '{"boolValue":true}'],
+            'bool:false' => [false, '{"boolValue":false}'],
+            'int:zero' => [0, '{"intValue":"0"}'],
+            'int:positive' => [3, '{"intValue":"3"}'],
+            'int:negative' => [-2, '{"intValue":"-2"}'],
+            'float:zero' => [0.0, '{"doubleValue":0.0}'],
+            'float:positive' => [3.14159, '{"doubleValue":3.14159}'],
+            'float:negative' => [-2.7, '{"doubleValue":-2.7}'],
+        ];
+    }
+
+    /**
+     * @dataProvider arrayProvider
+     */
+    public function test_is_simple_array(array $value, bool $expected): void
+    {
+        $this->assertSame($expected, AttributesConverter::isSimpleArray($value));
+    }
+
+    public static function arrayProvider(): array
+    {
+        return [
+            'simple' => [[1,2,3,4], true],
+            'empty' => [[], true],
+            'kv' => [['key' => 'value'], false],
+        ];
+    }
+
+    public function test_homogeneous_array_of_primitives(): void
+    {
+        $expected = '{"arrayValue":{"values":[{"intValue":"1"},{"intValue":"2"},{"intValue":"3"},{"intValue":"4"},{"intValue":"5"}]}}';
+        $json = AttributesConverter::convertAnyValue([1,2,3,4,5])->serializeToJsonString();
+        $this->assertJsonStringEqualsJsonString($json, $expected);
+    }
+
+    public function test_complex_array(): void
+    {
+        $expected = '{"kvlistValue":{"values":[{"key":"nested","value":{"arrayValue":{"values":[{"intValue":"123"},{"stringValue":"abc"},{"kvlistValue":{"values":[{"key":"sub","value":{"stringValue":"val"}}]}}]}}}]}}';
+        $json = AttributesConverter::convertAnyValue(['nested' => [123, 'abc', ['sub'=>'val']]])->serializeToJsonString();
+        $this->assertJsonStringEqualsJsonString($json, $expected);
+    }
+
+    public function test_kv_array_with_array_values(): void
+    {
+        $expected = '{"kvlistValue":{"values":[{"key":"nested","value":{"arrayValue":{"values":[{"intValue":"123"},{"intValue":"456"}]}}}]}}';
+        $json = AttributesConverter::convertAnyValue(['nested' => [123, 456]])->serializeToJsonString();
+        $this->assertJsonStringEqualsJsonString($json, $expected);
+    }
+}
diff --git a/tests/Unit/SDK/Common/Attribute/AttributeValidatorTest.php b/tests/Unit/SDK/Common/Attribute/AttributeValidatorTest.php
new file mode 100644
index 00000000..ba2389f6
--- /dev/null
+++ b/tests/Unit/SDK/Common/Attribute/AttributeValidatorTest.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OpenTelemetry\Tests\Unit\SDK\Common\Attribute;
+
+use OpenTelemetry\SDK\Common\Attribute\AttributeValidator;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \OpenTelemetry\SDK\Common\Attribute\AttributeValidator
+ */
+class AttributeValidatorTest extends TestCase
+{
+    private AttributeValidator $validator;
+
+    public function setUp(): void
+    {
+        $this->validator = new AttributeValidator();
+    }
+
+    /**
+     * @dataProvider primitiveProvider
+     */
+    public function test_validate_primitives($value): void
+    {
+        $this->assertTrue($this->validator->validate($value));
+    }
+
+    public static function primitiveProvider(): array
+    {
+        return [
+            'bool true' => [true],
+            'bool false' => [false],
+            'string' => ['hello otel'],
+            'int' => [4],
+            'double' => [3.14159],
+        ];
+    }
+
+    /**
+     * @dataProvider nonPrimitiveProvider
+     */
+    public function test_validate_non_primitives($value): void
+    {
+        $this->assertFalse($this->validator->validate($value));
+    }
+
+    public static function nonPrimitiveProvider(): array
+    {
+        return [
+            'object' => [new \stdClass()],
+            'null' => [null],
+            'resource' => [tmpfile()],
+        ];
+    }
+
+    /**
+     * @dataProvider arrayProvider
+     */
+    public function test_validate_array($value, bool $expected): void
+    {
+        $this->assertSame($expected, $this->validator->validate($value));
+    }
+
+    public static function arrayProvider(): array
+    {
+        return [
+            'empty array' => [[], true],
+            'array of strings' => [['one', 'two'], true],
+            'array of ints' => [[1, 2], true],
+            'array of double' => [[1.1, 1.2], true],
+            'mixed numerics' => [[2, 3.5, PHP_INT_MAX], true],
+            'array of bool' => [[true, false], true],
+            'mixed array' => [[true, 'one', 2], false],
+            'complex array' => [['one' => ['one']], false],
+        ];
+    }
+}
diff --git a/tests/Unit/SDK/Logs/ReadableLogRecordTest.php b/tests/Unit/SDK/Logs/ReadableLogRecordTest.php
index ac409a37..1c946278 100644
--- a/tests/Unit/SDK/Logs/ReadableLogRecordTest.php
+++ b/tests/Unit/SDK/Logs/ReadableLogRecordTest.php
@@ -19,34 +19,62 @@ use PHPUnit\Framework\TestCase;
  */
 class ReadableLogRecordTest extends TestCase
 {
-    public function test_getters(): void
+    private InstrumentationScopeInterface $scope;
+    private ContextInterface $context;
+    private LoggerSharedState $sharedState;
+    private ResourceInfo $resource;
+    public function setUp(): void
     {
-        $scope = $this->createMock(InstrumentationScopeInterface::class);
-        $sharedState = $this->createMock(LoggerSharedState::class);
-        $context = $this->createMock(ContextInterface::class);
-        $resource = $this->createMock(ResourceInfo::class);
+        $this->scope = $this->createMock(InstrumentationScopeInterface::class);
+        $this->sharedState = $this->createMock(LoggerSharedState::class);
+        $this->context = $this->createMock(ContextInterface::class);
+        $this->resource = $this->createMock(ResourceInfo::class);
         $limits = $this->createMock(LogRecordLimits::class);
         $attributeFactory = new AttributesFactory();
         $limits->method('getAttributeFactory')->willReturn($attributeFactory); //final
-        $sharedState->method('getResource')->willReturn($resource);
-        $sharedState->method('getLogRecordLimits')->willReturn($limits);
+        $this->sharedState->method('getResource')->willReturn($this->resource);
+        $this->sharedState->method('getLogRecordLimits')->willReturn($limits);
+    }
+
+    public function test_getters(): void
+    {
         $logRecord = (new LogRecord('body'))
             ->setSeverityNumber(5)
             ->setSeverityText('info')
             ->setTimestamp(11)
             ->setObservedTimestamp(22)
             ->setAttributes(['foo' => 'bar'])
-            ->setContext($context);
-        $record = new ReadableLogRecord($scope, $sharedState, $logRecord);
+            ->setContext($this->context);
+        $record = new ReadableLogRecord($this->scope, $this->sharedState, $logRecord);
 
-        $this->assertSame($scope, $record->getInstrumentationScope());
-        $this->assertSame($resource, $record->getResource());
+        $this->assertSame($this->scope, $record->getInstrumentationScope());
+        $this->assertSame($this->resource, $record->getResource());
         $this->assertSame(11, $record->getTimestamp());
         $this->assertSame(22, $record->getObservedTimestamp());
-        $this->assertSame($context, $record->getContext());
+        $this->assertSame($this->context, $record->getContext());
         $this->assertSame(5, $record->getSeverityNumber());
         $this->assertSame('info', $record->getSeverityText());
         $this->assertSame('body', $record->getBody());
         $this->assertEquals(['foo' => 'bar'], $record->getAttributes()->toArray());
     }
+
+    /**
+     * @group logs-compliance
+     */
+    public function test_log_record_can_accept_complex_attributes(): void
+    {
+        $homogeneous = [1,2,3,4,5];
+        $heterogeneous = ['one', 2, 3.14, true];
+        $complex = ['foo' => ['bar' => 'baz', 'bat' => 3.14]];
+        $logRecord = (new LogRecord())
+            ->setAttribute('homogeneous', $homogeneous)
+            ->setAttribute('heterogeneous', $heterogeneous)
+            ->setAttribute('complex', $complex);
+
+        $record = new ReadableLogRecord($this->scope, $this->sharedState, $logRecord);
+        $this->assertSame(0, $record->getAttributes()->getDroppedAttributesCount());
+        $this->assertSame($complex, $record->getAttributes()->get('complex'));
+        $this->assertSame($homogeneous, $record->getAttributes()->get('homogeneous'));
+        $this->assertSame($heterogeneous, $record->getAttributes()->get('heterogeneous'));
+    }
 }